Merge branch 'master' into develop

This commit is contained in:
Pieter Vander Vennet 2024-09-24 14:30:55 +02:00
commit e797e1dcbc
12 changed files with 170 additions and 254 deletions

View file

@ -1,221 +0,0 @@
Architecture
============
This document aims to give an architectural overview of how MapComplete is built. It should give some feeling on how
everything fits together.
Servers?
--------
There are no servers for MapComplete, all services are configured by third parties.
Minimal HTML - Minimal CSS
--------------------------
There is quasi no HTML. Most of the components are generated by TypeScript and attached dynamically. The HTML is a
barebones skeleton which serves every theme.
The UIEventSource
-----------------
Most (but not all) objects in MapComplete get all the state they need as a parameter in the constructor. However, as is
the case with most graphical applications, there are quite some dynamical values.
All values which change regularly are wrapped into
a [`UIEventSource`](../Logic/UIEventSource.ts). A `UIEventSource` is a
wrapper containing a value and offers the possibility to add a callback function which is called every time the value is
changed (with `setData`)
Furthermore, there are various helper functions, the most widely used one being `map` - generating a new event source
with the new value applied. Note that `map` will also absorb some changes,
e.g. `const someEventSource : UIEventSource<string[]> = ... ; someEventSource.map(list = list.length)` will only trigger
when the length of the list has changed.
An object which receives a `UIEventSource` is responsible of responding to changes of this object. This is especially
true for UI-components.
UI
--
```typescript
export default class MyComponent {
constructor(neededParameters, neededUIEventSources) {
}
}
```
The Graphical User Interface is composed of various UI-elements. For every UI-element, there is a `BaseUIElement` which creates the actual `HTMLElement` when needed.
There are some basic elements, such as:
- `FixedUIElement` which shows a fixed, unchangeable element
- `Img` to show an image
- `Combine` which wraps everything given (strings and other elements) in a div
- `List`
There is one special component: the `VariableUIElement`
The `VariableUIElement` takes a `UIEventSource<string|BaseUIElement>` and will dynamically show whatever the `UIEventSource` contains at the moment.
For example:
```typescript
const src : UIEventSource<string> = ... // E.g. user input, data that will be updated... new VariableUIElement(src)
.AttachTo('some-id') // attach it to the html
```
Note that every component offers support for `onClick( someCallBack)`
### Translations
To add a translation:
1. Open `langs/en.json`
2. Find a correct spot for your translation in the tree
3. run `npm run generate:translations`
4. `import Translations`
5. `Translations.t.<your-translation>.Clone()` is the `UIElement` offering your translation
### Input elements
Input elements are a special kind of BaseElement which offer a piece of a form to the user, e.g. a TextField, a Radio button, a dropdown, ...
The constructor will ask all the parameters to configure them. The actual value can be obtained via `inputElement.GetValue()`, which is a `UIEventSource` that will be triggered every time the user changes the input.
### Advanced elements
There are some components which offer useful functionality:
- The `subtleButton` which is a friendly, big button
- The Toggle: `const t = new Toggle( componentA, componentB, source)` is a `UIEventSource` which shows `componentA` as long as `source` contains `true` and will show `componentB` otherwise.
### Styling
Styling is done as much as possible with [TailwindCSS](https://tailwindcss.com/). It contains a ton of utility classes, each of them containing a few rules.
For example: ` someBaseUIElement.SetClass("flex flex-col border border-black rounded-full")` will set the component to be a flex object, as column, with a black border and pill-shaped.
If Tailwind is not enough, use `baseUiElement.SetStyle("background: red; someOtherCssRule: abc;")`.
### An example
For example: the user should input whether or not a shop is closed during public holidays. There are three options:
1. closed
2. opened as usual
3. opened with different hours as usual
In the case of different hours, input hours should be too.
This can be constructed as following:
```typescript
// We construct the dropdown element with values and labelshttps://tailwindcss.com/
const isOpened = new Dropdown<string>(Translations.t.is_this_shop_opened_during_holidays,
[
{ value: "closed", Translation.t.shop_closed_during_holidays.Clone()},
{ value: "open", Translations.t.shop_opened_as_usual.Clone()},
{ value: "hours", Translations.t.shop_opened_with_other_hours.Clone()}
] )
const startHour = new DateInput(...)drop
const endHour = new DateInput( ... )
// We construct a toggle which'll only show the extra questions if needed
const extraQuestion = new Toggle(
new Combine([Translations.t.openFrom, startHour, Translations.t.openTill, endHour]),
undefined,
isOpened.GetValue().map(isopened => isopened === "hours")
)
return new Combine([isOpened, extraQuestion])
```
### Constructing a special class
If you make a specialized class to offer a certain functionality, you can organize it as following:
1. Create a new class:
```typescript
export default class MyComponent {
constructor(neededParameters, neededUIEventSources) {
}
}
```
2. Construct the needed UI in the constructor
```typescript
export default class MyComponent {
constructor(neededParameters, neededUIEventSources) {
const component = ...
const toggle = ...
... other components ...
toggle.GetValue.AddCallbackAndRun(isSelected => { .. some actions ... }
new Combine([everything, ...] )
}
}
```
3. You'll notice that you'll end up with one certain component (in this example the combine) to wrap it all together. Change the class to extend this type of component and use `super()` to wrap it all up:
```typescript
export default class MyComponent extends Combine {
constructor(...) {
...
super([everything, ...])
}
}
```
Assets
------
### Themes
Theme and layer configuration files go into `assets/layers` and `assets/themes`.
### Images
Other files (mostly images that are part of the core of MapComplete) go into `assets/svg` and are usable with `Svg.image_file_ui()`. Run `npm run generate:images` if you added a new image.
Logic
-----
The last part is the business logic of the application, found in the directory [Logic](../Logic). Actors are small objects which react to `UIEventSources` to update other eventSources.
`State.state` is a big singleton object containing a lot of the state of the entire application. That one is a bit of a mess.

View file

@ -6835,6 +6835,13 @@
}
}
},
"4": {
"options": {
"0": {
"question": "Sollte {search} in jedem Kommentar erwähnen"
}
}
},
"5": {
"options": {
"0": {
@ -6855,6 +6862,20 @@
"question": "<b>Nicht</b> erstellt von {search}"
}
}
},
"8": {
"options": {
"0": {
"question": "Bearbeitet oder kommentiert von jedem Benutzer mit dem Namen {search}"
}
}
},
"9": {
"options": {
"0": {
"question": "Zuletzt bearbeitet von {search}"
}
}
}
},
"name": "OpenStreetMap-Hinweise",

View file

@ -592,7 +592,7 @@
"li1": "neem een foto met een enkel blad",
"li2": "neem een foto die de schors toont",
"li3": "neem een foto van de bloesems",
"li4": "neem een foto van de fruits"
"li4": "neem een foto van het fruit"
},
"loadingWikidata": "Informatie over {species} aan het laden",
"matchPercentage": "{match}% overeenkomst",

View file

@ -96,6 +96,10 @@
"shortDescription": "Mapa laviček",
"title": "Lavičky"
},
"bicycle_parkings": {
"description": "Mapa všech typů parkovišť pro jízdní kola",
"title": "Parkování jízdních kol"
},
"bicycle_rental": {
"description": "Na této mapě najdete půjčovny jízdních kol, jak jsou uvedeny v OpenStreetMap",
"shortDescription": "Mapa se stanicemi a obchody pro vypůjčení kol",
@ -151,6 +155,17 @@
"shortDescription": "Celosvětová mapa nabíjecích stanic",
"title": "Nabíjecí stanice"
},
"circular_economy": {
"description": "Různé předměty, které pomáhají lidem sdílet, znovu používat nebo recyklovat.",
"layers": {
"5": {
"override": {
"=name": "Obchody s použitým zbožím"
}
}
},
"title": "Cirkulární ekonomika"
},
"climbing": {
"description": "Na této mapě najdete nejrůznější možnosti lezení, jako lezecké tělocvičny, boulderingové haly a skály v přírodě.",
"descriptionTail": "Horolezeckou mapu původně vytvořil <a href='https://utopicode.de/en/?ref=kletterspots' target='_blank'>Christian Neumann</a>. V případě připomínek nebo dotazů ho prosím <a href='https://utopicode.de/en/contact/?project=kletterspots&ref=kletterspots' target='blank'>kontaktujte</a>.</p><p>Projekt využívá data projektu <a href='https://www.openstreetmap.org/' target='_blank'>OpenStreetMap</a>.</p>",
@ -273,6 +288,11 @@
},
"1": {
"name": "uzly",
"presets": {
"0": {
"title": "cyklistický uzel"
}
},
"tagRenderings": {
"node-expected_rcn_route_relations": {
"freeform": {
@ -299,6 +319,9 @@
"mappings": {
"0": {
"then": "uzel cyklu <strong>{rcn_ref}</strong>"
},
"1": {
"then": "Navrhovaný cyklistický uzel <strong>{proposed:rcn_ref}</strong>"
}
},
"render": "uzel cyklu"
@ -316,7 +339,7 @@
"override": {
"presets": {
"0": {
"title": "značka trasy pro spojení mezi uzlem"
"title": "značka trasy pro spojení mezi uzly"
}
}
}
@ -409,9 +432,13 @@
"title": "Cyklostezky"
},
"cyclofix": {
"description": "Cílem této mapy je představit cyklistům snadno použitelné řešení pro vyhledání vhodné infrastruktury pro jejich potřeby.<br><br>Můžete sledovat svou přesnou polohu (pouze pro mobilní zařízení) a v levém dolním rohu vybrat vrstvy, které jsou pro vás relevantní. Pomocí tohoto nástroje můžete také přidávat nebo upravovat špendlíky (body zájmu) do mapy a poskytovat další údaje pomocí odpovědí na otázky.<br><br>Všechny vámi provedené změny se automaticky uloží do globální databáze OpenStreetMap a mohou být volně znovu použity ostatními.<br><br>Další informace o projektu cyklofix najdete na <a href='https://cyclofix.osm.be/'>cyclofix.osm.be</a>.",
"description": "Mapa pro cyklisty, kde najdou vhodnou infrastrukturu pro své potřeby, jako jsou pumpy na kola, pitná voda, cyklistické obchody, opravny nebo parkoviště.",
"title": "Cyklofix - mapa pro cyklisty"
},
"disaster_response": {
"description": "Tato mapa obsahuje prvky určené pro připravenost na katastrofy a reakci na ně.",
"title": "Reakce na katastrofy a záchranné služby"
},
"drinking_water": {
"description": "Na této mapě jsou zobrazena veřejně přístupná místa s pitnou vodou, která lze snadno přidat",
"title": "Pitná voda"
@ -449,7 +476,7 @@
},
"5": {
"override": {
"=name": "Toursistická místa bez etymologických informací"
"=name": "Turistická místa bez etymologických informací"
}
},
"6": {
@ -464,7 +491,7 @@
}
},
"shortDescription": "Jaký je původ toponyma?",
"title": "Etymologie - podle čeho se ulice jmenuje?"
"title": "Etymologie - podle čeho je místo pojmenováno?"
},
"facadegardens": {
"description": "<a href='https://nl.wikipedia.org/wiki/Geveltuin' target=_blank>Fasádní zahrady</a>, zelené fasády a stromy ve městě přinášejí nejen klid a pohodu, ale také krásnější město, větší biodiverzitu, ochlazující efekt a lepší kvalitu ovzduší. <br/> Klimaan VZW a Mechelen Klimaatneutraal chtějí zmapovat stávající i nové fasádní zahrady jako příklad pro lidi, kteří si chtějí vybudovat vlastní zahradu, nebo pro městské chodce, kteří mají rádi přírodu.<br/>Více informací o projektu najdete na <a href='https://klimaan.be/' target=_blank>klimaan.be</a>.",
@ -553,6 +580,10 @@
"shortDescription": "Tato mapa zobrazuje fasádní zahrady s obrázky a užitečnými informacemi o orientaci, oslunění a druzích rostlin.",
"title": "Fasádní zahrady"
},
"fireplace": {
"description": "Venkovní místo pro rozdělání ohně nebo grilování na oficiálním místě.",
"title": "Ohniště a grily"
},
"food": {
"description": "Restaurace a rychlého občerstvení",
"title": "Restaurace a rychlé občerstvení"
@ -562,6 +593,21 @@
"layers": {
"0": {
"override": {
"filter+": {
"0": {
"options": {
"0": {
"question": "Žádný preferovaný typ oleje"
},
"1": {
"question": "Zobrazit pouze jídla smažená na rostlinném oleji"
},
"2": {
"question": "Zobrazit pouze jídla smažená na živočišném oleji"
}
}
}
},
"name": "Obchod s hranolky"
}
}
@ -572,6 +618,51 @@
"description": "<b>Kolo duchů</b> je památník pro cyklisty, kteří zemřeli při dopravní nehodě, ve formě bílého kola trvale umístěného poblíž místa nehody.<br/><br/>Na této mapě je možné vidět všechna kola duchů, která jsou známa OpenStreetMap. Chybí na mapě nějaké? Každý může přidat nebo aktualizovat informace zde - stačí mít pouze (bezplatný) účet OpenStreetMap. <p>Na Mastodonu existuje <a href='https://masto.bike/@ghostbikebot' target='_blank'>automatizovaný účet, který posílá měsíční přehled kol duchů po celém světě</a></p>",
"title": "Kola duchů"
},
"ghostsigns": {
"description": "Mapa zobrazující nepoužívané nápisy na budovách",
"layers": {
"0": {
"description": "Vrstva zobrazující nepoužívané nápisy na budovách",
"name": "Nápisy na zdech",
"presets": {
"0": {
"title": "nápis na zdi"
}
},
"tagRenderings": {
"brand": {
"freeform": {
"placeholder": "Název firmy"
},
"question": "Pro jaký účel byla tato značka vyrobena?",
"render": "Tato cedule byla vyrobena pro: {brand}"
}
}
},
"2": {
"override": {
"+tagRenderings": {
"0": {
"mappings": {
"0": {
"then": "Toto dílo je historickou reklamou"
},
"1": {
"then": "Toto dílo není historickou reklamou"
}
},
"question": "Je toto dílo historickou reklamou?"
}
}
}
}
},
"title": "Nápisy na zdech"
},
"glutenfree": {
"description": "Mapa s bezlepkovými položkami vytvořená pomocí crowdsourcingu",
"title": "Bez lepku"
},
"grb": {
"description": "Toto téma je pokusem o automatizaci importu GRB.",
"layers": {
@ -648,6 +739,10 @@
"description": "Na této mapě jsou zobrazeny veřejně přístupné vnitřní prostory",
"title": "Vnitřní prostory"
},
"items_with_image": {
"description": "Mapa zobrazující všechny položky v OSM, které mají obrázek. Toto téma je pro MapComplete velmi nevhodné, protože někdo nemůže přímo přidat obrázek. Nicméně toto téma je zde hlavně proto, aby to vše zahrnovalo do databáze, což umožní rychle načítat obrázky v okolí pro další funkce",
"title": "Všechny položky s obrázky"
},
"kerbs_and_crossings": {
"description": "Mapa zobrazující obrubníky a přechody.",
"layers": {
@ -664,6 +759,10 @@
},
"title": "Obrubníky a přechody"
},
"lactosefree": {
"description": "Mapa bezlaktózových obchodů a restaurací vytvořená crowdsourcingem",
"title": "Bezlaktózové obchody a restaurace"
},
"maproulette": {
"description": "Téma zobrazující úkoly MapRoulette, které umožňuje vyhledávat, filtrovat a opravovat je.",
"title": "Úkoly MapRoulette"
@ -1050,7 +1149,7 @@
"title": "Odpad"
},
"waste_basket": {
"description": "Na této mapě najdete koše na odpadky ve vašem okolí. Pokud na této mapě odpadkový koš chybí, můžete jej přidat sami",
"description": "Na této mapě najdete koše na odpadky ve vašem okolí. Pokud na této mapě odpadkový koš chybí, můžete jej přidat sami.",
"shortDescription": "Mapa odpadkových košů",
"title": "Odpadkový koš"
}

View file

@ -392,7 +392,7 @@ export class ChangesetHandler {
return [
["created_by", `MapComplete ${Constants.vNumber}`],
["locale", Locale.language.data],
["host", `${window.location.origin}${window.location.pathname}`],
["host", `${window.location.origin}${window.location.pathname}`], // Note: deferred changes might give a different hostpath then the theme with which the changes were made
["source", setSourceAsSurvey ? "survey" : undefined],
["imagery", this.changes.state["backgroundLayer"]?.data?.id],
].map(([key, value]) => ({

View file

@ -36,7 +36,7 @@ export default class FilteredLayer {
constructor(
layer: LayerConfig,
appliedFilters?: ReadonlyMap<string, UIEventSource<undefined | number | string>>,
isDisplayed?: UIEventSource<boolean>
isDisplayed?: UIEventSource<boolean>,
) {
this.layerDef = layer
this.isDisplayed = isDisplayed ?? new UIEventSource(true)
@ -82,25 +82,25 @@ export default class FilteredLayer {
layer: LayerConfig,
context: string,
osmConnection: OsmConnection,
enabledByDefault?: Store<boolean>
enabledByDefault?: Store<boolean>,
) {
let isDisplayed: UIEventSource<boolean>
if (layer.syncSelection === "local") {
isDisplayed = LocalStorageSource.GetParsed(
context + "-layer-" + layer.id + "-enabled",
layer.shownByDefault
layer.shownByDefault,
)
} else if (layer.syncSelection === "theme-only") {
isDisplayed = FilteredLayer.getPref(
osmConnection,
context + "-layer-" + layer.id + "-enabled",
layer
layer,
)
} else if (layer.syncSelection === "global") {
isDisplayed = FilteredLayer.getPref(
osmConnection,
"layer-" + layer.id + "-enabled",
layer
layer,
)
} else {
let isShown = layer.shownByDefault
@ -110,7 +110,7 @@ export default class FilteredLayer {
isDisplayed = QueryParameters.GetBooleanQueryParameter(
FilteredLayer.queryParameterKey(layer),
isShown,
"Whether or not layer " + layer.id + " is shown"
"Whether or not layer " + layer.id + " is shown",
)
}
@ -134,13 +134,18 @@ export default class FilteredLayer {
/**
* import Translations from "../UI/i18n/Translations"
* import { RegexTag } from "../Logic/Tags/RegexTag"
* import { ComparingTag } from "../Logic/Tags/ComparingTag"
*
* const option: FilterConfigOption = {question: Translations.T("question"), osmTags: undefined, originalTagsSpec: "key~.*{search}.*", fields: [{name: "search", type: "string"}] }
* FilteredLayer.fieldsToTags(option, {search: "value_regex"}) // => new RegexTag("key", /^(.*(value_regex).*)$/s)
*
* const option: FilterConfigOption = {question: Translations.T("question"), searchTerms: undefined, osmTags: undefined, originalTagsSpec: "edit_time>{search}", fields: [{name: "search", type: "date"}] }
* const comparingTag = FilteredLayer.fieldsToTags(option, {search: "2024-09-20"})
* comparingTag.asJson() // => "edit_time>1726790400000"
*/
private static fieldsToTags(
option: FilterConfigOption,
fieldstate: string | Record<string, string>
fieldstate: string | Record<string, string>,
): TagsFilter | undefined {
let properties: Record<string, string>
if (typeof fieldstate === "string") {
@ -160,7 +165,12 @@ export default class FilteredLayer {
}
for (const key in properties) {
v = (<string>v).replace("{" + key + "}", "(" + properties[key] + ")")
const needsParentheses = v.match(/[a-zA-Z0-9_:]+~/)
if (needsParentheses) {
v = (<string>v).replace("{" + key + "}", "(" + properties[key] + ")")
} else {
v = (<string>v).replace("{" + key + "}", properties[key])
}
}
return v
@ -171,7 +181,7 @@ export default class FilteredLayer {
private static getPref(
osmConnection: OsmConnection,
key: string,
layer: LayerConfig
layer: LayerConfig,
): UIEventSource<boolean> {
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
(v) => {
@ -186,7 +196,7 @@ export default class FilteredLayer {
return undefined
}
return "" + b
}
},
)
}

View file

@ -68,7 +68,7 @@ export abstract class Validator {
}
public getPlaceholder() {
return Translations.t.validation[this.name].description
return Translations.t.validation[this.name]?.description
}
public isValid(_: string, getCountry?: () => string): boolean {

View file

@ -18,7 +18,7 @@ import Filterview from "./BigComponents/Filterview.svelte"
import FilteredLayer from "../Models/FilteredLayer"
import { SubtleButton } from "./Base/SubtleButton"
import { GeoOperations } from "../Logic/GeoOperations"
import { Polygon } from "geojson"
import { FeatureCollection, Polygon } from "geojson"
import { Feature } from "geojson"
class StatsticsForOverviewFile extends Combine {
@ -30,7 +30,9 @@ class StatsticsForOverviewFile extends Combine {
new Title("Filters"),
new SvelteUIElement(Filterview, { filteredLayer }),
])
filteredLayer.currentFilter.addCallbackAndRun(tf => {
console.log("Filters are", tf)
})
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
for (const filepath of paths) {

View file

@ -39,16 +39,20 @@
}
function fusePath(subpartPath: string[]): (string | number)[] {
const newPath = [...path]
const toAdd = [...subpartPath]
for (const part of path) {
if (toAdd[0] === part) {
toAdd.splice(0, 1)
} else {
break
const newPath = [...path] // has indices, e.g. ["A", 1, "B", "C", 2]
const toAdd = [...subpartPath] // doesn't have indices, e.g. ["A", "B", "C", "D"]
let indexInToAdd = 0
for (let i = 0; i < newPath.length; i++) {
if(newPath[i] === toAdd[indexInToAdd]){
indexInToAdd ++
}
}
newPath.push(...toAdd)
// indexToAdd should now point to the last common index, '2' in the example
const resting = toAdd.slice(indexInToAdd)
newPath.push(...resting)
return newPath
}

View file

@ -204,7 +204,7 @@ export abstract class EditJsonState<T> {
for (let i = 0; i < path.length - 1; i++) {
const breadcrumb = path[i]
if (entry[breadcrumb] === undefined) {
if (entry[breadcrumb] === undefined || entry[breadcrumb] === null) {
if (isUndefined) {
// we have a dead end _and_ we do not need to set a value - we do an early return
return

View file

@ -105,7 +105,7 @@
</div>
{/each}
{:else}
<Accordion>
<Accordion> <!-- The CollapsedTagRenderingPreview contains the accordeon items -->
{#each $currentValue as value, i}
<CollapsedTagRenderingPreview
{state}

View file

@ -107,6 +107,7 @@
placeholder="The key of the tag"
type="key"
value={keyValue}
autofocus
on:submit
/>
<select