Merge pull request #2156 from pietervdvn/feature/search

Feature/search
This commit is contained in:
Pieter Vander Vennet 2024-09-17 02:57:59 +02:00 committed by GitHub
commit 9382b74b07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 5525 additions and 2276 deletions

View file

@ -10,6 +10,9 @@
"type": "UI", "type": "UI",
"section": "User interface improvements" "section": "User interface improvements"
}, },
{"type": "Search",
"section": "Search related features"
},
{"type": "chore", "hidden": true}, {"type": "chore", "hidden": true},
{"type": "docs", "hidden": true}, {"type": "docs", "hidden": true},
{"type": "style", "hidden": true}, {"type": "style", "hidden": true},

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

View file

@ -0,0 +1,115 @@
# Research: search functionality
How do various mapping platforms offer the 'search'-functionality?
This research in preparation for the search functionality
## Google
The search bar is visible from the start at the top left.
When the search bar is focused, the 'recent'-searches are shown.
When typing a category, options for this category are listed in the sidebar and on the map.
Hovering over them will shop a pin on their location on the map.
![](./Google0.png)
Note that filters are suggested on the top left.
When clicking a docter, another side bar (more to the middle) shows the basic info, never obscuring the search results:
![](./Google1.png)
### Mobile
The sidepanels are shown fullscreen, the map view is not visible
## Apple
### Desktop
The newly launched apple Maps has a prominent side bar, showing 'search', 'guides' and 'directions'.
Clicking the search opens another sidebar, which features:
1. The search bar
2. Recently searched
3. Find Nearby (with some common POI categories)
![](./Apple0.png)
Starting to type a search will offer some options under the searchbar; searching results in many options.
This searches nearby (< 100km) and shows a not very obvious pin on the map.
Hovering over an option in the UI has no effect on the map.
![](./Apple1.png)
![](./Apple2.png)
Searching a place will open a new sidebar, with some basic information, e.g. the wikipedia article and some pictures
### Mobile
The browser version is not supported on small screens - except on iPhones.
Luckily, there are [plenty online ](https://www.youtube.com/watch?v=m6p3nGzHPUk)[tutorials around](https://www.youtube.com/watch?v=hH1uV1jXY58)
A similar pattern appears here, even though, when selecting an option from the search result list, this option will receive a huge pin.
## Bing
### Desktop
Very similar to google/Apple
![](./Bing0.png)
A popup on the map is shown on hover, but utterly useless as _unhovering_ the entry will hide the popup.
This popup is also triggered when hovering the pin, but here it _is_ possible to move into the popup.
However, clicking the entry or pin will open the entry for the POI:
![](./Bing1.png)
### Mobile
(Note: trying the responsive design doesn't properly work)
A bottom card shows all the entries, the map view is completely hidden
## OpenStreetMap
A simple list with entries is shown on the left, hovering an entry will reveal the location on the map
![](./OSM0.png)
## Here Maps (Here We Go)
(Note: does not work on librewolf)
This offers a very clean, minimalistic approach with cold colours.
![](./Here0.png)
![](./Here1.png)
![](./Here2.png)
Note that search pins where _two_ entries are at the same location, get a different colour.
## OsmAnd
OsmAnd is not known for it's userfriendliness.
The flow is:
1. Select the search button
2. A screen is opened with a search button on top + tab with recents, categories, ...
3. Typing text will search addresses, placenames and categories
4. Selecting a category will show them as dot on the map
# Other considerations
Support for coordinates (some always interpret lat, lon) and optional reverse geocoding (#1599)
Support for OSM-ids (e.g. node/123, https://osm.org/node/123) (#1671)
Support for layers (categories) and/or switching themes
Switch themes (or layers) via search (https://github.com/pietervdvn/MapComplete/issues/1385)
Open the popup on exact match (https://github.com/pietervdvn/MapComplete/issues/1385)
https://github.com/pietervdvn/MapComplete/issues/1480

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

View file

@ -0,0 +1,220 @@
[# User Test of the new (WIP) search functionality
## Background info
The participant has average computer knowledge and knows a bit about OSM; has used MapComplete before. They had recently eaten in a small restaurant which they enjoyed.
Browser: Dev machine with dev account (Librewolf/Firefox) + participants mobile phone (Android with DuckDuckGo-browser)
Testurl: pietervdvn.github.io/mc/feature/search/food.html
## Task
The main goal is to test the new Search-functionality and to validate the UI; the secondary goal is to also validate the general UI.
For this, the following tasks were given:
[MapComplete was opened with the 'food' theme, showing the province]
1. Search the business
2. Give a review and update information
3. Search vegetarian pizzeria's in a city
4. Bonus: Use the search function to search pizzeria's _without_ typing a number
5. Switch to the map of _toilets_ using the search function
6. Ad hoc: link a mapillary streetview image
In between the tasks, time was given to explore and surface more issues.
# Interactions and surfaced issues
## Searching for 'Saladette' and others
The user was asked to search 'Saladette' in Roeselare.
> "Let's use the search function, cause I don't know by heart where Roeselare is"
> "Oh, a hamburger menu! Maybe the search is there"
[x] Failure: Search bar isn't very visible and rather hidden/low contrast: the white searchbar on a yellow basemap with many white-on-black indicators is hidden
Fixed (see #2113)
[x] Failure: the 'theme overview menu' where a search functionality is (again) not considered a button!
Fixed (see #2113)
Observator hinted to the location of the search button
> User clicks search button, an empty result bar appears
[x] Failure: bar shows up if there are no results (was using private navigation)
Search suggestions should be shown!
> User searches for 'Roeselare', but the results are mostly 'Kanaal Roeselare'
[x] Failure: maybe dedup some results, and place e.g. cities higher? Is there a relevancy-metric included?
(Fixed now)
(In a different part of the user test, the user was asked to go to Ghent)
> User types 'Gent' and presses enter
> Gentstraat in Brugge pops up
[ ] Failure: cities should have more priority
> User swipes "back" to go to the previous location
[ ] Failure: Should this work? TBD
> "Fuck it, let's just type 'Saladette'
Success, immediately found! "Dat heeft ie snel"
> User sees the little clock of being closed
[ ] Failure: closed icon not immediately clear; maybe use a different icon?
## Adding a review
> The user opens the info popup of the restaurant
> User sponteanously adds a review
Success: user sponteanously interacts with the questions!
[x] Failure: some terms are still in english, fixed now
> User wants to make a change to the review
[/] Failure: this is not yet possible, tracked in https://github.com/pietervdvn/MapComplete/issues/2129
## Updating information
> The user wants to update information
> User reads the detailed description between "restaurant" and "fastfood" and then wants to change it to "fast food"
> User is not logged in
### Logging in
> User clicks 'login button' and doesn't know password anymore
> User wants to _see_ the password they are typing, and will thus first type it in the URL-bar of a new browsertab, to copy-paste it into the password field
[/] Failure: user cannot show the password they are typing. See https://github.com/openstreetmap/openstreetmap-website/issues/5122
> Password is incorrect, but user doesn't see immediately see this
[/] Failure: error message should be closer to the login form. See https://github.com/openstreetmap/openstreetmap-website/issues/5123
> In the end, the observators OSM-account was used
## Actually using and updating information
> THe user notices that complex opening hours are displayed a bit sloppily
[+] Coincidentally, an issue was opened about precisely this at the same time: https://github.com/pietervdvn/MapComplete/issues/2100
> At first, the user changed the classification from 'restaurant' to 'fastfood' after thoroughly reading through the descriptions
> Then, the 'cuisine' was inspected. As the restaurant they visited is focusing on _vegetarian_ salads, the user wanted to use the freeform to enter 'vegetarian salad'
[ ] Failure: how to properly explain this? Move the 'vegetarian' question up? Should some options, such as 'chicken restaurant' be hidden if `vegetarian=only`?
[ ] UI: issue: the emojis (especially flags) slightly overlaps with the text on this browser
> The user left the 'cuisine' question open and moved on to other questions
> Whenever they answered a question, the UI would jump back to the first open question
[x] Failure: maybe simply remove this scrolling behaviour? (Fixed in d62974b1e3896f887c581ffcbe44488a6de8a9bc)
> User gets confused by having some bold options: "I thought someone already selected option "Lactose free offering"
> (In the popup for a different restaurant)
> User wants to scroll down, but the opening hours picker intercepts the swipe event
[x] Failure: move OH-picker into separate popup
> User wants to remove selected OH
[x] Failure: trash bin is too small, maybe provide a 'clear all' button?
![](./2024-08-26%20Usertest-bold-question.png)
[x] Failure: don't show bold (fixed in b79835074fe5f954bd4b64ecdb713ca13503495e)
> The user also taps the 'phone' icon, upon which the phone app opens with the phone number filled out
> The phone number misses some numbers
[x] browser-specific-bug: phone links should not contain spaces in blink-based browsers, fixed in 4168ef01e333784f738fafa15d1eb7d7c4c527c7
## Using filters through the search menu
> When instructed to search for filters, the user didn't realise that is possible through the search
[x] Failure: search results should show some example filters, cities, layers and other thematic maps when nothing has been shown before
[x] Failure: default text should be changed and broadened and mention more then just 'locations'
> The user attempts to search, but often 'fat-fingers' and presses a shop behind the search bar, opening this
[x] Solution: on mobile, a 'no-touch' buffer should be added; Maybe even a top bar? --> Fixed in feature/menu-drawer
Fixed by #2113
> The user was tasked to search a 'vegetarian pizzeria'
> User literally types "vegetarian pizza", but no filters pop up as the goal was to search for 'pizza' and "vegetarian" separately
> Same for "vegetarisch frietkot"
[x] Failure: filter-search should be split on word
Fixed
> Suggested filters shows up as "This is a pizzeria"
[x] Failure: Unclear text, to be changed (Fixed in 3939d2fe7bb4e6f40abd659372e4d67b457281c3)
[x] Unclear that this is a filter that can be added: subheadings are needed
> User clears the filter, MC hangs as it is re-rendering all items
[x] Failure: show a loading icon - Fixed
## Switching theme
> The user types 'WC' and doesn't find anything
[x] Failure: only 'toilet' is known, fixed in cdc1e05499ffc41d093503ccd24defa347eea50e
> The user sees the 'WC'-theme button, but after a second, it is replaced by other search results
[x] Reorder this, so that slow-loading and fast-loading search queries don't overlap
Fixed by having separate titles
> The user sees 'no results found', which gets replaced by results a few ms later
[x] Have a 'loading-indicator'
> User switches to 'toilets' theme
## Exploring the toilets theme
> User sees different types of icons, many of which are standing-urinals
> "Tiens, those are mostly men toilets. Good to know"
> User notices a "lock"-icon and wonders if this is a non-useable toilet
> User presses toilet and confirms that this is not open to the public
Success: the icons are clear :)
## Using and linking nearby pictures
> The user was pointed to the "See and link nearby images"
> "Wow, linking sounds intimidating"
[x] Note: "and link" also doesn't work if the user isn't logged in
Remove this wording, fixed in weblate
> Mapillary-pictures popup, some images are made by 'Teddy73'
> "What is this 'Teddy73' and this 'CC-BY-SA'-thing?
[x] Failure: attribution is unclear and irritating, made smaller in link-preview and more explicit in image preview
Fixed
> User wants a bigger version of the picture and zooms in onto the low-quality picture
[x] Failure: user doesn't realize that tapping the picture will open up a pannable, big screen and HD version
Fix: add a 'zoom-in' icon, fixed in 8465b59c7f4ece18b830899e9cc7b680ae100c13
> User finds the zoomed-in version, but is confused by the download-button. How to link?
[ ] Failure: move download-button behind "extra"-dot?
[ ] Failure: should some of the tools (e.g. linking and unlinking) be hidden behind a dot?

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -0,0 +1,30 @@
# Search validation: search jewerly stores
Subject:
Tech Skills: Average
Demography: M, 60
Language: NL
Medium: Android phone(s), DuckDuckGo browser + Fennec Browser
User interface language: Nl
## Task
To validate the 'search with filters', the tester was tasked with searching all jewelry stores in their city.
## How it went
1. The user was presented with the 'shops'-map
2. The user read the entire intro ("a bit a long text")
3. Searches for "juwelen" in the search field, which matches "juwelierszaak" and "modeaccessoirewinkel"
Success! Combination of the iD presets (with search keywords) with fuzzy matching works
4. Users clicks on the filter, but this seemingly freezes the application as nothing happens. User clicks a few more times
Fixed: shows a 'loading'-icon now
Fixed: on mobile, the search bar should collapse automatically so that the now-filtered items become visible
5. Closing the search bar: works somewhat good
6. Searching for a location: works good
## To improve
[x] Why are there multiple "Open Now" filters? Because of different layers with a similar filter, they are now shown merged
[x] Special layers (e.g. gps location) are disabled as well (fixed now)

View file

@ -338,6 +338,7 @@
"tagRenderings": [ "tagRenderings": [
"shops.*" "shops.*"
], ],
"#filter": "no-auto",
"filter": [ "filter": [
{ {
"id": "sells_second-hand", "id": "sells_second-hand",
@ -384,7 +385,8 @@
} }
} }
] ]
} },
"open_now"
], ],
"deletion": true, "deletion": true,
"allowMove": true "allowMove": true

View file

@ -24,6 +24,23 @@
"ca": "Una capa que mostra fonts d'aigua potable", "ca": "Una capa que mostra fonts d'aigua potable",
"cs": "Vrstva zobrazující fontány s pitnou vodou" "cs": "Vrstva zobrazující fontány s pitnou vodou"
}, },
"searchTerms": {
"en": [
"drink",
"water",
"fountain",
"bubbler"
],
"nl": [
"drinken",
"water",
"drinkwater",
"waterfontein",
"fontein",
"kraan",
"kraantje"
]
},
"source": { "source": {
"osmTags": { "osmTags": {
"and": [ "and": [

View file

@ -1,6 +1,7 @@
{ {
"#":"no-translations", "#":"no-translations",
"#dont-translate": "*", "#dont-translate": "*",
"#filter": "no-auto",
"pointRendering": [ "pointRendering": [
{ {
"location": [ "location": [

View file

@ -9,6 +9,7 @@
"id": "open_now", "id": "open_now",
"options": [ "options": [
{ {
"emoji": "⏰",
"question": { "question": {
"en": "Open now", "en": "Open now",
"nl": "Nu open", "nl": "Nu open",
@ -32,6 +33,7 @@
"id": "accepts_cash", "id": "accepts_cash",
"options": [ "options": [
{ {
"emoji": "🪙",
"osmTags": "payment:cash=yes", "osmTags": "payment:cash=yes",
"question": { "question": {
"en": "Accepts cash", "en": "Accepts cash",
@ -50,6 +52,7 @@
"id": "accepts_cards", "id": "accepts_cards",
"options": [ "options": [
{ {
"emoji": "💳",
"osmTags": "payment:cards=yes", "osmTags": "payment:cards=yes",
"question": { "question": {
"en": "Accepts payment cards", "en": "Accepts payment cards",
@ -68,6 +71,7 @@
"id": "accepts_debit_cards", "id": "accepts_debit_cards",
"options": [ "options": [
{ {
"emoji": "💳",
"osmTags": "payment:debit_cards=yes", "osmTags": "payment:debit_cards=yes",
"question": { "question": {
"en": "Accepts debit cards", "en": "Accepts debit cards",
@ -83,6 +87,7 @@
"id": "accepts_credit_cards", "id": "accepts_credit_cards",
"options": [ "options": [
{ {
"emoji": "💳",
"osmTags": "payment:credit_cards=yes", "osmTags": "payment:credit_cards=yes",
"question": { "question": {
"en": "Accepts credit cards", "en": "Accepts credit cards",
@ -268,6 +273,7 @@
{ {
"question": { "question": {
"en": "No preference towards dogs", "en": "No preference towards dogs",
"nl": "Geen voorkeur voor honden",
"de": "Keine Bevorzugung von Hunden", "de": "Keine Bevorzugung von Hunden",
"cs": "Bez preference psů" "cs": "Bez preference psů"
} }
@ -275,22 +281,27 @@
{ {
"question": { "question": {
"en": "Dogs allowed", "en": "Dogs allowed",
"nl": "Honden toegelaten",
"de": "Hunde erlaubt", "de": "Hunde erlaubt",
"cs": "Psi povoleny" "cs": "Psi povoleny"
}, },
"emoji": "🐕",
"osmTags": { "osmTags": {
"or": [ "or": [
"dog=unleashed", "dog=unleashed",
"dog=yes" "dog=yes"
] ]
} },
"icon": "./assets/layers/questions/dogs_allowed.svg"
}, },
{ {
"question": { "question": {
"en": "No dogs allowed", "en": "No dogs allowed",
"nl": "Geen honden toegelaten",
"de": "Keine Hunde erlaubt", "de": "Keine Hunde erlaubt",
"cs": "Psi nejsou povoleni" "cs": "Psi nejsou povoleni"
}, },
"icon": "./assets/layers/questions/no_dogs.svg",
"osmTags": "dog=no" "osmTags": "dog=no"
} }
] ]
@ -304,6 +315,7 @@
"de": "Internetzugang vorhanden", "de": "Internetzugang vorhanden",
"cs": "Nabízí internet" "cs": "Nabízí internet"
}, },
"icon": "wifi",
"osmTags": { "osmTags": {
"or": [ "or": [
"internet_access=wlan", "internet_access=wlan",
@ -355,6 +367,7 @@
"cs": "Má bezlepkovou nabídku", "cs": "Má bezlepkovou nabídku",
"de": "Hat glutenfreie Angebote" "de": "Hat glutenfreie Angebote"
}, },
"icon": "./assets/layers/questions/glutenfree.svg",
"osmTags": { "osmTags": {
"or": [ "or": [
"diet:gluten_free=yes", "diet:gluten_free=yes",
@ -374,6 +387,7 @@
"cs": "Má nabídku bez laktózy", "cs": "Má nabídku bez laktózy",
"de": "Hat laktosefreie Angebote" "de": "Hat laktosefreie Angebote"
}, },
"icon": "./assets/layers/questions/lactose_free.svg",
"osmTags": { "osmTags": {
"or": [ "or": [
"diet:lactose_free=yes", "diet:lactose_free=yes",

View file

@ -342,6 +342,7 @@
"mappings": [ "mappings": [
{ {
"if": "cuisine=pizza", "if": "cuisine=pizza",
"icon": "🍕",
"then": { "then": {
"en": "Pizzeria", "en": "Pizzeria",
"nl": "Pizzeria", "nl": "Pizzeria",
@ -355,6 +356,7 @@
}, },
{ {
"if": "cuisine=friture", "if": "cuisine=friture",
"icon": "🍟",
"then": { "then": {
"en": "Friture", "en": "Friture",
"nl": "Frituur", "nl": "Frituur",
@ -366,6 +368,7 @@
}, },
{ {
"if": "cuisine=pasta", "if": "cuisine=pasta",
"icon": "🍝",
"then": { "then": {
"en": "Serves mainly pasta", "en": "Serves mainly pasta",
"nl": "Pastazaak", "nl": "Pastazaak",
@ -379,6 +382,7 @@
}, },
{ {
"if": "cuisine=kebab", "if": "cuisine=kebab",
"icon": "🥙",
"then": { "then": {
"en": "Kebab shop", "en": "Kebab shop",
"nl": "Kebabzaak", "nl": "Kebabzaak",
@ -392,6 +396,7 @@
}, },
{ {
"if": "cuisine=sandwich", "if": "cuisine=sandwich",
"icon": "🥪",
"then": { "then": {
"en": "Sandwich shop", "en": "Sandwich shop",
"nl": "Broodjeszaak", "nl": "Broodjeszaak",
@ -403,6 +408,7 @@
}, },
{ {
"if": "cuisine=burger", "if": "cuisine=burger",
"icon": "🍔",
"then": { "then": {
"en": "Burgersrestaurant", "en": "Burgersrestaurant",
"nl": "Hamburgerrestaurant", "nl": "Hamburgerrestaurant",
@ -415,6 +421,7 @@
}, },
{ {
"if": "cuisine=sushi", "if": "cuisine=sushi",
"icon": "🍣",
"then": { "then": {
"en": "Sushi restaurant", "en": "Sushi restaurant",
"nl": "Sushirestaurant", "nl": "Sushirestaurant",
@ -427,6 +434,7 @@
}, },
{ {
"if": "cuisine=coffee", "if": "cuisine=coffee",
"icon": "☕",
"then": { "then": {
"en": "Coffeebar", "en": "Coffeebar",
"nl": "Koffiezaak", "nl": "Koffiezaak",
@ -439,6 +447,7 @@
}, },
{ {
"if": "cuisine=italian", "if": "cuisine=italian",
"icon": "🇮🇹",
"then": { "then": {
"en": "Italian restaurant (which serves more than pasta and pizza)", "en": "Italian restaurant (which serves more than pasta and pizza)",
"nl": "Italiaans restaurant (dat meer dan enkel pasta of pizza verkoopt)", "nl": "Italiaans restaurant (dat meer dan enkel pasta of pizza verkoopt)",
@ -451,6 +460,7 @@
}, },
{ {
"if": "cuisine=french", "if": "cuisine=french",
"icon": "🇫🇷",
"then": { "then": {
"en": "French restaurant", "en": "French restaurant",
"nl": "Frans restaurant", "nl": "Frans restaurant",
@ -463,6 +473,7 @@
}, },
{ {
"if": "cuisine=chinese", "if": "cuisine=chinese",
"icon": "🇨🇳",
"then": { "then": {
"en": "Chinese", "en": "Chinese",
"nl": "Chinees restaurant", "nl": "Chinees restaurant",
@ -475,6 +486,7 @@
}, },
{ {
"if": "cuisine=greek", "if": "cuisine=greek",
"icon": "🇬🇷",
"then": { "then": {
"en": "Greek", "en": "Greek",
"nl": "Grieks restaurant", "nl": "Grieks restaurant",
@ -487,6 +499,7 @@
}, },
{ {
"if": "cuisine=indian", "if": "cuisine=indian",
"icon": "🇮🇳",
"then": { "then": {
"en": "Indian", "en": "Indian",
"nl": "Indisch restaurant", "nl": "Indisch restaurant",
@ -499,6 +512,7 @@
}, },
{ {
"if": "cuisine=turkish", "if": "cuisine=turkish",
"icon": "🇹🇷",
"then": { "then": {
"en": "Turkish restaurant", "en": "Turkish restaurant",
"nl": "Turks restaurant", "nl": "Turks restaurant",
@ -511,6 +525,7 @@
}, },
{ {
"if": "cuisine=thai", "if": "cuisine=thai",
"icon": "🇹🇭",
"then": { "then": {
"en": "Thai restaurant", "en": "Thai restaurant",
"nl": "Thaïs restaurant", "nl": "Thaïs restaurant",
@ -519,9 +534,42 @@
"ca": "Aquí es serveixen plats tailandesos", "ca": "Aquí es serveixen plats tailandesos",
"cs": "Podávají se zde thajské pokrmy" "cs": "Podávají se zde thajské pokrmy"
} }
},
{
"if": "cuisine=mexican ",
"icon": "🇲🇽",
"then": {
"en": "Mexican dishes are served here",
"nl": "Dit is een mexicaans restaurant"
}
},
{
"if": "cuisine=japanese ",
"icon": "🇯🇵",
"then": {
"en": "Japanese dishes are served here",
"nl": "Dit is een japans restaurant"
}
},
{
"if": "cuisine=chicken ",
"icon": "🐔",
"then": {
"en": "Chicken based dishes are served here",
"nl": "Dit is een kiprestaurant"
}
},
{
"if": "cuisine=seafood ",
"icon": "🐟",
"then": {
"en": "Seafood dishes are served here",
"nl": "Dit is een vis- en zeerestaurant"
}
} }
], ],
"id": "Cuisine" "id": "Cuisine",
"filter": true
}, },
{ {
"id": "show-menu-image", "id": "show-menu-image",
@ -1291,6 +1339,7 @@
"es": "Tiene menú vegetariano", "es": "Tiene menú vegetariano",
"fr": "A un menu végétarien" "fr": "A un menu végétarien"
}, },
"icon": "./assets/layers/food/Vegetarian-mark.svg",
"osmTags": { "osmTags": {
"or": [ "or": [
"diet:vegetarian=yes", "diet:vegetarian=yes",

View file

@ -356,9 +356,32 @@
} }
} }
} }
},
{
"#": "ignore-image-in-then",
"if": "osm_id~*",
"then": {
"special": {
"type": "link",
"text": "<img alt='on osm' textmode='🗺️' src='./assets/svg/osm-logo-us.svg'/>",
"href": "https://www.openstreetmap.org/{osm_id}",
"arialabel": {
"en": "Open on openstreetmap.org",
"nl": "Bekijk op openstreetmap.org",
"de": "Auf openstreetmap.org öffnen",
"pl": "Otwórz na openstreetmap.org",
"da": "Åbn på openstreetmap.org"
}
}
}
} }
], ],
"condition": "id~(node|way|relation)/[0-9]*" "condition": {
"or": [
"id~(node|way|relation)/[0-9]*",
"osm_id~*"
]
}
}, },
{ {
"id": "rating", "id": "rating",

View file

@ -133,9 +133,8 @@
} }
}, },
"opening_hours", "opening_hours",
"phone", "contact",
"email", "payment-options",
"website",
{ {
"id": "wheelchair", "id": "wheelchair",
"question": { "question": {

View file

@ -0,0 +1,66 @@
{
"id": "search",
"description": {
"en": "Priviliged layer showing the search results"
},
"source": "special",
"title": "{display_name}",
"tagRenderings": [
{
"id": "intro",
"render": {
"en": "Search result"
}
},
{
"id": "osm",
"render": {
"*": "<a href='https://openstreetmap.org/{osm_type}/{osm_id}'>On OpenStreetMap</a>"
}
},
"all_tags"
],
"pointRendering": [
{
"location": [
"point",
"centroid"
],
"marker": [
{
"icon": "circle",
"color": "white"
},
{
"icon": {
"render": "globe_alt",
"mappings": [
{
"if": "category~city|locality|county",
"then": "building_office_2"
},
{
"if": "category=train_station",
"then": "train"
},
{
"if": "category=airport",
"then": "airport"
},
{
"if": "category=house",
"then": "house"
},
{
"if": "category=shop",
"then": "building_storefront"
}
]
}
}
],
"label": "{display_name}",
"labelCssClasses": "bg-white rounded p-2 no-wrap"
}
]
}

View file

@ -260,6 +260,9 @@
{ {
"builtin": "id_presets.shop_types", "builtin": "id_presets.shop_types",
"override": { "override": {
"labels": [
"description"
],
"question": { "question": {
"en": "What kind of shop is this?", "en": "What kind of shop is this?",
"nl": "Wat voor soort winkel is dit?", "nl": "Wat voor soort winkel is dit?",
@ -307,7 +310,8 @@
} }
], ],
"condition": "craft=", "condition": "craft=",
"invalidValues": "shop=yes" "invalidValues": "shop=yes",
"filter": true
} }
}, },
{ {
@ -1173,52 +1177,6 @@
"description" "description"
], ],
"filter": [ "filter": [
"open_now",
{
"id": "shop-type",
"options": [
{
"fields": [
{
"name": "search",
"type": "string"
}
],
"osmTags": "shop~i~.*{search}.*",
"question": {
"en": "Only show shops selling {search}",
"de": "Nur Geschäfte, die {search} verkaufen",
"nl": "Toon enkel winkels die {search} verkopen",
"es": "Solo mostrar tiendas que vendan {search}",
"fr": "N'afficher que les magasins vendant {search}",
"ca": "Sols mostrar botigues que venen {search}",
"cs": "Zobrazit pouze obchody prodávající {search}"
}
}
]
},
{
"id": "shop-name",
"options": [
{
"fields": [
{
"name": "search",
"type": "string"
}
],
"osmTags": "name~i~.*{search}.*",
"question": {
"en": "Only show shops with name {search}",
"de": "Nur Geschäfte mit dem Namen {search} anzeigen",
"nl": "Toon enkel winkels met naam {search}",
"es": "Solo mostrar tiendas con nombre {search}",
"fr": "N'afficher que les magasins portant le nom {search}",
"cs": "Zobrazit pouze obchody s názvem {search}"
}
}
]
},
{ {
"id": "second_hand", "id": "second_hand",
"options": [ "options": [

View file

@ -25,6 +25,31 @@
"cs": "Vrstva zobrazující (veřejné) toalety", "cs": "Vrstva zobrazující (veřejné) toalety",
"sl": "Prikaz (javnih) stranišč" "sl": "Prikaz (javnih) stranišč"
}, },
"searchTerms": {
"en": [
"Toilets",
"Bathroom",
"Lavatory",
"Water Closet",
"outhouse",
"privy",
"head",
"latrine",
"WC",
"W.C."
],
"nl": [
"WC",
"WCs",
"plee",
"gemak",
"opschik",
"kabinet",
"latrine",
"retirade",
"piesemopsantee"
]
},
"source": { "source": {
"osmTags": "amenity=toilets" "osmTags": "amenity=toilets"
}, },

View file

@ -305,6 +305,12 @@
"*": "{logout()}" "*": "{logout()}"
} }
}, },
{
"id": "title-map",
"render": {
"en": "<h3>Configure map</h3>"
}
},
{ {
"id": "a11y-features", "id": "a11y-features",
"question": { "question": {
@ -470,6 +476,81 @@
} }
] ]
}, },
{
"id": "show_crosshair",
"question": {
"en": "Should a crosshair be shown in the center of the display?",
"cs": "Měl by se uprostřed displeje zobrazovat kříž?",
"de": "Soll ein Fadenkreuz in der Mitte des Bildschirms angezeigt werden?",
"nl": "Moet er een kruisje getoond worden in het centrum van je display?"
},
"questionHint": {
"en": "This can help to accurately position a new element",
"cs": "To může pomoci přesněji umístit nový prvek",
"de": "Dies kann dazu beitragen, ein neues Element genau zu positionieren",
"nl": "Dit kan helpen om nieuwe elementen accuraat te plaatsen",
"ca": "Això pot ajudar a posicionar amb precisió un nou element"
},
"mappings": [
{
"if": "mapcomplete-show_crosshair=yes",
"then": "Show a crosshair in the center of the map when zoomed in above level 17"
},
{
"if": "mapcomplete-show_crosshair=no",
"then": "Do not show a crosshair in the center of the map"
},
{
"if": "mapcomplete-show_crosshair=",
"then": "Do not show a crosshair in the center of the map",
"hideInAnswer": true
},
{
"if": "mapcomplete-show_crosshair=always",
"then": "Always show a crosshair in the center of the map"
}
]
},
{
"id": "fixate-north",
"question": {
"en": "Should north always be up?",
"de": "Soll Norden immer oben sein?",
"ca": "El nord hauria d'estar sempre amunt?",
"cs": "Měl by být sever vždy nahoře?",
"nl": "Moet het noorden altijd naar boven getoond worden?",
"da": "Skal nord altid pege opad?"
},
"mappings": [
{
"if": "mapcomplete-fixate-north=no",
"alsoShowIf": "mapcomplete-fixate-north=",
"icon": "./assets/svg/compass.svg",
"then": {
"en": "Allow to rotate the map",
"de": "Drehen der Karte zulassen",
"ca": "Permet girar el mapa",
"fr": "Autoriser la rotation de la carte",
"da": "Tillad rotation af kortet",
"cs": "Umožnit otáčení mapy",
"nl": "Sta kaartrotatie toe"
}
},
{
"if": "mapcomplete-fixate-north=yes",
"icon": "./assets/svg/compass.svg",
"then": {
"en": "Always keep north pointing up",
"de": "Norden immer nach oben zeigen lassen",
"fr": "Toujours garder le nord en haut",
"ca": "Mantingueu sempre el nord apuntant cap amunt",
"cs": "Sever vždy směřujte nahoru",
"nl": "Hou het noorden altijd naar boven",
"da": "Nord peger altid opad"
}
}
]
},
{ {
"id": "scalebar", "id": "scalebar",
"question": { "question": {
@ -494,6 +575,214 @@
} }
] ]
}, },
{
"id": "title-editing",
"render": {
"en": "<h3>Editing settings</h3>"
}
},
{
"id": "all-questions-at-once",
"question": {
"en": "Should questions for unknown data fields appear one-by-one or together?",
"de": "Sollen Fragen für unbekannte Datenfelder einzeln oder zusammen angezeigt werden?",
"fr": "Est-ce que les questions pour les champs sans donnée doivent apparaître une à une ou toutes ensembles ?",
"pt": "As perguntas para campos de dados desconhecidos devem aparecer uma a uma ou juntas?",
"ca": "Les preguntes amb camps de dades desconeguts haurien d'aparèixer una per una o juntes?",
"nl": "Moeten onbeantwoorde vragen om beurt of allemaal samen getoond worden?",
"cs": "Mají se otázky pro neznámá datová pole zobrazovat jednotlivě, nebo společně?",
"da": "Skal spørgsmål for ukendte oplysninger vises ét ad gangen eller alle på én gang?"
},
"mappings": [
{
"if": "mapcomplete-show-all-questions=true",
"then": {
"en": "Show all questions in the infobox together",
"de": "Alle Fragen in der Infobox zusammen anzeigen",
"ca": "Mostra totes les preguntes al quadre d'informació",
"fr": "Afficher toutes les question en même temps dans l'infobox",
"pt": "Mostrar todas as perguntas na caixa de informações juntas",
"nl": "Toon alle onbeantwoorde vragen",
"cs": "Zobrazit všechny otázky v infoboxu dohromady",
"da": "Vis alle spørgsmål i infoboksen på én gang"
}
},
{
"if": "mapcomplete-show-all-questions=false",
"then": {
"en": "Show questions one-by-one",
"de": "Fragen der Reihe nach anzeigen",
"ca": "Mostra les preguntes una per una",
"fr": "Afficher les questions une à une",
"pt": "Mostrar perguntas uma a uma",
"nl": "Toon de vragen één per één",
"cs": "Zobrazit otázky jednu po druhé",
"da": "Vis spørgsmål ét ad gangen"
}
}
]
},
{
"id": "show_tags",
"question": {
"en": "Show the raw OpenStreetMap-tags?",
"de": "Rohe OpenStreetMap-Tags anzeigen?",
"fr": "Afficher les attributs OpenStreetMap bruts ?",
"ca": "Mostra les etiquetes d'OpenStreetMap en brut?",
"cs": "Zobrazit nezpracované/raw tagy OpenStreetMap?",
"nl": "Moeten de data-attributen getoond worden?"
},
"questionHint": {
"en": "<b>Tags</b> are attributes that every element has. This is the technical data that is stored in the database. You don't need this information to edit with MapComplete, but advanced users might want to use this as reference.",
"de": "<b>Tags</b> sind die Eigenschaften, die jedes Objekt hat. Das sind die technischen Daten, die in der Datenbank gespeichert werden. Du brauchst diese Informationen nicht, um mit MapComplete Änderungen zu machen, aber fortgeschrittenen Nutzer*innen kann es als Referenz dienen.",
"ca": "Les <b>etiquetes</b> són atributs que té cada element. Aquestes són les dades tècniques que s'emmagatzemen a la base de dades. No necessiteu aquesta informació per editar amb MapComplete, però és possible que els usuaris avançats la vulguin fer servir com a referència.",
"cs": "<b>Tagy</b> jsou atributy, které má každý element. Jedná se o technické údaje, které jsou uloženy v databázi. K úpravám pomocí MapComplete tyto informace nepotřebujete, ale pokročilí uživatelé by je mohli chtít použít jako referenci.",
"nl": "<b>Data-attributen</b> zijn stukjes data die elk element in OpenStreetMap heeft. Dit is technische data die in de databank komt. Je hoeft deze informatie niet te kennen om aanpassingen te maken met MapComplete, maar geavanceerde gebruikers kunnen dit als referentie gebruiken.",
"fr": "Les<b>Tags</b> ou étiquettes sont des attributs rattachés à chaque élément. C'est une donnée technique qui est stockée dans une base de données. Vous n'avez pas besoin de connaître ces étiquettes pour utiliser MapComplete, mais certains utilisateurs préfèrent les afficher."
},
"mappings": [
{
"if": "mapcomplete-show_tags=no",
"then": {
"en": "Never show the tags.",
"de": "Tags nie anzeigen.",
"ca": "No mostris mai les etiquetes.",
"cs": "Nikdy nezobrazovat tagy.",
"nl": "Verberg data-attributen",
"fr": "Ne jamais voir les étiquettes."
}
},
{
"if": "mapcomplete-show_tags=",
"then": {
"en": "Show the tags that will be applied once I have made {__userjourney_tagsVisibleAt} changesets",
"de": "Tags anzeigen, sobald ich {__userjourney_tagsVisibleAt} Änderungssätze erstellt habe",
"ca": "Mostra les etiquetes que s'aplicaran un cop hagi fet {__userjourney_tagsVisibleAt} conjunts de canvis",
"cs": "Zobrazit tagy, které budou použity, jakmile provedu {__userjourney_tagsVisibleAt} sady změn",
"nl": "Toon data-attributen bij wijzigingen indien je meer dan {__userjourney_tagsVisibleAt} changesets hebt gemaakt",
"fr": "Voir les étiquettes au bout de {__userjourney_tagsVisibleAt} modifications"
}
},
{
"if": "mapcomplete-show_tags=yes",
"then": {
"en": "Show the tags that will be applied when making a change",
"de": "Tags anzeigen, die bei der Änderung hinzugefügt werden",
"ca": "Mostra les etiquetes que s'aplicaran en fer un canvi",
"cs": "Zobrazení tagů, které budou použity při provádění změny",
"nl": "Toon de data-attributen die toegepast zullen worden bij wijzigingen",
"fr": "Voir les étiquettes quand je fais une modification"
}
},
{
"if": "mapcomplete-show_tags=full",
"then": {
"en": "Show the tags that will be applied when making a change and show the tags table on every feature",
"de": "Tags anzeigen, die bei der Änderung hinzugefügt werden, und Tag-Tabelle bei jedem Objekt anzeigen",
"ca": "Mostra les etiquetes que s'aplicaran a l'hora de fer un canvi i mostra la taula d'etiquetes a cada element",
"cs": "Zobrazení tagů, které se použijí při provádění změny, a zobrazení tabulky tagů u každé funkce",
"nl": "Toon de data-attributen die toegepast zullen worden bij wijzigingen en toon een tabel met alle data-attributen bij elk object.",
"fr": "Voir les étiquettes quand je fais une modification et toujours voir les étiquettes pour chaque élément"
}
}
]
},
{
"id": "add-new-feature",
"question": {
"en": "How should the menu to add a new feature be opened?",
"de": "Wie soll das Menü zum Hinzufügen eines neuen Objekts geöffnet werden?"
},
"mappings": [
{
"if": "mapcomplete-preferences-add-new-mode=button",
"then": {
"en": "Adding a new feature is done with the button at the bottom left. Clicking the map does nothing",
"de": "Das Hinzufügen eines neuen Objekts erfolgt über die Schaltfläche unten links. Ein Klick auf die Karte bewirkt nichts"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=click",
"then": {
"en": "When clicking or tapping the map, a marker pops up where a new feature is added",
"de": "Wenn Sie auf die Karte klicken oder tippen, wird eine Markierung eingeblendet, an der ein neues Objekt hinzugefügt wird"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=click_right",
"then": {
"en": "When right-clicking or long-pressing the map, a marker pops up where a new feature can be added",
"de": "Beim Rechtsklick oder einem langen Druck auf die Karte erscheint eine Markierung, mit der ein neues Objekt hinzugefügt werden kann"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=button_click",
"then": {
"en": "When clicking or tapping the map, a marker pops up where a new feature can be added. Additionally, a button at the bottom left is shown",
"de": "Beim Klicken oder Tippen auf die Karte wird eine Markierung eingeblendet, an der ein neues Objekt hinzugefügt werden kann. Zusätzlich wird unten links eine Schaltfläche angezeigt"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=button_click_right",
"then": {
"en": "When right-clicking or long-pressing the map, a marker pops up where a new feature can be added. Additionally, a button at the bottom left is shown",
"de": "Beim Rechtsklick oder einem langen Druck auf die Karte erscheint eine Markierung, an der ein neues Objekt hinzugefügt werden kann. Zusätzlich wird unten links eine Schaltfläche angezeigt"
}
}
]
},
{
"id": "title-privacy-legal",
"render": {
"en": "<h3>Privacy and legal</h3>"
}
},
{
"id": "more_privacy_theme_override",
"mappings": [
{
"if": "__featureSwitchMorePrivacy=true",
"then": {
"en": "This theme is sensitive. Making changes will not indicate if you were nearby explicitly.",
"de": "Dieses Thema ist sensibel. Wenn du Änderungen vornimmst, wird nicht angezeigt, ob du explizit in der Nähe warst.",
"nl": "Dit thema ligt gevoelig. Wijzigingen aan objecten zullen niet aangeven of je in de buurt was."
}
}
]
},
{
"id": "more_privacy",
"question": {
"en": "When making changes, should a rough indication be given how far away you were from the object?",
"de": "Sollte bei Änderungen eine grobe Angabe gemacht werden, wie weit du vom Objekt entfernt warst?",
"nl": "Mag er opgeslaan worden hoever je je van een object bevindt wanneer je aanpassingen maakt aan dit object?"
},
"questionHint": {
"en": "If you make a change to one or more objects and you enabled your location, a rough indication of where you made will be saved: it is indicated if you were closer then 25m, 500m, 5km or further away then 5km. This helps mappers understand your context when making changes, but gives an indication of where you were at this time. ",
"de": "Wenn du eine Änderung an einem oder mehreren Objekten vornimmst und deinen Standort aktivierst, wird eine ungefähre Angabe darüber gespeichert, wo du dich befunden hast: Es wird angezeigt, ob du näher als 25m, 500m, 5km oder weiter als 5km entfernt warst. Das hilft den Kartierern, deinen Kontext zu verstehen, wenn du Änderungen vornimmst, gibt aber auch einen Hinweis darauf, wo du zu diesem Zeitpunkt warst. ",
"nl": "Wanneer je een wijziging maakt aan één of meer interessepunten en als MapComplete toont waar je bent, dan kan opgeslaan worden hoever je je ongeveer van deze objecten bevindt. Er wordt aangeduid of je dichter dan 25m, 500m, 5km of verder dan 5km was. Dit helpt om andere bijdragers te begrijpen hoe je je bijdragen deed, maar geeft natuurlijk ook aan waar je op dat moment was. "
},
"mappings": [
{
"if": "mapcomplete-more_privacy=yes",
"icon": "./assets/svg/eye.svg",
"then": {
"en": "When making changes to OpenStreetMap, do not indicate how far away you were from the changed objects.",
"de": "Wenn du Änderungen an OpenStreetMap vornimmst, gibst du nicht an, wie weit du von den geänderten Objekten entfernt warst.",
"nl": "Geef niet aan hoever je je van de gewijzigde objecten bevindt wanneer je wijzigingen maakt met MapComplete."
}
},
{
"if": "mapcomplete-more_privacy=no",
"icon": "cross_bottom_right:red;./assets/svg/eye.svg",
"then": {
"en": "When making changes to OpenStreetMap, roughly indicate how far away you were from the changed objects. This helps other contributors to understand how you made the change",
"de": "Gebe bei Änderungen an OpenStreetMap an, wie weit du ungefähr von den geänderten Objekten entfernt warst. Das hilft anderen Mitwirkenden zu verstehen, wie du die Änderung vorgenommen hast",
"nl": "Geef aan hoever je je ongeveer bevindt ten opzichte van objecten die je wijzigt in OpenStreetMap. Dit helpt andere bijdagers te begrijpen welke wijzigingen je waarom maakt."
}
}
]
},
{ {
"id": "picture-license", "id": "picture-license",
"description": "This question is not meant to be placed on an OpenStreetMap-element; however it is used in the user information panel to ask which license the user wants", "description": "This question is not meant to be placed on an OpenStreetMap-element; however it is used in the user information panel to ask which license the user wants",
@ -564,275 +853,70 @@
] ]
}, },
{ {
"id": "show_tags", "id": "sync-visited-themes",
"question": { "question": {
"en": "Show the raw OpenStreetMap-tags?", "en": "Should the thematic maps you visit be saved?"
"de": "Rohe OpenStreetMap-Tags anzeigen?",
"fr": "Afficher les attributs OpenStreetMap bruts ?",
"ca": "Mostra les etiquetes d'OpenStreetMap en brut?",
"cs": "Zobrazit nezpracované/raw tagy OpenStreetMap?",
"nl": "Moeten de data-attributen getoond worden?"
}, },
"questionHint": { "questionHint": {
"en": "<b>Tags</b> are attributes that every element has. This is the technical data that is stored in the database. You don't need this information to edit with MapComplete, but advanced users might want to use this as reference.", "en": "If you visit a map about a certain topic, MapComplete can remember this and offer this as suggestion."
"de": "<b>Tags</b> sind die Eigenschaften, die jedes Objekt hat. Das sind die technischen Daten, die in der Datenbank gespeichert werden. Du brauchst diese Informationen nicht, um mit MapComplete Änderungen zu machen, aber fortgeschrittenen Nutzer*innen kann es als Referenz dienen.",
"ca": "Les <b>etiquetes</b> són atributs que té cada element. Aquestes són les dades tècniques que s'emmagatzemen a la base de dades. No necessiteu aquesta informació per editar amb MapComplete, però és possible que els usuaris avançats la vulguin fer servir com a referència.",
"cs": "<b>Tagy</b> jsou atributy, které má každý element. Jedná se o technické údaje, které jsou uloženy v databázi. K úpravám pomocí MapComplete tyto informace nepotřebujete, ale pokročilí uživatelé by je mohli chtít použít jako referenci.",
"nl": "<b>Data-attributen</b> zijn stukjes data die elk element in OpenStreetMap heeft. Dit is technische data die in de databank komt. Je hoeft deze informatie niet te kennen om aanpassingen te maken met MapComplete, maar geavanceerde gebruikers kunnen dit als referentie gebruiken.",
"fr": "Les<b>Tags</b> ou étiquettes sont des attributs rattachés à chaque élément. C'est une donnée technique qui est stockée dans une base de données. Vous n'avez pas besoin de connaître ces étiquettes pour utiliser MapComplete, mais certains utilisateurs préfèrent les afficher."
}, },
"mappings": [ "mappings": [
{ {
"if": "mapcomplete-show_tags=no", "if": "mapcomplete-preference-theme-history=sync",
"alsoShowIf": "mapcomplete-preference-theme-history=",
"then": { "then": {
"en": "Never show the tags.", "en": "Save the visited thematic maps and sync them via openstreetmap.org. OpenStreetMap and all apps you use can see this history"
"de": "Tags nie anzeigen.",
"ca": "No mostris mai les etiquetes.",
"cs": "Nikdy nezobrazovat tagy.",
"nl": "Verberg data-attributen",
"fr": "Ne jamais voir les étiquettes."
} }
}, },
{ {
"if": "mapcomplete-show_tags=", "if": "mapcomplete-preference-theme-history=local",
"then": { "then": {
"en": "Show the tags that will be applied once I have made {__userjourney_tagsVisibleAt} changesets", "en": "Save the visited thematic maps on my device"
"de": "Tags anzeigen, sobald ich {__userjourney_tagsVisibleAt} Änderungssätze erstellt habe",
"ca": "Mostra les etiquetes que s'aplicaran un cop hagi fet {__userjourney_tagsVisibleAt} conjunts de canvis",
"cs": "Zobrazit tagy, které budou použity, jakmile provedu {__userjourney_tagsVisibleAt} sady změn",
"nl": "Toon data-attributen bij wijzigingen indien je meer dan {__userjourney_tagsVisibleAt} changesets hebt gemaakt",
"fr": "Voir les étiquettes au bout de {__userjourney_tagsVisibleAt} modifications"
} }
}, },
{ {
"if": "mapcomplete-show_tags=yes", "if": "mapcomplete-preference-theme-history=no",
"then": { "then": {
"en": "Show the tags that will be applied when making a change", "en": "Don't save visited thematic maps"
"de": "Tags anzeigen, die bei der Änderung hinzugefügt werden",
"ca": "Mostra les etiquetes que s'aplicaran en fer un canvi",
"cs": "Zobrazení tagů, které budou použity při provádění změny",
"nl": "Toon de data-attributen die toegepast zullen worden bij wijzigingen",
"fr": "Voir les étiquettes quand je fais une modification"
}
},
{
"if": "mapcomplete-show_tags=full",
"then": {
"en": "Show the tags that will be applied when making a change and show the tags table on every feature",
"de": "Tags anzeigen, die bei der Änderung hinzugefügt werden, und Tag-Tabelle bei jedem Objekt anzeigen",
"ca": "Mostra les etiquetes que s'aplicaran a l'hora de fer un canvi i mostra la taula d'etiquetes a cada element",
"cs": "Zobrazení tagů, které se použijí při provádění změny, a zobrazení tabulky tagů u každé funkce",
"nl": "Toon de data-attributen die toegepast zullen worden bij wijzigingen en toon een tabel met alle data-attributen bij elk object.",
"fr": "Voir les étiquettes quand je fais une modification et toujours voir les étiquettes pour chaque élément"
} }
} }
] ]
}, },
{ {
"id": "all-questions-at-once", "id": "sync-visited-locations",
"question": { "question": {
"en": "Should questions for unknown data fields appear one-by-one or together?", "en": "Should the locations you search for and inspect be remembered?"
"de": "Sollen Fragen für unbekannte Datenfelder einzeln oder zusammen angezeigt werden?",
"fr": "Est-ce que les questions pour les champs sans donnée doivent apparaître une à une ou toutes ensembles ?",
"pt": "As perguntas para campos de dados desconhecidos devem aparecer uma a uma ou juntas?",
"ca": "Les preguntes amb camps de dades desconeguts haurien d'aparèixer una per una o juntes?",
"nl": "Moeten onbeantwoorde vragen om beurt of allemaal samen getoond worden?",
"cs": "Mají se otázky pro neznámá datová pole zobrazovat jednotlivě, nebo společně?",
"da": "Skal spørgsmål for ukendte oplysninger vises ét ad gangen eller alle på én gang?"
},
"mappings": [
{
"if": "mapcomplete-show-all-questions=true",
"then": {
"en": "Show all questions in the infobox together",
"de": "Alle Fragen in der Infobox zusammen anzeigen",
"ca": "Mostra totes les preguntes al quadre d'informació",
"fr": "Afficher toutes les question en même temps dans l'infobox",
"pt": "Mostrar todas as perguntas na caixa de informações juntas",
"nl": "Toon alle onbeantwoorde vragen",
"cs": "Zobrazit všechny otázky v infoboxu dohromady",
"da": "Vis alle spørgsmål i infoboksen på én gang"
}
},
{
"if": "mapcomplete-show-all-questions=false",
"then": {
"en": "Show questions one-by-one",
"de": "Fragen der Reihe nach anzeigen",
"ca": "Mostra les preguntes una per una",
"fr": "Afficher les questions une à une",
"pt": "Mostrar perguntas uma a uma",
"nl": "Toon de vragen één per één",
"cs": "Zobrazit otázky jednu po druhé",
"da": "Vis spørgsmål ét ad gangen"
}
}
]
},
{
"id": "show_crosshair",
"question": {
"en": "Should a crosshair be shown in the center of the display?",
"cs": "Měl by se uprostřed displeje zobrazovat kříž?",
"de": "Soll ein Fadenkreuz in der Mitte des Bildschirms angezeigt werden?",
"nl": "Moet er een kruisje getoond worden in het centrum van je display?"
}, },
"questionHint": { "questionHint": {
"en": "This can help to accurately position a new element", "en": "Those locations will be offered in the search menu"
"cs": "To může pomoci přesněji umístit nový prvek",
"de": "Dies kann dazu beitragen, ein neues Element genau zu positionieren",
"nl": "Dit kan helpen om nieuwe elementen accuraat te plaatsen",
"ca": "Això pot ajudar a posicionar amb precisió un nou element"
}, },
"mappings": [ "mappings": [
{ {
"if": "mapcomplete-show_crosshair=yes", "if": "mapcomplete-preference-search-history=sync",
"then": "Show a crosshair in the center of the map when zoomed in above level 17" "alsoShowIf": "mapcomplete-preference-search-history=",
},
{
"if": "mapcomplete-show_crosshair=no",
"then": "Do not show a crosshair in the center of the map"
},
{
"if": "mapcomplete-show_crosshair=",
"then": "Do not show a crosshair in the center of the map",
"hideInAnswer": true
},
{
"if": "mapcomplete-show_crosshair=always",
"then": "Always show a crosshair in the center of the map"
}
]
},
{
"id": "add-new-feature",
"question": {
"en": "How should the menu to add a new feature be opened?",
"de": "Wie soll das Menü zum Hinzufügen eines neuen Objekts geöffnet werden?"
},
"mappings": [
{
"if": "mapcomplete-preferences-add-new-mode=button",
"then": { "then": {
"en": "Adding a new feature is done with the button at the bottom left. Clicking the map does nothing", "en": "Save the locations you search for and inspect and sync them via openstreetmap.org. OpenStreetMap and all apps you use can see this history"
"de": "Das Hinzufügen eines neuen Objekts erfolgt über die Schaltfläche unten links. Ein Klick auf die Karte bewirkt nichts"
} }
}, },
{ {
"if": "mapcomplete-preferences-add-new-mode=click", "if": "mapcomplete-preference-search-history=local",
"then": { "then": {
"en": "When clicking or tapping the map, a marker pops up where a new feature is added", "en": "Save the locations you search for and inspect on my device"
"de": "Wenn Sie auf die Karte klicken oder tippen, wird eine Markierung eingeblendet, an der ein neues Objekt hinzugefügt wird"
} }
}, },
{ {
"if": "mapcomplete-preferences-add-new-mode=click_right", "if": "mapcomplete-preference-search-history=no",
"then": { "then": {
"en": "When right-clicking or long-pressing the map, a marker pops up where a new feature can be added", "en": "Don't save the locations you search for and inspect "
"de": "Beim Rechtsklick oder einem langen Druck auf die Karte erscheint eine Markierung, mit der ein neues Objekt hinzugefügt werden kann"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=button_click",
"then": {
"en": "When clicking or tapping the map, a marker pops up where a new feature can be added. Additionally, a button at the bottom left is shown",
"de": "Beim Klicken oder Tippen auf die Karte wird eine Markierung eingeblendet, an der ein neues Objekt hinzugefügt werden kann. Zusätzlich wird unten links eine Schaltfläche angezeigt"
}
},
{
"if": "mapcomplete-preferences-add-new-mode=button_click_right",
"then": {
"en": "When right-clicking or long-pressing the map, a marker pops up where a new feature can be added. Additionally, a button at the bottom left is shown",
"de": "Beim Rechtsklick oder einem langen Druck auf die Karte erscheint eine Markierung, an der ein neues Objekt hinzugefügt werden kann. Zusätzlich wird unten links eine Schaltfläche angezeigt"
} }
} }
] ]
}, },
{ {
"id": "fixate-north", "id": "title-id",
"question": { "render": {
"en": "Should north always be up?", "en": "<h3>Mangrove ID management</h3>"
"de": "Soll Norden immer oben sein?", }
"ca": "El nord hauria d'estar sempre amunt?",
"cs": "Měl by být sever vždy nahoře?",
"nl": "Moet het noorden altijd naar boven getoond worden?",
"da": "Skal nord altid pege opad?"
},
"mappings": [
{
"if": "mapcomplete-fixate-north=no",
"alsoShowIf": "mapcomplete-fixate-north=",
"icon": "./assets/svg/compass.svg",
"then": {
"en": "Allow to rotate the map",
"de": "Drehen der Karte zulassen",
"ca": "Permet girar el mapa",
"fr": "Autoriser la rotation de la carte",
"da": "Tillad rotation af kortet",
"cs": "Umožnit otáčení mapy",
"nl": "Sta kaartrotatie toe"
}
},
{
"if": "mapcomplete-fixate-north=yes",
"icon": "./assets/svg/compass.svg",
"then": {
"en": "Always keep north pointing up",
"de": "Norden immer nach oben zeigen lassen",
"fr": "Toujours garder le nord en haut",
"ca": "Mantingueu sempre el nord apuntant cap amunt",
"cs": "Sever vždy směřujte nahoru",
"nl": "Hou het noorden altijd naar boven",
"da": "Nord peger altid opad"
}
}
]
},
{
"id": "more_privacy_theme_override",
"mappings": [
{
"if": "__featureSwitchMorePrivacy=true",
"then": {
"en": "This theme is sensitive. Making changes will not indicate if you were nearby explicitly.",
"de": "Dieses Thema ist sensibel. Wenn du Änderungen vornimmst, wird nicht angezeigt, ob du explizit in der Nähe warst.",
"nl": "Dit thema ligt gevoelig. Wijzigingen aan objecten zullen niet aangeven of je in de buurt was."
}
}
]
},
{
"id": "more_privacy",
"question": {
"en": "When making changes, should a rough indication be given how far away you were from the object?",
"de": "Sollte bei Änderungen eine grobe Angabe gemacht werden, wie weit du vom Objekt entfernt warst?",
"nl": "Mag er opgeslaan worden hoever je je van een object bevindt wanneer je aanpassingen maakt aan dit object?"
},
"questionHint": {
"en": "If you make a change to one or more objects and you enabled your location, a rough indication of where you made will be saved: it is indicated if you were closer then 25m, 500m, 5km or further away then 5km. This helps mappers understand your context when making changes, but gives an indication of where you were at this time. ",
"de": "Wenn du eine Änderung an einem oder mehreren Objekten vornimmst und deinen Standort aktivierst, wird eine ungefähre Angabe darüber gespeichert, wo du dich befunden hast: Es wird angezeigt, ob du näher als 25m, 500m, 5km oder weiter als 5km entfernt warst. Das hilft den Kartierern, deinen Kontext zu verstehen, wenn du Änderungen vornimmst, gibt aber auch einen Hinweis darauf, wo du zu diesem Zeitpunkt warst. ",
"nl": "Wanneer je een wijziging maakt aan één of meer interessepunten en als MapComplete toont waar je bent, dan kan opgeslaan worden hoever je je ongeveer van deze objecten bevindt. Er wordt aangeduid of je dichter dan 25m, 500m, 5km of verder dan 5km was. Dit helpt om andere bijdragers te begrijpen hoe je je bijdragen deed, maar geeft natuurlijk ook aan waar je op dat moment was. "
},
"mappings": [
{
"if": "mapcomplete-more_privacy=yes",
"icon": "./assets/svg/eye.svg",
"then": {
"en": "When making changes to OpenStreetMap, do not indicate how far away you were from the changed objects.",
"de": "Wenn du Änderungen an OpenStreetMap vornimmst, gibst du nicht an, wie weit du von den geänderten Objekten entfernt warst.",
"nl": "Geef niet aan hoever je je van de gewijzigde objecten bevindt wanneer je wijzigingen maakt met MapComplete."
}
},
{
"if": "mapcomplete-more_privacy=no",
"icon": "cross_bottom_right:red;./assets/svg/eye.svg",
"then": {
"en": "When making changes to OpenStreetMap, roughly indicate how far away you were from the changed objects. This helps other contributors to understand how you made the change",
"de": "Gebe bei Änderungen an OpenStreetMap an, wie weit du ungefähr von den geänderten Objekten entfernt warst. Das hilft anderen Mitwirkenden zu verstehen, wie du die Änderung vorgenommen hast",
"nl": "Geef aan hoever je je ongeveer bevindt ten opzichte van objecten die je wijzigt in OpenStreetMap. Dit helpt andere bijdagers te begrijpen welke wijzigingen je waarom maakt."
}
}
]
}, },
{ {
"id": "mangrove-keys", "id": "mangrove-keys",

4
assets/svg/airport.svg Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="airport" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<path id="path7712-0" style="fill:#000" d="M15,6.8182L15,8.5l-6.5-1&#xA;&#x9;l-0.3182,4.7727L11,14v1l-3.5-0.6818L4,15v-1l2.8182-1.7273L6.5,7.5L0,8.5V6.8182L6.5,4.5v-3c0,0,0-1.5,1-1.5s1,1.5,1,1.5v2.8182&#xA;&#x9;L15,6.8182z"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Maki
SPDX-License-Identifier: CC0-1.0

View file

@ -59,6 +59,16 @@
], ],
"sources": [] "sources": []
}, },
{
"path": "airport.svg",
"license": "CC0-1.0",
"authors": [
"Maki"
],
"sources": [
"https://github.com/mapbox/maki/blob/main/icons/airport.svg"
]
},
{ {
"path": "back.svg", "path": "back.svg",
"license": "CC0-1.0", "license": "CC0-1.0",
@ -1183,6 +1193,16 @@
"https://pngimg.com/image/46283" "https://pngimg.com/image/46283"
] ]
}, },
{
"path": "train.svg",
"license": "CC0-1.0",
"authors": [
"Maki"
],
"sources": [
"https://labs.mapbox.com/maki-icons/"
]
},
{ {
"path": "translate.svg", "path": "translate.svg",
"license": "CC-BY-SA-3.0", "license": "CC-BY-SA-3.0",

24
assets/svg/train.svg Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="svg4619"
x="0px"
y="0px"
width="500"
height="500"
viewBox="0 0 500 500"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6" />
<path
id="path14245"
d="M 183.33333,0 C 166.66667,0 166.66667,16.666667 166.66667,16.666667 V 50 c 0,9.233332 7.43333,16.666668 16.66666,16.666668 C 192.56667,66.666668 200,59.233332 200,50 V 33.333332 h 33.33333 V 100 H 200 c 0,0 -66.66667,0 -66.66667,66.66667 v 100 c 0,100.00001 100,100.00001 100,100.00001 h 33.33334 c 0,0 100.00001,0 100.00001,-100.00001 v -100 C 366.66668,100 300,100 300,100 H 266.66667 V 33.333332 H 300 V 50 c 0,9.233332 7.43333,16.666668 16.66667,16.666668 9.23333,0 16.66665,-7.433336 16.66665,-16.666668 V 16.666667 C 333.33332,0 316.66667,0 316.66667,0 Z M 250,133.33333 l 68.16333,25.78 15.16999,57.55334 c 4.38669,16.66666 -16.66665,16.66666 -16.66665,16.66666 H 183.33333 c 0,0 -21.05333,0 -16.66666,-16.66666 l 15.16999,-57.55334 z m 0,133.33334 c 9.20333,0 16.66667,7.46333 16.66667,16.66666 C 266.66667,292.53667 259.20333,300 250,300 c -9.20333,0 -16.66667,-7.46333 -16.66667,-16.66667 0,-9.20333 7.46334,-16.66666 16.66667,-16.66666 z M 137.5,400 100,500 h 50 l 12.5,-33.33332 H 337.50002 L 350,500 h 50 L 362.50002,400 H 312.5 L 325,433.33332 H 175 L 187.5,400 Z"
style="stroke-width:33.3332; fill: #000" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Maki
SPDX-License-Identifier: CC0-1.0

View file

@ -80,7 +80,7 @@
"override": { "override": {
"name=": null, "name=": null,
"minzoom": 17, "minzoom": 17,
"filter": { "=filter": {
"sameAs": "bike_shop" "sameAs": "bike_shop"
} }
} }
@ -182,7 +182,7 @@
"builtin": "charging_station", "builtin": "charging_station",
"override": { "override": {
"name": null, "name": null,
"filter": { "=filter": {
"sameAs": "charging_station_ebikes" "sameAs": "charging_station_ebikes"
}, },
"minzoom": 18, "minzoom": 18,
@ -201,6 +201,7 @@
] ]
} }
}, },
"filter": null,
"=presets": [] "=presets": []
} }
}, },
@ -209,7 +210,7 @@
"builtin": "vending_machine", "builtin": "vending_machine",
"override": { "override": {
"name": null, "name": null,
"filter": { "=filter": {
"sameAs": "vending_machine_bicycle" "sameAs": "vending_machine_bicycle"
}, },
"minzoom": 18, "minzoom": 18,

View file

@ -50,13 +50,24 @@
}, },
"icon": "./assets/themes/shops/shop.svg", "icon": "./assets/themes/shops/shop.svg",
"layers": [ "layers": [
"shops", {
"pharmacy", "builtin": [
"ice_cream", "shops",
"trolley_bay" "pharmacy",
], "ice_cream"
"overrideAll": { ],
"minzoom": 14, "override": {
"syncSelection": "theme-only" "minzoom": 12,
} "syncSelection": "theme-only"
}
},
{
"builtin": [
"trolley_bay"
],
"override": {
"syncSelection": "theme-only"
}
}
]
} }

View file

@ -262,6 +262,7 @@
"examples": "Examples", "examples": "Examples",
"fewChangesBefore": "Please, answer a few questions of existing features before adding a new feature.", "fewChangesBefore": "Please, answer a few questions of existing features before adding a new feature.",
"filterPanel": { "filterPanel": {
"allTypes": "All types",
"disableAll": "Disable all", "disableAll": "Disable all",
"enableAll": "Enable all" "enableAll": "Enable all"
}, },
@ -396,8 +397,22 @@
"save": "Save", "save": "Save",
"screenToSmall": "Open <i>{theme}</i> in a new window", "screenToSmall": "Open <i>{theme}</i> in a new window",
"search": { "search": {
"activeFilters": "Active filters",
"clearFilters": "Clear filters",
"deleteSearchHistory": "Delete location history",
"deleteThemeHistory": "Delete earlier visited themes",
"editSearchSyncSettings": "Edit sync settings",
"editThemeSync": "Edit sync settings",
"error": "Something went wrong…", "error": "Something went wrong…",
"instructions": "Use the search bar above to search for locations, filters or other thematic maps",
"locations": "Locations",
"nMoreFilters": "{n} more",
"nothing": "Nothing found…", "nothing": "Nothing found…",
"nothingFor": "No results found for {term}",
"otherMaps": "Other maps",
"pickFilter": "Pick a filter",
"recentThemes": "Recently visited maps",
"recents": "Recently seen places",
"search": "Search a location", "search": "Search a location",
"searchShort": "Search…", "searchShort": "Search…",
"searching": "Searching…" "searching": "Searching…"

View file

@ -6894,15 +6894,6 @@
} }
}, },
"description": "Una botiga", "description": "Una botiga",
"filter": {
"1": {
"options": {
"0": {
"question": "Sols mostrar botigues que venen {search}"
}
}
}
},
"name": "Botiga", "name": "Botiga",
"presets": { "presets": {
"0": { "0": {

View file

@ -7201,21 +7201,7 @@
}, },
"description": "Obchod", "description": "Obchod",
"filter": { "filter": {
"1": { "0": {
"options": {
"0": {
"question": "Zobrazit pouze obchody prodávající {search}"
}
}
},
"2": {
"options": {
"0": {
"question": "Zobrazit pouze obchody s názvem {search}"
}
}
},
"3": {
"options": { "options": {
"0": { "0": {
"question": "Zobrazit pouze obchody prodávající použité zboží" "question": "Zobrazit pouze obchody prodávající použité zboží"

View file

@ -1612,6 +1612,13 @@
"arialabel": "Åbn på openstreetmap.org" "arialabel": "Åbn på openstreetmap.org"
} }
} }
},
"2": {
"then": {
"special": {
"arialabel": "Åbn på openstreetmap.org"
}
}
} }
}, },
"render": { "render": {

View file

@ -5881,6 +5881,13 @@
"arialabel": "Auf openstreetmap.org öffnen" "arialabel": "Auf openstreetmap.org öffnen"
} }
} }
},
"2": {
"then": {
"special": {
"arialabel": "Auf openstreetmap.org öffnen"
}
}
} }
}, },
"render": { "render": {
@ -9070,21 +9077,7 @@
}, },
"description": "Ein Geschäft", "description": "Ein Geschäft",
"filter": { "filter": {
"1": { "0": {
"options": {
"0": {
"question": "Nur Geschäfte, die {search} verkaufen"
}
}
},
"2": {
"options": {
"0": {
"question": "Nur Geschäfte mit dem Namen {search} anzeigen"
}
}
},
"3": {
"options": { "options": {
"0": { "0": {
"question": "Nur Second-Hand-Geschäfte anzeigen" "question": "Nur Second-Hand-Geschäfte anzeigen"

View file

@ -5169,6 +5169,18 @@
"14": { "14": {
"then": "Thai restaurant" "then": "Thai restaurant"
}, },
"15": {
"then": "Mexican dishes are served here"
},
"16": {
"then": "Japanese dishes are served here"
},
"17": {
"then": "Chicken based dishes are served here"
},
"18": {
"then": "Seafood dishes are served here"
},
"2": { "2": {
"then": "Serves mainly pasta" "then": "Serves mainly pasta"
}, },
@ -5889,6 +5901,13 @@
"arialabel": "Open on openstreetmap.org" "arialabel": "Open on openstreetmap.org"
} }
} }
},
"2": {
"then": {
"special": {
"arialabel": "Open on openstreetmap.org"
}
}
} }
}, },
"render": { "render": {
@ -9127,6 +9146,14 @@
"render": "School <i>{name}</i>" "render": "School <i>{name}</i>"
} }
}, },
"search": {
"description": "Priviliged layer showing the search results",
"tagRenderings": {
"intro": {
"render": "Search result"
}
}
},
"selected_element": { "selected_element": {
"description": "Highlights the currently selected element. Override this layer to have different colors" "description": "Highlights the currently selected element. Override this layer to have different colors"
}, },
@ -9174,21 +9201,7 @@
}, },
"description": "A shop", "description": "A shop",
"filter": { "filter": {
"1": { "0": {
"options": {
"0": {
"question": "Only show shops selling {search}"
}
}
},
"2": {
"options": {
"0": {
"question": "Only show shops with name {search}"
}
}
},
"3": {
"options": { "options": {
"0": { "0": {
"question": "Only show shops selling second-hand items" "question": "Only show shops selling second-hand items"
@ -11734,6 +11747,48 @@
"question": "Show the raw OpenStreetMap-tags?", "question": "Show the raw OpenStreetMap-tags?",
"questionHint": "<b>Tags</b> are attributes that every element has. This is the technical data that is stored in the database. You don't need this information to edit with MapComplete, but advanced users might want to use this as reference." "questionHint": "<b>Tags</b> are attributes that every element has. This is the technical data that is stored in the database. You don't need this information to edit with MapComplete, but advanced users might want to use this as reference."
}, },
"sync-visited-locations": {
"mappings": {
"0": {
"then": "Save the locations you search for and inspect and sync them via openstreetmap.org. OpenStreetMap and all apps you use can see this history"
},
"1": {
"then": "Save the locations you search for and inspect on my device"
},
"2": {
"then": "Don't save the locations you search for and inspect "
}
},
"question": "Should the locations you search for and inspect be remembered?",
"questionHint": "Those locations will be offered in the search menu"
},
"sync-visited-themes": {
"mappings": {
"0": {
"then": "Save the visited thematic maps and sync them via openstreetmap.org. OpenStreetMap and all apps you use can see this history"
},
"1": {
"then": "Save the visited thematic maps on my device"
},
"2": {
"then": "Don't save visited thematic maps"
}
},
"question": "Should the thematic maps you visit be saved?",
"questionHint": "If you visit a map about a certain topic, MapComplete can remember this and offer this as suggestion."
},
"title-editing": {
"render": "<h3>Editing settings</h3>"
},
"title-id": {
"render": "<h3>Mangrove ID management</h3>"
},
"title-map": {
"render": "<h3>Configure map</h3>"
},
"title-privacy-legal": {
"render": "<h3>Privacy and legal</h3>"
},
"translation-completeness": { "translation-completeness": {
"mappings": { "mappings": {
"0": { "0": {

View file

@ -3990,22 +3990,6 @@
} }
}, },
"description": "Una tienda", "description": "Una tienda",
"filter": {
"1": {
"options": {
"0": {
"question": "Solo mostrar tiendas que vendan {search}"
}
}
},
"2": {
"options": {
"0": {
"question": "Solo mostrar tiendas con nombre {search}"
}
}
}
},
"name": "Tienda", "name": "Tienda",
"presets": { "presets": {
"0": { "0": {

View file

@ -5815,22 +5815,6 @@
} }
}, },
"description": "Un magasin", "description": "Un magasin",
"filter": {
"1": {
"options": {
"0": {
"question": "N'afficher que les magasins vendant {search}"
}
}
},
"2": {
"options": {
"0": {
"question": "N'afficher que les magasins portant le nom {search}"
}
}
}
},
"name": "Magasin", "name": "Magasin",
"presets": { "presets": {
"0": { "0": {

View file

@ -4032,6 +4032,19 @@
} }
} }
}, },
"10": {
"options": {
"0": {
"question": "Geen voorkeur voor honden"
},
"1": {
"question": "Honden toegelaten"
},
"2": {
"question": "Geen honden toegelaten"
}
}
},
"2": { "2": {
"options": { "options": {
"0": { "0": {
@ -4296,6 +4309,18 @@
"14": { "14": {
"then": "Thaïs restaurant" "then": "Thaïs restaurant"
}, },
"15": {
"then": "Dit is een mexicaans restaurant"
},
"16": {
"then": "Dit is een japans restaurant"
},
"17": {
"then": "Dit is een kiprestaurant"
},
"18": {
"then": "Dit is een vis- en zeerestaurant"
},
"2": { "2": {
"then": "Pastazaak" "then": "Pastazaak"
}, },
@ -4885,6 +4910,13 @@
"arialabel": "Bekijk op openstreetmap.org" "arialabel": "Bekijk op openstreetmap.org"
} }
} }
},
"2": {
"then": {
"special": {
"arialabel": "Bekijk op openstreetmap.org"
}
}
} }
}, },
"render": { "render": {
@ -7349,22 +7381,6 @@
} }
}, },
"description": "Een winkel", "description": "Een winkel",
"filter": {
"1": {
"options": {
"0": {
"question": "Toon enkel winkels die {search} verkopen"
}
}
},
"2": {
"options": {
"0": {
"question": "Toon enkel winkels met naam {search}"
}
}
}
},
"name": "Winkel", "name": "Winkel",
"presets": { "presets": {
"0": { "0": {

View file

@ -2180,6 +2180,13 @@
"arialabel": "Otwórz na openstreetmap.org" "arialabel": "Otwórz na openstreetmap.org"
} }
} }
},
"2": {
"then": {
"special": {
"arialabel": "Otwórz na openstreetmap.org"
}
}
} }
}, },
"render": { "render": {

View file

@ -333,9 +333,21 @@
"save": "Opslaan", "save": "Opslaan",
"screenToSmall": "Open {theme} in een nieuw venster", "screenToSmall": "Open {theme} in een nieuw venster",
"search": { "search": {
"activeFilters": "Actieve filters",
"clearFilters": "Verwijder filters",
"deleteSearchHistory": "Verwijder geschiedenis",
"deleteThemeHistory": "Verwijder geschiedenis",
"editSearchSyncSettings": "Stel je geschiedenis-voorkeuren in",
"editThemeSync": "Stel je geschiedenis-voorkeuren in",
"error": "Niet gelukt…", "error": "Niet gelukt…",
"instructions": "Gebruik de zoekbalk om locaties, filters of om andere kaarten te zoeken",
"locations": "Plaatsen",
"nothing": "Niets gevonden…", "nothing": "Niets gevonden…",
"search": "Zoek naar een locatie", "otherMaps": "Andere kaarten",
"pickFilter": "Kies een filter",
"recentThemes": "Recent bezochte kaarten",
"recents": "Recent bekeken plaatsen",
"search": "Zoek naar een locatie, filter of kaart",
"searchShort": "Zoek…", "searchShort": "Zoek…",
"searching": "Aan het zoeken…" "searching": "Aan het zoeken…"
}, },

View file

@ -51,6 +51,8 @@
}, },
"country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country", "country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country",
"nominatimEndpoint": "https://geocoding.geofabrik.de/b75350b1cfc34962ac49824fe5b582dc/", "nominatimEndpoint": "https://geocoding.geofabrik.de/b75350b1cfc34962ac49824fe5b582dc/",
"#photonEndpoint": "`api/` or `reverse/` will be appended by the code",
"photonEndpoint": "https://photon.komoot.io/",
"jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}", "jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}",
"protomaps": { "protomaps": {
"api-key": "2af8b969a9e8b692", "api-key": "2af8b969a9e8b692",
@ -91,7 +93,7 @@
"generate:contributor-list": "vite-node scripts/generateContributors.ts", "generate:contributor-list": "vite-node scripts/generateContributors.ts",
"generate:service-worker": "tsc src/service-worker.ts --outFile public/service-worker.js && git_hash=$(git rev-parse HEAD) && sed -i.bak \"s/GITHUB-COMMIT/$git_hash/\" public/service-worker.js && rm public/service-worker.js.bak", "generate:service-worker": "tsc src/service-worker.ts --outFile public/service-worker.js && git_hash=$(git rev-parse HEAD) && sed -i.bak \"s/GITHUB-COMMIT/$git_hash/\" public/service-worker.js && rm public/service-worker.js.bak",
"reset:layeroverview": "npm run prep:layeroverview && npm run generate:layeroverview && npm run refresh:layeroverview", "reset:layeroverview": "npm run prep:layeroverview && npm run generate:layeroverview && npm run refresh:layeroverview",
"prep:layeroverview": "mkdir -p ./src/assets/generated/layers; echo {\\\"themes\\\":[]} > ./src/assets/generated/known_themes.json && echo {\\\"layers\\\": []} > ./src/assets/generated/known_layers.json && rm -f ./src/assets/generated/layers/*.json && rm -f ./src/assets/generated/themes/*.json && cp ./assets/layers/usersettings/usersettings.json ./src/assets/generated/layers/usersettings.json && echo '{}' > ./src/assets/generated/layers/favourite.json && echo '{}' > ./src/assets/generated/layers/summary.json && echo '{}' > ./src/assets/generated/layers/last_click.json && echo '[]' > ./src/assets/generated/theme_overview.json && echo '{}' > ./src/assets/generated/layers/geocoded_image.json", "prep:layeroverview": "./scripts/initFiles.sh",
"generate": "npm run generate:licenses && npm run generate:images && npm run generate:charging-stations && npm run generate:translations && npm run refresh:layeroverview && npm run generate:service-worker", "generate": "npm run generate:licenses && npm run generate:images && npm run generate:charging-stations && npm run generate:translations && npm run refresh:layeroverview && npm run generate:service-worker",
"generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -", "generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -",
"clean:tests": "find . -type f -name \"*.doctest.ts\" | xargs -r rm", "clean:tests": "find . -type f -name \"*.doctest.ts\" | xargs -r rm",

View file

@ -1168,6 +1168,14 @@ input[type="range"].range-lg::-moz-range-thumb {
right: 33.333333%; right: 33.333333%;
} }
.right-10 {
right: 2.5rem;
}
.top-10 {
top: 2.5rem;
}
.top-4 { .top-4 {
top: 1rem; top: 1rem;
} }
@ -1414,6 +1422,11 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.mx-4 { .mx-4 {
margin-left: 1rem; margin-left: 1rem;
margin-right: 1rem; margin-right: 1rem;
@ -1439,11 +1452,6 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-right: -0.25rem; margin-right: -0.25rem;
} }
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.mx-12 { .mx-12 {
margin-left: 3rem; margin-left: 3rem;
margin-right: 3rem; margin-right: 3rem;
@ -1470,6 +1478,14 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-bottom: 4rem; margin-bottom: 4rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.mr-0\.5 { .mr-0\.5 {
margin-right: 0.125rem; margin-right: 0.125rem;
} }
@ -1486,14 +1502,6 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.mt-2 { .mt-2 {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -1502,8 +1510,8 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.ml-4 { .mr-3 {
margin-left: 1rem; margin-right: 0.75rem;
} }
.mb-2 { .mb-2 {
@ -1522,8 +1530,8 @@ input[type="range"].range-lg::-moz-range-thumb {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.mr-3 { .ml-4 {
margin-right: 0.75rem; margin-left: 1rem;
} }
.mt-8 { .mt-8 {
@ -1729,6 +1737,10 @@ input[type="range"].range-lg::-moz-range-thumb {
height: 0px; height: 0px;
} }
.h-7 {
height: 1.75rem;
}
.h-1\/2 { .h-1\/2 {
height: 50%; height: 50%;
} }
@ -1741,10 +1753,6 @@ input[type="range"].range-lg::-moz-range-thumb {
height: calc(100% - 2rem); height: calc(100% - 2rem);
} }
.h-7 {
height: 1.75rem;
}
.h-11 { .h-11 {
height: 2.75rem; height: 2.75rem;
} }
@ -1991,14 +1999,14 @@ input[type="range"].range-lg::-moz-range-thumb {
width: 0px; width: 0px;
} }
.w-3 {
width: 0.75rem;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
.w-3 {
width: 0.75rem;
}
.w-11 { .w-11 {
width: 2.75rem; width: 2.75rem;
} }
@ -2752,6 +2760,10 @@ input[type="range"].range-lg::-moz-range-thumb {
overflow-y: auto; overflow-y: auto;
} }
.overflow-x-hidden {
overflow-x: hidden;
}
.overflow-y-scroll { .overflow-y-scroll {
overflow-y: scroll; overflow-y: scroll;
} }
@ -3908,6 +3920,11 @@ input[type="range"].range-lg::-moz-range-thumb {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.px-0 {
padding-left: 0px;
padding-right: 0px;
}
.px-3 { .px-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
@ -3973,11 +3990,6 @@ input[type="range"].range-lg::-moz-range-thumb {
padding-bottom: 0.875rem; padding-bottom: 0.875rem;
} }
.px-0 {
padding-left: 0px;
padding-right: 0px;
}
.\!px-0 { .\!px-0 {
padding-left: 0px !important; padding-left: 0px !important;
padding-right: 0px !important; padding-right: 0px !important;
@ -4000,10 +4012,6 @@ input[type="range"].range-lg::-moz-range-thumb {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.pl-1 {
padding-left: 0.25rem;
}
.pr-1 { .pr-1 {
padding-right: 0.25rem; padding-right: 0.25rem;
} }
@ -4036,6 +4044,10 @@ input[type="range"].range-lg::-moz-range-thumb {
padding-top: 0.5rem; padding-top: 0.5rem;
} }
.pl-1 {
padding-left: 0.25rem;
}
.pb-1\.5 { .pb-1\.5 {
padding-bottom: 0.375rem; padding-bottom: 0.375rem;
} }
@ -4865,6 +4877,11 @@ input[type="range"].range-lg::-moz-range-thumb {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
} }
.drop-shadow-2xl {
--tw-drop-shadow: drop-shadow(0 25px 25px rgb(0 0 0 / 0.15));
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.drop-shadow-md { .drop-shadow-md {
--tw-drop-shadow: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06)); --tw-drop-shadow: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
@ -4927,6 +4944,12 @@ input[type="range"].range-lg::-moz-range-thumb {
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
} }
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition { .transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@ -4941,12 +4964,6 @@ input[type="range"].range-lg::-moz-range-thumb {
transition-duration: 150ms; transition-duration: 150ms;
} }
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-transform { .transition-transform {
transition-property: transform; transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@ -4986,10 +5003,10 @@ input[type="range"].range-lg::-moz-range-thumb {
} }
:root { :root {
/* /*
* The main colour scheme of mapcomplete is configured here. * The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these. * For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/ */
/* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */ /* No support for dark mode yet, we disable it to prevent some elements to suddenly toggle */
color-scheme: only light; color-scheme: only light;
/* Main color of the application: the background and text colours */ /* Main color of the application: the background and text colours */
@ -5014,9 +5031,9 @@ input[type="range"].range-lg::-moz-range-thumb {
--disabled: #B8B8B8; --disabled: #B8B8B8;
--disabled-font: #B8B8B8; --disabled-font: #B8B8B8;
/** /**
* Base colour of interactive elements, mainly the 'subtle button' * Base colour of interactive elements, mainly the 'subtle button'
* @deprecated * @deprecated
*/ */
--subtle-detail-color: #dbeafe; --subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black; --subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey; --subtle-detail-color-light-contrast: lightgrey;
@ -5026,14 +5043,14 @@ input[type="range"].range-lg::-moz-range-thumb {
--catch-detail-color-contrast: #fb3afb; --catch-detail-color-contrast: #fb3afb;
--image-carousel-height: 350px; --image-carousel-height: 350px;
/** Technical value, used by icon.svelte /** Technical value, used by icon.svelte
*/ */
--svg-color: #000000; --svg-color: #000000;
} }
@font-face{ @font-face {
font-family:"Source Sans Pro"; font-family: "Source Sans Pro";
src:url("/assets/source-sans-pro.regular.ttf") format("woff"); src: url("/assets/source-sans-pro.regular.ttf") format("woff");
} }
/***********************************************************************\ /***********************************************************************\
@ -5238,6 +5255,16 @@ button.as-link {
padding: 0; padding: 0;
} }
button.unstyled, .button-unstyled button {
background-color: unset;
display: inline-flex;
justify-content: start;
border: none;
box-shadow: none !important;
margin: 0;
padding: 0;
}
/******* Other input elements ******/ /******* Other input elements ******/
.hover-alert:hover { .hover-alert:hover {
@ -5264,18 +5291,18 @@ select:hover {
.neutral-label { .neutral-label {
/** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries. /** This label styles as normal text. It's power comes from the many :not(.neutral-label) entries.
* Placed here for autocompletion * Placed here for autocompletion
*/ */
} }
label:not(.neutral-label):not(.button) { label:not(.neutral-label):not(.button) {
/** /**
* Label should _contain_ the input element * Label should _contain_ the input element
*/ */
padding: 0.25rem; padding: 0.25rem;
padding-right: 0.5rem; padding-right: 0.5rem;
padding-left: 0.5rem; padding-left: 0.5rem;
margin:0.25rem; margin: 0.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@ -5329,6 +5356,17 @@ h2.group {
background-color: #58cd2722; background-color: #58cd2722;
} }
.badge {
display: flex;
align-items: center;
white-space: nowrap;
border-radius: 999rem;
padding-left: 0.25rem;
padding-right: 0.25rem;
border: 1px solid var(--subtle-detail-color-light-contrast);
background-color: var(--low-interaction-background);
}
.alert { .alert {
/* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */ /* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */
background-color: var(--alert-color); background-color: var(--alert-color);
@ -5496,6 +5534,10 @@ a.link-underline {
color: unset !important; color: unset !important;
} }
a:hover {
background-color: var(--low-interaction-background);
}
.disable-links a.must-link, .disable-links a.must-link,
.disable-links .must-link a { .disable-links .must-link a {
/* Hide links if they are disabled */ /* Hide links if they are disabled */
@ -5514,7 +5556,7 @@ a.link-underline {
.selected svg:not(.noselect *) path.selectable { .selected svg:not(.noselect *) path.selectable {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
stroke: white !important; stroke: white !important;
stroke-width: 20px !important; stroke-width: 20px !important;
overflow: visible !important; overflow: visible !important;
@ -5528,7 +5570,7 @@ a.link-underline {
.selected svg { .selected svg {
/* A marker on the map gets the 'selected' class when it's properties are displayed /* A marker on the map gets the 'selected' class when it's properties are displayed
*/ */
overflow: visible !important; overflow: visible !important;
} }
@ -8060,14 +8102,14 @@ svg.apply-fill path {
order: 9999; order: 9999;
} }
.sm\:m-2 {
margin: 0.5rem;
}
.sm\:m-1 { .sm\:m-1 {
margin: 0.25rem; margin: 0.25rem;
} }
.sm\:m-2 {
margin: 0.5rem;
}
.sm\:mx-1 { .sm\:mx-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
margin-right: 0.25rem; margin-right: 0.25rem;
@ -8082,10 +8124,6 @@ svg.apply-fill path {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.sm\:mt-0 {
margin-top: 0px;
}
.sm\:mt-2 { .sm\:mt-2 {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -8146,6 +8184,10 @@ svg.apply-fill path {
width: 16rem; width: 16rem;
} }
.sm\:w-80 {
width: 20rem;
}
.sm\:w-11 { .sm\:w-11 {
width: 2.75rem; width: 2.75rem;
} }
@ -8359,6 +8401,10 @@ svg.apply-fill path {
width: 50%; width: 50%;
} }
.md\:w-96 {
width: 24rem;
}
.md\:w-48 { .md\:w-48 {
width: 12rem; width: 12rem;
} }

View file

@ -24,7 +24,7 @@ export default abstract class Script {
}) })
.catch((e) => { .catch((e) => {
console.log(`ERROR in script ${process.argv[1]}:`, e) console.log(`ERROR in script ${process.argv[1]}:`, e)
process.exit(1) // process.exit(1)
}) })
} }

View file

@ -45,6 +45,7 @@ export class GenerateFavouritesLayer extends Script {
this.addTagRenderings(proto) this.addTagRenderings(proto)
this.addTitle(proto) this.addTitle(proto)
proto.titleIcons = this.generateTitleIcons() proto.titleIcons = this.generateTitleIcons()
delete proto.filter
const targetContent = JSON.stringify(proto, null, " ") const targetContent = JSON.stringify(proto, null, " ")
const path = "./assets/layers/favourite/favourite.json" const path = "./assets/layers/favourite/favourite.json"
if (existsSync(path)) { if (existsSync(path)) {

View file

@ -29,11 +29,14 @@ import LayerConfig from "../src/Models/ThemeConfig/LayerConfig"
import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig" import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig"
import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext" import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext"
import { GenerateFavouritesLayer } from "./generateFavouritesLayer" import { GenerateFavouritesLayer } from "./generateFavouritesLayer"
import LayoutConfig from "../src/Models/ThemeConfig/LayoutConfig" import LayoutConfig, { MinimalLayoutInformation } from "../src/Models/ThemeConfig/LayoutConfig"
import Translations from "../src/UI/i18n/Translations" import Translations from "../src/UI/i18n/Translations"
import { Translatable } from "../src/Models/ThemeConfig/Json/Translatable" import { Translatable } from "../src/Models/ThemeConfig/Json/Translatable"
import { ValidateThemeAndLayers } from "../src/Models/ThemeConfig/Conversion/ValidateThemeAndLayers" import { ValidateThemeAndLayers } from "../src/Models/ThemeConfig/Conversion/ValidateThemeAndLayers"
import { ExtractImages } from "../src/Models/ThemeConfig/Conversion/FixImages" import { ExtractImages } from "../src/Models/ThemeConfig/Conversion/FixImages"
import {
TagRenderingConfigJson,
} from "../src/Models/ThemeConfig/Json/TagRenderingConfigJson"
// This scripts scans 'src/assets/layers/*.json' for layer definition files and 'src/assets/themes/*.json' for theme definition files. // This scripts scans 'src/assets/layers/*.json' for layer definition files and 'src/assets/themes/*.json' for theme definition files.
// It spits out an overview of those to be used to load them // It spits out an overview of those to be used to load them
@ -56,7 +59,7 @@ class ParseLayer extends Conversion<
convert( convert(
path: string, path: string,
context: ConversionContext context: ConversionContext,
): { ): {
parsed: LayerConfig parsed: LayerConfig
raw: LayerConfigJson raw: LayerConfigJson
@ -85,7 +88,7 @@ class ParseLayer extends Conversion<
context context
.enter("source") .enter("source")
.err( .err(
"No source is configured. (Tags might be automatically derived if presets are given)" "No source is configured. (Tags might be automatically derived if presets are given)",
) )
return undefined return undefined
} }
@ -116,7 +119,7 @@ class AddIconSummary extends DesugaringStep<{ raw: LayerConfigJson; parsed: Laye
const fixed = json.raw const fixed = json.raw
const layerConfig = json.parsed const layerConfig = json.parsed
const pointRendering: PointRenderingConfig = layerConfig.mapRendering.find((pr) => const pointRendering: PointRenderingConfig = layerConfig.mapRendering.find((pr) =>
pr.location.has("point") pr.location.has("point"),
) )
const defaultTags = layerConfig.GetBaseTags() const defaultTags = layerConfig.GetBaseTags()
fixed["_layerIcon"] = Utils.NoNull( fixed["_layerIcon"] = Utils.NoNull(
@ -131,7 +134,7 @@ class AddIconSummary extends DesugaringStep<{ raw: LayerConfigJson; parsed: Laye
result["color"] = c result["color"] = c
} }
return result return result
}) }),
) )
return { raw: fixed, parsed: layerConfig } return { raw: fixed, parsed: layerConfig }
} }
@ -153,7 +156,7 @@ class LayerOverviewUtils extends Script {
private static extractLayerIdsFrom( private static extractLayerIdsFrom(
themeFile: LayoutConfigJson, themeFile: LayoutConfigJson,
includeInlineLayers = true includeInlineLayers = true,
): string[] { ): string[] {
const publicLayerIds: string[] = [] const publicLayerIds: string[] = []
if (!Array.isArray(themeFile.layers)) { if (!Array.isArray(themeFile.layers)) {
@ -185,7 +188,7 @@ class LayerOverviewUtils extends Script {
return publicLayerIds return publicLayerIds
} }
public static cleanTranslation(t: Record<string, string> | Translation): Translatable { public static cleanTranslation(t: string | Record<string, string> | Translation): Translatable {
return Translations.T(t).OnEveryLanguage((s) => parse_html(s).textContent).translations return Translations.T(t).OnEveryLanguage((s) => parse_html(s).textContent).translations
} }
@ -208,11 +211,71 @@ class LayerOverviewUtils extends Script {
return false return false
} }
static mergeKeywords(into: Record<string, string[]>, source: Readonly<Record<string, string[]>>){
for (const key in source) {
if(into[key]){
into[key].push(...source[key])
}else{
into[key] = source[key]
}
}
}
private layerKeywords(l: LayerConfigJson): Record<string, string[]> {
const keywords: Record<string, string[]> = {}
function addWord(language: string, word: string | string[]) {
if(Array.isArray(word)){
word.forEach(w => addWord(language, w))
return
}
word = Utils.SubstituteKeys(word, {})?.trim()
if(!word){
return
}
if (!keywords[language]) {
keywords[language] = []
}
keywords[language].push(word)
}
function addWords(tr: string | Record<string, string> | Record<string, string[]> | TagRenderingConfigJson) {
if(!tr){
return
}
if (typeof tr === "string") {
addWord("*", tr)
return
}
if (tr["render"] !== undefined || tr["mappings"] !== undefined) {
tr = <TagRenderingConfigJson>tr
addWords(<Translatable>tr.render)
for (const mapping of tr.mappings ?? []) {
if (typeof mapping === "string") {
addWords(mapping)
continue
}
addWords(mapping.then)
}
return
}
for (const lang in tr) {
addWord(lang, tr[lang])
}
}
addWord("*", l.id)
addWords(l.title)
addWords(l.description)
addWords(l.searchTerms)
return keywords
}
writeSmallOverview( writeSmallOverview(
themes: { themes: {
id: string id: string
title: any title: Translatable
shortDescription: any shortDescription: Translatable
icon: string icon: string
hideFromOverview: boolean hideFromOverview: boolean
mustHaveLanguage: boolean mustHaveLanguage: boolean
@ -220,29 +283,43 @@ class LayerOverviewUtils extends Script {
| LayerConfigJson | LayerConfigJson
| string | string
| { | {
builtin builtin
} }
)[] )[]
}[] }[],
sharedLayers: Map<string, LayerConfigJson>
) { ) {
const perId = new Map<string, any>() const layerKeywords : Record<string, Record<string, string[]>> = {}
sharedLayers.forEach((layer, id) => {
layerKeywords[id] = this.layerKeywords(layer)
})
const perId = new Map<string, MinimalLayoutInformation>()
for (const theme of themes) { for (const theme of themes) {
const keywords: {}[] = []
const keywords: Record<string, string[]> = {}
for (const layer of theme.layers ?? []) { for (const layer of theme.layers ?? []) {
const l = <LayerConfigJson>layer const l = <LayerConfigJson>layer
keywords.push({ "*": l.id }) if(sharedLayers.has(l.id)){
keywords.push(l.title) continue
keywords.push(l.description) }
if(l.id.startsWith("note_import")){
continue
}
LayerOverviewUtils.mergeKeywords(keywords, this.layerKeywords(l))
} }
const data = { const data = <MinimalLayoutInformation> {
id: theme.id, id: theme.id,
title: theme.title, title: theme.title,
shortDescription: LayerOverviewUtils.cleanTranslation(theme.shortDescription), shortDescription: LayerOverviewUtils.cleanTranslation(theme.shortDescription),
icon: theme.icon, icon: theme.icon,
hideFromOverview: theme.hideFromOverview, hideFromOverview: theme.hideFromOverview,
mustHaveLanguage: theme.mustHaveLanguage, mustHaveLanguage: theme.mustHaveLanguage,
keywords: Utils.NoNull(keywords), keywords,
layers: theme.layers.filter(l => sharedLayers.has(l["id"])).map(l => l["id"])
} }
perId.set(theme.id, data) perId.set(theme.id, data)
} }
@ -263,8 +340,8 @@ class LayerOverviewUtils extends Script {
writeFileSync( writeFileSync(
"./src/assets/generated/theme_overview.json", "./src/assets/generated/theme_overview.json",
JSON.stringify(sorted, null, " "), JSON.stringify({ layers: layerKeywords, themes: sorted }, null, " "),
{ encoding: "utf8" } { encoding: "utf8" },
) )
} }
@ -276,7 +353,7 @@ class LayerOverviewUtils extends Script {
writeFileSync( writeFileSync(
`${LayerOverviewUtils.themePath}${theme.id}.json`, `${LayerOverviewUtils.themePath}${theme.id}.json`,
JSON.stringify(theme, null, " "), JSON.stringify(theme, null, " "),
{ encoding: "utf8" } { encoding: "utf8" },
) )
} }
@ -287,12 +364,12 @@ class LayerOverviewUtils extends Script {
writeFileSync( writeFileSync(
`${LayerOverviewUtils.layerPath}${layer.id}.json`, `${LayerOverviewUtils.layerPath}${layer.id}.json`,
JSON.stringify(layer, null, " "), JSON.stringify(layer, null, " "),
{ encoding: "utf8" } { encoding: "utf8" },
) )
} }
static asDict( static asDict(
trs: QuestionableTagRenderingConfigJson[] trs: QuestionableTagRenderingConfigJson[],
): Map<string, QuestionableTagRenderingConfigJson> { ): Map<string, QuestionableTagRenderingConfigJson> {
const d = new Map<string, QuestionableTagRenderingConfigJson>() const d = new Map<string, QuestionableTagRenderingConfigJson>()
for (const tr of trs) { for (const tr of trs) {
@ -305,12 +382,12 @@ class LayerOverviewUtils extends Script {
getSharedTagRenderings( getSharedTagRenderings(
doesImageExist: DoesImageExist, doesImageExist: DoesImageExist,
bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson>, bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson>,
bootstrapTagRenderingsOrder: string[] bootstrapTagRenderingsOrder: string[],
): QuestionableTagRenderingConfigJson[] ): QuestionableTagRenderingConfigJson[]
getSharedTagRenderings( getSharedTagRenderings(
doesImageExist: DoesImageExist, doesImageExist: DoesImageExist,
bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson> = null, bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson> = null,
bootstrapTagRenderingsOrder: string[] = [] bootstrapTagRenderingsOrder: string[] = [],
): QuestionableTagRenderingConfigJson[] { ): QuestionableTagRenderingConfigJson[] {
const prepareLayer = new PrepareLayer( const prepareLayer = new PrepareLayer(
{ {
@ -321,7 +398,7 @@ class LayerOverviewUtils extends Script {
}, },
{ {
addTagRenderingsToContext: true, addTagRenderingsToContext: true,
} },
) )
const path = "assets/layers/questions/questions.json" const path = "assets/layers/questions/questions.json"
@ -341,7 +418,7 @@ class LayerOverviewUtils extends Script {
return this.getSharedTagRenderings( return this.getSharedTagRenderings(
doesImageExist, doesImageExist,
dict, dict,
sharedQuestions.tagRenderings.map((tr) => tr["id"]) sharedQuestions.tagRenderings.map((tr) => tr["id"]),
) )
} }
@ -381,8 +458,8 @@ class LayerOverviewUtils extends Script {
if (contents.indexOf("<text") > 0) { if (contents.indexOf("<text") > 0) {
console.warn( console.warn(
"The SVG at " + "The SVG at " +
path + path +
" contains a `text`-tag. This is highly discouraged. Every machine viewing your theme has their own font libary, and the font you choose might not be present, resulting in a different font being rendered. Solution: open your .svg in inkscape (or another program), select the text and convert it to a path" " contains a `text`-tag. This is highly discouraged. Every machine viewing your theme has their own font libary, and the font you choose might not be present, resulting in a different font being rendered. Solution: open your .svg in inkscape (or another program), select the text and convert it to a path",
) )
errCount++ errCount++
} }
@ -398,14 +475,14 @@ class LayerOverviewUtils extends Script {
args args
.find((a) => a.startsWith("--themes=")) .find((a) => a.startsWith("--themes="))
?.substring("--themes=".length) ?.substring("--themes=".length)
?.split(",") ?? [] ?.split(",") ?? [],
) )
const layerWhitelist = new Set( const layerWhitelist = new Set(
args args
.find((a) => a.startsWith("--layers=")) .find((a) => a.startsWith("--layers="))
?.substring("--layers=".length) ?.substring("--layers=".length)
?.split(",") ?? [] ?.split(",") ?? [],
) )
const forceReload = args.some((a) => a == "--force") const forceReload = args.some((a) => a == "--force")
@ -425,6 +502,7 @@ class LayerOverviewUtils extends Script {
// These two get a free pass // These two get a free pass
priviliged.delete("summary") priviliged.delete("summary")
priviliged.delete("last_click") priviliged.delete("last_click")
priviliged.delete("search")
const isBoostrapping = AllSharedLayers.getSharedLayersConfigs().size == 0 const isBoostrapping = AllSharedLayers.getSharedLayersConfigs().size == 0
if (!isBoostrapping && priviliged.size > 0) { if (!isBoostrapping && priviliged.size > 0) {
@ -440,11 +518,11 @@ class LayerOverviewUtils extends Script {
sharedLayers, sharedLayers,
recompiledThemes, recompiledThemes,
forceReload, forceReload,
themeWhitelist themeWhitelist,
) )
new ValidateThemeEnsemble().convertStrict( new ValidateThemeEnsemble().convertStrict(
Array.from(sharedThemes.values()).map((th) => new LayoutConfig(th, true)) Array.from(sharedThemes.values()).map((th) => new LayoutConfig(th, true)),
) )
if (recompiledThemes.length > 0) { if (recompiledThemes.length > 0) {
@ -452,7 +530,7 @@ class LayerOverviewUtils extends Script {
"./src/assets/generated/known_layers.json", "./src/assets/generated/known_layers.json",
JSON.stringify({ JSON.stringify({
layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"), layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"),
}) }),
) )
} }
@ -473,7 +551,7 @@ class LayerOverviewUtils extends Script {
const proto: LayoutConfigJson = JSON.parse( const proto: LayoutConfigJson = JSON.parse(
readFileSync("./assets/themes/mapcomplete-changes/mapcomplete-changes.proto.json", { readFileSync("./assets/themes/mapcomplete-changes/mapcomplete-changes.proto.json", {
encoding: "utf8", encoding: "utf8",
}) }),
) )
const protolayer = <LayerConfigJson>( const protolayer = <LayerConfigJson>(
proto.layers.filter((l) => l["id"] === "mapcomplete-changes")[0] proto.layers.filter((l) => l["id"] === "mapcomplete-changes")[0]
@ -490,12 +568,12 @@ class LayerOverviewUtils extends Script {
layers: ScriptUtils.getLayerFiles().map((f) => f.parsed), layers: ScriptUtils.getLayerFiles().map((f) => f.parsed),
themes: ScriptUtils.getThemeFiles().map((f) => f.parsed), themes: ScriptUtils.getThemeFiles().map((f) => f.parsed),
}, },
ConversionContext.construct([], []) ConversionContext.construct([], []),
) )
for (const [_, theme] of sharedThemes) { for (const [_, theme] of sharedThemes) {
theme.layers = theme.layers.filter( theme.layers = theme.layers.filter(
(l) => Constants.added_by_default.indexOf(l["id"]) < 0 (l) => Constants.added_by_default.indexOf(l["id"]) < 0,
) )
} }
@ -504,7 +582,7 @@ class LayerOverviewUtils extends Script {
"./src/assets/generated/known_themes.json", "./src/assets/generated/known_themes.json",
JSON.stringify({ JSON.stringify({
themes: Array.from(sharedThemes.values()), themes: Array.from(sharedThemes.values()),
}) }),
) )
} }
@ -516,7 +594,7 @@ class LayerOverviewUtils extends Script {
private parseLayer( private parseLayer(
doesImageExist: DoesImageExist, doesImageExist: DoesImageExist,
prepLayer: PrepareLayer, prepLayer: PrepareLayer,
sharedLayerPath: string sharedLayerPath: string,
): { ): {
raw: LayerConfigJson raw: LayerConfigJson
parsed: LayerConfig parsed: LayerConfig
@ -527,7 +605,7 @@ class LayerOverviewUtils extends Script {
const parsed = parser.convertStrict(sharedLayerPath, context) const parsed = parser.convertStrict(sharedLayerPath, context)
const result = AddIconSummary.singleton.convertStrict( const result = AddIconSummary.singleton.convertStrict(
parsed, parsed,
context.inOperation("AddIconSummary") context.inOperation("AddIconSummary"),
) )
return { ...result, context } return { ...result, context }
} }
@ -535,7 +613,7 @@ class LayerOverviewUtils extends Script {
private buildLayerIndex( private buildLayerIndex(
doesImageExist: DoesImageExist, doesImageExist: DoesImageExist,
forceReload: boolean, forceReload: boolean,
whitelist: Set<string> whitelist: Set<string>,
): Map<string, LayerConfigJson> { ): Map<string, LayerConfigJson> {
// First, we expand and validate all builtin layers. These are written to src/assets/generated/layers // First, we expand and validate all builtin layers. These are written to src/assets/generated/layers
// At the same time, an index of available layers is built. // At the same time, an index of available layers is built.
@ -590,17 +668,17 @@ class LayerOverviewUtils extends Script {
console.log( console.log(
"Recompiled layers " + "Recompiled layers " +
recompiledLayers.join(", ") + recompiledLayers.join(", ") +
" and skipped " + " and skipped " +
skippedLayers.length + skippedLayers.length +
" layers. Detected " + " layers. Detected " +
warningCount + warningCount +
" warnings" " warnings",
) )
// We always need the calculated tags of 'usersettings', so we export them separately // We always need the calculated tags of 'usersettings', so we export them separately
this.extractJavascriptCodeForLayer( this.extractJavascriptCodeForLayer(
state.sharedLayers.get("usersettings"), state.sharedLayers.get("usersettings"),
"./src/Logic/State/UserSettingsMetaTagging.ts" "./src/Logic/State/UserSettingsMetaTagging.ts",
) )
return sharedLayers return sharedLayers
@ -617,8 +695,8 @@ class LayerOverviewUtils extends Script {
private extractJavascriptCode(themeFile: LayoutConfigJson) { private extractJavascriptCode(themeFile: LayoutConfigJson) {
const allCode = [ const allCode = [
"import {Feature} from 'geojson'", "import {Feature} from 'geojson'",
'import { ExtraFuncType } from "../../../Logic/ExtraFunctions";', "import { ExtraFuncType } from \"../../../Logic/ExtraFunctions\";",
'import { Utils } from "../../../Utils"', "import { Utils } from \"../../../Utils\"",
"export class ThemeMetaTagging {", "export class ThemeMetaTagging {",
" public static readonly themeName = " + JSON.stringify(themeFile.id), " public static readonly themeName = " + JSON.stringify(themeFile.id),
"", "",
@ -630,8 +708,8 @@ class LayerOverviewUtils extends Script {
allCode.push( allCode.push(
" public metaTaggging_for_" + " public metaTaggging_for_" +
id + id +
"(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {" "(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {",
) )
allCode.push(" const {" + ExtraFunctions.types.join(", ") + "} = helperFunctions") allCode.push(" const {" + ExtraFunctions.types.join(", ") + "} = helperFunctions")
for (const line of code) { for (const line of code) {
@ -642,10 +720,10 @@ class LayerOverviewUtils extends Script {
if (!isStrict) { if (!isStrict) {
allCode.push( allCode.push(
" Utils.AddLazyProperty(feat.properties, '" + " Utils.AddLazyProperty(feat.properties, '" +
attributeName + attributeName +
"', () => " + "', () => " +
expression + expression +
" ) " " ) ",
) )
} else { } else {
attributeName = attributeName.substring(0, attributeName.length - 1).trim() attributeName = attributeName.substring(0, attributeName.length - 1).trim()
@ -690,7 +768,7 @@ class LayerOverviewUtils extends Script {
const code = l.calculatedTags ?? [] const code = l.calculatedTags ?? []
allCode.push( allCode.push(
" public metaTaggging_for_" + l.id + "(feat: {properties: Record<string, string>}) {" " public metaTaggging_for_" + l.id + "(feat: {properties: Record<string, string>}) {",
) )
for (const line of code) { for (const line of code) {
const firstEq = line.indexOf("=") const firstEq = line.indexOf("=")
@ -700,10 +778,10 @@ class LayerOverviewUtils extends Script {
if (!isStrict) { if (!isStrict) {
allCode.push( allCode.push(
" Utils.AddLazyProperty(feat.properties, '" + " Utils.AddLazyProperty(feat.properties, '" +
attributeName + attributeName +
"', () => " + "', () => " +
expression + expression +
" ) " " ) ",
) )
} else { } else {
attributeName = attributeName.substring(0, attributeName.length - 2).trim() attributeName = attributeName.substring(0, attributeName.length - 2).trim()
@ -728,14 +806,14 @@ class LayerOverviewUtils extends Script {
sharedLayers: Map<string, LayerConfigJson>, sharedLayers: Map<string, LayerConfigJson>,
recompiledThemes: string[], recompiledThemes: string[],
forceReload: boolean, forceReload: boolean,
whitelist: Set<string> whitelist: Set<string>,
): Map<string, LayoutConfigJson> { ): Map<string, LayoutConfigJson> {
console.log(" ---------- VALIDATING BUILTIN THEMES ---------") console.log(" ---------- VALIDATING BUILTIN THEMES ---------")
const themeFiles = ScriptUtils.getThemeFiles() const themeFiles = ScriptUtils.getThemeFiles()
const fixed = new Map<string, LayoutConfigJson>() const fixed = new Map<string, LayoutConfigJson>()
const publicLayers = LayerOverviewUtils.publicLayerIdsFrom( const publicLayers = LayerOverviewUtils.publicLayerIdsFrom(
themeFiles.map((th) => th.parsed) themeFiles.map((th) => th.parsed),
) )
const trs = this.getSharedTagRenderings(new DoesImageExist(licensePaths, existsSync)) const trs = this.getSharedTagRenderings(new DoesImageExist(licensePaths, existsSync))
@ -775,15 +853,15 @@ class LayerOverviewUtils extends Script {
LayerOverviewUtils.themePath + "/" + themePath.substring(themePath.lastIndexOf("/")) LayerOverviewUtils.themePath + "/" + themePath.substring(themePath.lastIndexOf("/"))
const usedLayers = Array.from( const usedLayers = Array.from(
LayerOverviewUtils.extractLayerIdsFrom(themeFile, false) LayerOverviewUtils.extractLayerIdsFrom(themeFile, false),
).map((id) => LayerOverviewUtils.layerPath + id + ".json") ).map((id) => LayerOverviewUtils.layerPath + id + ".json")
if (!forceReload && !this.shouldBeUpdated([themePath, ...usedLayers], targetPath)) { if (!forceReload && !this.shouldBeUpdated([themePath, ...usedLayers], targetPath)) {
fixed.set( fixed.set(
themeFile.id, themeFile.id,
JSON.parse( JSON.parse(
readFileSync(LayerOverviewUtils.themePath + themeFile.id + ".json", "utf8") readFileSync(LayerOverviewUtils.themePath + themeFile.id + ".json", "utf8"),
) ),
) )
ScriptUtils.erasableLog("Skipping", themeFile.id) ScriptUtils.erasableLog("Skipping", themeFile.id)
skippedThemes.push(themeFile.id) skippedThemes.push(themeFile.id)
@ -794,23 +872,23 @@ class LayerOverviewUtils extends Script {
new PrevalidateTheme().convertStrict( new PrevalidateTheme().convertStrict(
themeFile, themeFile,
ConversionContext.construct([themePath], ["PrepareLayer"]) ConversionContext.construct([themePath], ["PrepareLayer"]),
) )
try { try {
themeFile = new PrepareTheme(convertState, { themeFile = new PrepareTheme(convertState, {
skipDefaultLayers: true, skipDefaultLayers: true,
}).convertStrict( }).convertStrict(
themeFile, themeFile,
ConversionContext.construct([themePath], ["PrepareLayer"]) ConversionContext.construct([themePath], ["PrepareLayer"]),
) )
new ValidateThemeAndLayers( new ValidateThemeAndLayers(
new DoesImageExist(licensePaths, existsSync, knownTagRenderings), new DoesImageExist(licensePaths, existsSync, knownTagRenderings),
themePath, themePath,
true, true,
knownTagRenderings knownTagRenderings,
).convertStrict( ).convertStrict(
themeFile, themeFile,
ConversionContext.construct([themePath], ["PrepareLayer"]) ConversionContext.construct([themePath], ["PrepareLayer"]),
) )
if (themeFile.icon.endsWith(".svg")) { if (themeFile.icon.endsWith(".svg")) {
@ -860,7 +938,7 @@ class LayerOverviewUtils extends Script {
const usedImages = Utils.Dedup( const usedImages = Utils.Dedup(
new ExtractImages(true, knownTagRenderings) new ExtractImages(true, knownTagRenderings)
.convertStrict(themeFile) .convertStrict(themeFile)
.map((x) => x.path) .map((x) => x.path),
) )
usedImages.sort() usedImages.sort()
@ -879,23 +957,24 @@ class LayerOverviewUtils extends Script {
if (whitelist.size == 0) { if (whitelist.size == 0) {
this.writeSmallOverview( this.writeSmallOverview(
Array.from(fixed.values()).map((t) => { Array.from(fixed.values()).map((t) => {
return { return <any> {
...t, ...t,
hideFromOverview: t.hideFromOverview ?? false, hideFromOverview: t.hideFromOverview ?? false,
shortDescription: shortDescription:
t.shortDescription ?? new Translation(t.description).FirstSentence(), t.shortDescription ?? new Translation(t.description).FirstSentence(),
mustHaveLanguage: t.mustHaveLanguage?.length > 0, mustHaveLanguage: t.mustHaveLanguage?.length > 0,
} }
}) }),
sharedLayers
) )
} }
console.log( console.log(
"Recompiled themes " + "Recompiled themes " +
recompiledThemes.join(", ") + recompiledThemes.join(", ") +
" and skipped " + " and skipped " +
skippedThemes.length + skippedThemes.length +
" themes" " themes",
) )
return fixed return fixed

View file

@ -327,8 +327,6 @@ class GenerateLayouts extends Script {
): Promise<string> { ): Promise<string> {
const apiUrls: string[] = [ const apiUrls: string[] = [
...Constants.allServers, ...Constants.allServers,
Constants.countryCoderEndpoint,
Constants.nominatimEndpoint,
"https://www.openstreetmap.org", "https://www.openstreetmap.org",
"https://api.openstreetmap.org", "https://api.openstreetmap.org",
"https://pietervdvn.goatcounter.com", "https://pietervdvn.goatcounter.com",

16
scripts/initFiles.sh Executable file
View file

@ -0,0 +1,16 @@
#! /bin/bash
# Creates various empty (stub) version of files
mkdir -p ./src/assets/generated/layers
echo '{"themes":[]}' > ./src/assets/generated/known_themes.json
echo '{"layers": []}' > ./src/assets/generated/known_layers.json
rm -f ./src/assets/generated/layers/*.json
rm -f ./src/assets/generated/themes/*.json
cp ./assets/layers/usersettings/usersettings.json ./src/assets/generated/layers/usersettings.json
echo '{}' > ./src/assets/generated/layers/favourite.json
echo '{}' > ./src/assets/generated/layers/summary.json
echo '{}' > ./src/assets/generated/layers/last_click.json
echo '{}' > ./src/assets/generated/layers/search.json
echo '[]' > ./src/assets/generated/theme_overview.json
echo '{}' > ./src/assets/generated/layers/geocoded_image.json

View file

@ -7,7 +7,7 @@ export class AllSharedLayers {
public static sharedLayers: Map<string, LayerConfig> = AllSharedLayers.getSharedLayers() public static sharedLayers: Map<string, LayerConfig> = AllSharedLayers.getSharedLayers()
public static getSharedLayersConfigs(): Map<string, LayerConfigJson> { public static getSharedLayersConfigs(): Map<string, LayerConfigJson> {
const sharedLayers = new Map<string, LayerConfigJson>() const sharedLayers = new Map<string, LayerConfigJson>()
for (const layer of known_layers.layers) { for (const layer of known_layers["layers"]) {
// @ts-ignore // @ts-ignore
sharedLayers.set(layer.id, layer) sharedLayers.set(layer.id, layer)
} }
@ -16,7 +16,7 @@ export class AllSharedLayers {
} }
private static getSharedLayers(): Map<string, LayerConfig> { private static getSharedLayers(): Map<string, LayerConfig> {
const sharedLayers = new Map<string, LayerConfig>() const sharedLayers = new Map<string, LayerConfig>()
for (const layer of known_layers.layers) { for (const layer of known_layers["layers"]) {
try { try {
// @ts-ignore // @ts-ignore
const parsed = new LayerConfig(layer, "shared_layers") const parsed = new LayerConfig(layer, "shared_layers")

View file

@ -21,7 +21,7 @@ export default class TitleHandler {
if (selected === undefined) { if (selected === undefined) {
return defaultTitle return defaultTitle
} }
const layer = state.layout.getMatchingLayer(selected.properties) const layer = state.getMatchingLayer(selected.properties)
if (layer === undefined) { if (layer === undefined) {
return defaultTitle return defaultTitle
} }

View file

@ -6,7 +6,7 @@ import { Feature, Polygon } from "geojson"
export class BBox { export class BBox {
static global: BBox = new BBox([ static global: BBox = new BBox([
[-180, -90], [-180, -90],
[180, 90], [180, 90]
]) ])
readonly maxLat: number readonly maxLat: number
readonly maxLon: number readonly maxLon: number
@ -53,7 +53,7 @@ export class BBox {
static fromLeafletBounds(bounds) { static fromLeafletBounds(bounds) {
return new BBox([ return new BBox([
[bounds.getWest(), bounds.getNorth()], [bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()], [bounds.getEast(), bounds.getSouth()]
]) ])
} }
@ -74,7 +74,7 @@ export class BBox {
// Note: x is longitude // Note: x is longitude
f["bbox"] = new BBox([ f["bbox"] = new BBox([
[minX, minY], [minX, minY],
[maxX, maxY], [maxX, maxY]
]) ])
} }
return f["bbox"] return f["bbox"]
@ -94,7 +94,7 @@ export class BBox {
} }
return new BBox([ return new BBox([
[maxLon, maxLat], [maxLon, maxLat],
[minLon, minLat], [minLon, minLat]
]) ])
} }
@ -121,7 +121,7 @@ export class BBox {
public unionWith(other: BBox) { public unionWith(other: BBox) {
return new BBox([ return new BBox([
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)], [Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)], [Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)]
]) ])
} }
@ -174,7 +174,7 @@ export class BBox {
return new BBox([ return new BBox([
[lon - s / 2, lat - s / 2], [lon - s / 2, lat - s / 2],
[lon + s / 2, lat + s / 2], [lon + s / 2, lat + s / 2]
]) ])
} }
@ -231,21 +231,21 @@ export class BBox {
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor) const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
return new BBox([ return new BBox([
[this.minLon - lonDiff, this.minLat - latDiff], [this.minLon - lonDiff, this.minLat - latDiff],
[this.maxLon + lonDiff, this.maxLat + latDiff], [this.maxLon + lonDiff, this.maxLat + latDiff]
]) ])
} }
padAbsolute(degrees: number): BBox { padAbsolute(degrees: number): BBox {
return new BBox([ return new BBox([
[this.minLon - degrees, this.minLat - degrees], [this.minLon - degrees, this.minLat - degrees],
[this.maxLon + degrees, this.maxLat + degrees], [this.maxLon + degrees, this.maxLat + degrees]
]) ])
} }
toLngLat(): [[number, number], [number, number]] { toLngLat(): [[number, number], [number, number]] {
return [ return [
[this.minLon, this.minLat], [this.minLon, this.minLat],
[this.maxLon, this.maxLat], [this.maxLon, this.maxLat]
] ]
} }
@ -260,7 +260,7 @@ export class BBox {
return { return {
type: "Feature", type: "Feature",
properties: properties, properties: properties,
geometry: this.asGeometry(), geometry: this.asGeometry()
} }
} }
@ -273,9 +273,9 @@ export class BBox {
[this.maxLon, this.minLat], [this.maxLon, this.minLat],
[this.maxLon, this.maxLat], [this.maxLon, this.maxLat],
[this.minLon, this.maxLat], [this.minLon, this.maxLat],
[this.minLon, this.minLat], [this.minLon, this.minLat]
], ]
], ]
} }
} }
@ -302,7 +302,7 @@ export class BBox {
minLon, minLon,
maxLon, maxLon,
minLat, minLat,
maxLat, maxLat
} }
} }
@ -316,4 +316,8 @@ export class BBox {
public overlapsWithFeature(f: Feature) { public overlapsWithFeature(f: Feature) {
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0 return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
} }
center() {
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2]
}
} }

View file

@ -24,7 +24,7 @@ export default class FavouritesFeatureSource extends StaticFeatureSource {
constructor(state: SpecialVisualizationState) { constructor(state: SpecialVisualizationState) {
const features: Store<Feature[]> = Stores.ListStabilized( const features: Store<Feature[]> = Stores.ListStabilized(
state.osmConnection.preferencesHandler.preferences.map((prefs) => { state.osmConnection.preferencesHandler.allPreferences.map((prefs) => {
const feats: Feature[] = [] const feats: Feature[] = []
const allIds = new Set<string>() const allIds = new Set<string>()
for (const key in prefs) { for (const key in prefs) {

View file

@ -28,10 +28,10 @@ export class SummaryTileSourceRewriter implements FeatureSource {
!l.layerDef.id.startsWith("note_import") !l.layerDef.id.startsWith("note_import")
) )
this._summarySource = summarySource this._summarySource = summarySource
filteredLayers.forEach((v, k) => { filteredLayers.forEach((v) => {
v.isDisplayed.addCallback((_) => this.update()) v.isDisplayed.addCallback(() => this.update())
}) })
this._summarySource.features.addCallbackAndRunD((_) => this.update()) this._summarySource.features.addCallbackAndRunD(() => this.update())
} }
private update() { private update() {

View file

@ -908,9 +908,12 @@ export class GeoOperations {
} }
/** /**
* GeoOperations.distanceToHuman(52.8) // => "53m" * GeoOperations.distanceToHuman(52.3) // => "50m"
* GeoOperations.distanceToHuman(999) // => "1.0km"
* GeoOperations.distanceToHuman(2800) // => "2.8km" * GeoOperations.distanceToHuman(2800) // => "2.8km"
* GeoOperations.distanceToHuman(12800) // => "13km" * GeoOperations.distanceToHuman(12800) // => "13km"
* GeoOperations.distanceToHuman(128000) // => "130km"
*
* *
* @param meters * @param meters
*/ */
@ -918,13 +921,13 @@ export class GeoOperations {
if (meters === undefined) { if (meters === undefined) {
return "" return ""
} }
meters = Math.round(meters) meters = Utils.roundHuman( Math.round(meters))
if (meters < 1000) { if (meters < 1000) {
return meters + "m" return Utils.roundHuman(meters) + "m"
} }
if (meters >= 10000) { if (meters >= 10000) {
const km = Math.round(meters / 1000) const km = Utils.roundHuman(Math.round(meters / 1000))
return km + "km" return km + "km"
} }

View file

@ -424,9 +424,11 @@ export default class MetaTagging {
} }
} }
console.warn( if (!window.location.pathname.endsWith("theme.html")) {
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP" console.warn(
) "Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)
}
const calculatedTags: [string, string, boolean][] = layer?.calculatedTags ?? [] const calculatedTags: [string, string, boolean][] = layer?.calculatedTags ?? []
if (calculatedTags === undefined || calculatedTags.length === 0) { if (calculatedTags === undefined || calculatedTags.length === 0) {

View file

@ -1,45 +0,0 @@
import { Utils } from "../../Utils"
import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
import { FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale"
export interface GeoCodeResult {
display_name: string
lat: number
lon: number
/**
* Format:
* [lat, lat, lon, lon]
*/
boundingbox: number[]
osm_type: "node" | "way" | "relation"
osm_id: string
}
export class Geocoding {
public static readonly host = Constants.nominatimEndpoint
static async Search(query: string, bbox: BBox): Promise<GeoCodeResult[]> {
const b = bbox ?? BBox.global
const url = `${
Geocoding.host
}search?format=json&limit=1&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${
Locale.language.data
}&q=${query}`
return Utils.downloadJson(url)
}
static async reverse(
coordinate: { lon: number; lat: number },
zoom: number = 17,
language?: string
): Promise<FeatureCollection> {
// https://nominatim.org/release-docs/develop/api/Reverse/
// IF the zoom is low, it'll only return a country instead of an address
const url = `${Geocoding.host}reverse?format=geojson&lat=${coordinate.lat}&lon=${
coordinate.lon
}&zoom=${Math.ceil(zoom) + 1}&accept-language=${language}`
return Utils.downloadJson(url)
}
}

View file

@ -137,7 +137,6 @@ export class OsmConnection {
this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser) this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser)
if (options.oauth_token?.data !== undefined) { if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
this.auth.bootstrapToken(options.oauth_token.data, (err, result) => { this.auth.bootstrapToken(options.oauth_token.data, (err, result) => {
console.log("Bootstrap token called back", err, result) console.log("Bootstrap token called back", err, result)
this.AttemptLogin() this.AttemptLogin()
@ -155,20 +154,27 @@ export class OsmConnection {
console.log("Not authenticated") console.log("Not authenticated")
} }
} }
public GetPreference<T extends string = string>( public GetPreference<T extends string = string>(
key: string, key: string,
defaultValue: string = undefined, defaultValue: string = undefined,
options?: { options?: {
documentation?: string
prefix?: string prefix?: string
} }
): UIEventSource<T | undefined> { ): UIEventSource<T | undefined> {
return <UIEventSource<T>>this.preferencesHandler.GetPreference(key, defaultValue, options) const prefix =options?.prefix ?? "mapcomplete-"
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
}
public getPreference<T extends string = string>(
key: string,
defaultValue: string = undefined,
prefix: string = "mapcomplete-"
): UIEventSource<T | undefined> {
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
} }
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> { public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
return this.preferencesHandler.GetLongPreference(key, prefix) return this.preferencesHandler.getPreference(key, prefix)
} }
public OnLoggedIn(action: (userDetails: UserDetails) => void) { public OnLoggedIn(action: (userDetails: UserDetails) => void) {
@ -183,7 +189,6 @@ export class OsmConnection {
this.userDetails.ping() this.userDetails.ping()
console.log("Logged out") console.log("Logged out")
this.loadingStatus.setData("not-attempted") this.loadingStatus.setData("not-attempted")
this.preferencesHandler.preferences.setData(undefined)
} }
/** /**

View file

@ -1,333 +1,341 @@
import { UIEventSource } from "../UIEventSource" import { Store, UIEventSource } from "../UIEventSource"
import UserDetails, { OsmConnection } from "./OsmConnection" import { OsmConnection } from "./OsmConnection"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource" import { LocalStorageSource } from "../Web/LocalStorageSource"
// @ts-ignore import OSMAuthInstance = OSMAuth.osmAuth
import { osmAuth } from "osm-auth" import { Utils } from "../../Utils"
import OSMAuthInstance = OSMAuth.OSMAuthInstance
export class OsmPreferences { export class OsmPreferences {
private preferences: Record<string, UIEventSource<string>> = {}
private localStorageInited: Set<string> = new Set()
/** /**
* A dictionary containing all the preferences. The 'preferenceSources' will be initialized from this * Contains all the keys as returned by the OSM-preferences.
* We keep a local copy of them, to init mapcomplete with the previous choices and to be able to get the open changesets right away * Used to clean up old preferences
*/ */
public preferences = LocalStorageSource.GetParsed<Record<string, string>>( private seenKeys: string[] = []
"all-osm-preferences",
{} private readonly _allPreferences: UIEventSource<Record<string, string>> = new UIEventSource({})
) public readonly allPreferences: Store<Readonly<Record<string, string>>> = this._allPreferences
/**
* A map containing the individual preference sources
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private readonly auth: OSMAuthInstance
private userDetails: UIEventSource<UserDetails>
private longPreferences = {}
private readonly _fakeUser: boolean private readonly _fakeUser: boolean
private readonly auth: OSMAuthInstance
private readonly osmConnection: OsmConnection
constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) { constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) {
this.auth = auth this.auth = auth
this._fakeUser = fakeUser this._fakeUser = fakeUser
this.userDetails = osmConnection.userDetails this.osmConnection = osmConnection
osmConnection.OnLoggedIn(() => { osmConnection.OnLoggedIn(() => {
this.UpdatePreferences(true) this.loadBulkPreferences()
return true return true
}) })
} }
/**
* OSM preferences can be at most 255 chars private setPreferencesAll(key: string, value: string) {
* @param key if (this._allPreferences.data[key] !== value) {
* @param prefix this._allPreferences.data[key] = value
* @constructor this._allPreferences.ping()
*/
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
if (this.longPreferences[prefix + key] !== undefined) {
return this.longPreferences[prefix + key]
} }
const source = new UIEventSource<string>(undefined, "long-osm-preference:" + prefix + key)
this.longPreferences[prefix + key] = source
const allStartWith = prefix + key + "-combined"
const subOptions = { prefix: "" }
// Gives the number of combined preferences
const length = this.GetPreference(allStartWith + "-length", "", subOptions)
if ((allStartWith + "-length").length > 255) {
throw (
"This preference key is too long, it has " +
key.length +
" characters, but at most " +
(255 - "-length".length - "-combined".length - prefix.length) +
" characters are allowed"
)
}
const self = this
source.addCallback((str) => {
if (str === undefined || str === "") {
return
}
if (str === null) {
console.error("Deleting " + allStartWith)
let count = parseInt(length.data)
for (let i = 0; i < count; i++) {
// Delete all the preferences
self.GetPreference(allStartWith + "-" + i, "", subOptions).setData("")
}
self.GetPreference(allStartWith + "-length", "", subOptions).setData("")
return
}
let i = 0
while (str !== "") {
if (str === undefined || str === "undefined") {
source.setData(undefined)
throw (
"Got 'undefined' or a literal string containing 'undefined' for a long preference with name " +
key
)
}
if (str === "undefined") {
source.setData(undefined)
throw (
"Got a literal string containing 'undefined' for a long preference with name " +
key
)
}
if (i > 100) {
throw "This long preference is getting very long... "
}
self.GetPreference(allStartWith + "-" + i, "", subOptions).setData(
str.substr(0, 255)
)
str = str.substr(255)
i++
}
length.setData("" + i) // We use I, the number of preference fields used
})
function updateData(l: number) {
if (Object.keys(self.preferences.data).length === 0) {
// The preferences are still empty - they are not yet updated, so we delay updating for now
return
}
const prefsCount = Number(l)
if (prefsCount > 100) {
throw "Length to long"
}
let str = ""
for (let i = 0; i < prefsCount; i++) {
const key = allStartWith + "-" + i
if (self.preferences.data[key] === undefined) {
console.warn(
"Detected a broken combined preference:",
key,
"is undefined",
self.preferences
)
}
str += self.preferences.data[key] ?? ""
}
source.setData(str)
}
length.addCallback((l) => {
updateData(Number(l))
})
this.preferences.addCallbackAndRun((_) => {
updateData(Number(length.data))
})
return source
} }
public GetPreference( private initPreference(key: string, value: string = ""): UIEventSource<string> {
if (this.preferences[key] !== undefined) {
return this.preferences[key]
}
const pref = this.preferences[key] = new UIEventSource(value, "preference: " + key)
if (value) {
this.setPreferencesAll(key, value)
}
pref.addCallback(v => {
this.uploadKvSplit(key, v)
this.setPreferencesAll(key, v)
})
return pref
}
private async loadBulkPreferences() {
const prefs = await this.getPreferencesDictDirectly()
this.seenKeys = Object.keys(prefs)
const legacy = OsmPreferences.getLegacyCombinedItems(prefs)
const merged = OsmPreferences.mergeDict(prefs)
for (const key in merged) {
this.initPreference(key, prefs[key])
}
for (const key in legacy) {
this.initPreference(key, legacy[key])
}
}
public getPreference(
key: string,
defaultValue: string = undefined,
prefix?: string,
) {
return this.getPreferenceSeedFromlocal(key, defaultValue, { prefix })
}
/**
* Gets a OSM-preference.
* The OSM-preference is cached in local storage and updated from the OSM.org as soon as those values come in.
* THis means that values written before being logged in might be erased by the cloud settings
*/
private getPreferenceSeedFromlocal(
key: string, key: string,
defaultValue: string = undefined, defaultValue: string = undefined,
options?: { options?: {
documentation?: string prefix?: string,
prefix?: string saveToLocalStorage?: true | boolean
} },
): UIEventSource<string> { ): UIEventSource<string> {
const prefix: string = options?.prefix ?? "mapcomplete-" if (options?.prefix) {
if (key.startsWith(prefix) && prefix !== "") { key = options.prefix + key
console.trace(
"A preference was requested which has a duplicate prefix in its key. This is probably a bug"
)
} }
key = prefix + key key = key.replace(/[:/"' {}.%\\]/g, "")
key = key.replace(/[:\\\/"' {}.%]/g, "")
if (key.length >= 255) {
throw "Preferences: key length to big" const localStorage = LocalStorageSource.Get(key)
if (localStorage.data === "null" || localStorage.data === "undefined") {
localStorage.set(undefined)
} }
const cached = this.preferenceSources.get(key) let pref: UIEventSource<string> = this.initPreference(key, localStorage.data ?? defaultValue)
if (cached !== undefined) { if (this.localStorageInited.has(key)) {
return cached return pref
}
if (this.userDetails.data.loggedIn && this.preferences.data[key] === undefined) {
this.UpdatePreferences()
} }
const pref = new UIEventSource<string>( if (options?.saveToLocalStorage ?? true) {
this.preferences.data[key] ?? defaultValue, pref.addCallback(v => localStorage.set(v)) // Keep a local copy
"osm-preference:" + key }
) this.localStorageInited.add(key)
pref.addCallback((v) => {
this.UploadPreference(key, v)
})
this.preferences.addCallbackD((allPrefs) => {
const v = allPrefs[key]
if (v === undefined) {
return
}
pref.setData(v)
})
this.preferenceSources.set(key, pref)
return pref return pref
} }
public ClearPreferences() { public ClearPreferences() {
let isRunning = false console.log("Starting to remove all preferences")
const self = this this.removeAllWithPrefix("")
this.preferences.addCallback((prefs) => { }
console.log("Cleaning preferences...")
if (Object.keys(prefs).length == 0) { /**
return *
* OsmPreferences.mergeDict({abc: "123", def: "123", "def:0": "456", "def:1":"789"}) // => {abc: "123", def: "123456789"}
*/
private static mergeDict(dict: Record<string, string>): Record<string, string> {
const newDict = {}
const allKeys: string[] = Object.keys(dict)
const normalKeys = allKeys.filter(k => !k.match(/[a-z-_0-9A-Z]*:[0-9]+/))
for (const normalKey of normalKeys) {
if(normalKey.match(/-combined-[0-9]*$/) || normalKey.match(/-combined-length$/)){
// Ignore legacy keys
continue
} }
if (isRunning) { const partKeys = OsmPreferences.keysStartingWith(allKeys, normalKey)
return const parts = partKeys.map(k => dict[k])
} newDict[normalKey] = parts.join("")
isRunning = true }
const prefixes = ["mapcomplete-"] return newDict
for (const key in prefs) { }
const matches = prefixes.some((prefix) => key.startsWith(prefix))
if (matches) { /**
console.log("Clearing ", key) * Gets all items which have a 'combined'-string, the legacy long preferences
self.GetPreference(key, "", { prefix: "" }).setData("") *
} * const input = {
} * "extra-noncombined-key":"xyz",
isRunning = false * "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-0":
return * "{\"id\":\"https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json\",\"icon\":\"https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg\",\"title\":{\"ca\":\"wikidataimg\",\"_context\":\"themes:wikidataimg.title\"},\"shortDescription\"",
* "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-1":
* ":{\"ca\":\"Afegeix imatges d'articles de wikimedia\",\"_context\":\"themes:wikidataimg\"}}",
* }
* const merged = OsmPreferences.getLegacyCombinedItems(input)
* const data = merged["mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson"]
* JSON.parse(data) // => {"id": "https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg","title": { "ca": "wikidataimg", "_context": "themes:wikidataimg.title" }, "shortDescription": {"ca": "Afegeix imatges d'articles de wikimedia","_context": "themes:wikidataimg"}}
* merged["extra-noncombined-key"] // => undefined
*/
public static getLegacyCombinedItems(dict: Record<string, string>): Record<string, string> {
const merged: Record<string, string> = {}
const keys = Object.keys(dict)
const toCheck =Utils.NoNullInplace( Utils.Dedup(keys.map(k => k.match(/(.*)-combined-[0-9]+$/)?.[1])))
for (const key of toCheck) {
let i = 0
let str = ""
let v: string
do {
v = dict[key + "-combined-" + i]
str += v ?? ""
i++
} while (v !== undefined)
merged[key] = str
}
return merged
}
/**
* Bulk-downloads all preferences
* @private
*/
private getPreferencesDictDirectly(): Promise<Record<string, string>> {
return new Promise<Record<string, string>>((resolve, reject) => {
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/preferences",
},
(error, value: XMLDocument) => {
if (error) {
console.log("Could not load preferences", error)
reject(error)
return
}
const prefs = value.getElementsByTagName("preference")
const dict: Record<string, string> = {}
for (let i = 0; i < prefs.length; i++) {
const pref = prefs[i]
const k = pref.getAttribute("k")
dict[k] = pref.getAttribute("v")
}
resolve(dict)
},
)
}) })
} }
removeAllWithPrefix(prefix: string) { /**
for (const key in this.preferences.data) { * Returns all keys matching `k:[number]`
if (key.startsWith(prefix)) { * Split separately for test
this.GetPreference(key, "", { prefix: "" }).setData(undefined) *
console.log("Clearing preference", key) * const keys = ["abc", "def", "ghi", "ghi:0", "ghi:1"]
} * OsmPreferences.keysStartingWith(keys, "xyz") // => []
} * OsmPreferences.keysStartingWith(keys, "abc") // => ["abc"]
this.preferences.ping() * OsmPreferences.keysStartingWith(keys, "ghi") // => ["ghi", "ghi:0", "ghi:1"]
*
*/
private static keysStartingWith(allKeys: string[], key: string): string[] {
const keys = allKeys.filter(k => k === key || k.match(new RegExp(key + ":[0-9]+")))
keys.sort()
return keys
} }
private UpdatePreferences(forceUpdate?: boolean) { /**
const self = this * Smart 'upload', which splits the value into `k`, `k:0`, `k:1` if needed.
if (this._fakeUser) { * If `v` is null, undefined, empty, "undefined" (literal string) or "null" (literal string), will delete `k` and `k:[number]`
*
*/
private async uploadKvSplit(k: string, v: string) {
if (v === null || v === undefined || v === "" || v === "undefined" || v === "null") {
const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k)
await Promise.all(keysToDelete.map(k => this.deleteKeyDirectly(k)))
return return
} }
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/preferences",
},
function (error, value: XMLDocument) {
if (error) {
console.log("Could not load preferences", error)
return
}
const prefs = value.getElementsByTagName("preference")
const seenKeys = new Set<string>()
for (let i = 0; i < prefs.length; i++) {
const pref = prefs[i]
const k = pref.getAttribute("k")
const v = pref.getAttribute("v")
self.preferences.data[k] = v
seenKeys.add(k)
}
if (forceUpdate) {
for (let key in self.preferences.data) {
if (seenKeys.has(key)) {
continue
}
console.log("Deleting key", key, "as we didn't find it upstream")
delete self.preferences.data[key]
}
}
// We merge all the preferences: new keys are uploaded
// For differing values, the server overrides local changes
self.preferenceSources.forEach((preference, key) => {
const osmValue = self.preferences.data[key]
if (osmValue === undefined && preference.data !== undefined) {
// OSM doesn't know this value yet
self.UploadPreference(key, preference.data)
} else {
// OSM does have a value - set it
preference.setData(osmValue)
}
})
self.preferences.ping() await this.uploadKeyDirectly(k, v.slice(0, 255))
} v = v.slice(255)
) let i = 0
while (v.length > 0) {
await this.uploadKeyDirectly(`${k}:${i}`, v.slice(0, 255))
v = v.slice(255)
}
} }
private UploadPreference(k: string, v: string) { /**
if (!this.userDetails.data.loggedIn) { * Directly deletes this key
* @param k
* @private
*/
private deleteKeyDirectly(k: string) {
if (!this.osmConnection.userDetails.data.loggedIn) {
console.debug(`Not saving preference ${k}: user not logged in`) console.debug(`Not saving preference ${k}: user not logged in`)
return return
} }
if (this.preferences.data[k] === v) {
return
}
const self = this
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15))
if (this._fakeUser) { if (this._fakeUser) {
return return
} }
if (v === undefined || v === "") { return new Promise<void>((resolve, reject) => {
this.auth.xhr(
{ this.auth.xhr(
method: "DELETE", {
path: "/api/0.6/user/preferences/" + encodeURIComponent(k), method: "DELETE",
headers: { "Content-Type": "text/plain" }, path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
}, headers: { "Content-Type": "text/plain" },
function (error) { },
if (error) { (error) => {
console.warn("Could not remove preference", error) if (error) {
return console.warn("Could not remove preference", error)
} reject(error)
delete self.preferences.data[k] return
self.preferences.ping() }
console.debug("Preference ", k, "removed!") console.debug("Preference ", k, "removed!")
} resolve()
) },
)
},
)
}
/**
* Uploads the given k=v to the OSM-server
* Deletes it if 'v' is undefined, null or empty
*/
private async uploadKeyDirectly(k: string, v: string) {
if (!this.osmConnection.userDetails.data.loggedIn) {
console.debug(`Not saving preference ${k}: user not logged in`)
return return
} }
this.auth.xhr( if (this._fakeUser) {
{ return
method: "PUT", }
path: "/api/0.6/user/preferences/" + encodeURIComponent(k), if (v === undefined || v === "" || v === null) {
headers: { "Content-Type": "text/plain" }, await this.deleteKeyDirectly(k)
content: v, return
}, }
function (error) {
if (error) { if (v.length > 255) {
console.warn(`Could not set preference "${k}"'`, error) console.error("Preference too long, max 255 chars", { k, v })
return throw "Preference too long, at most 255 characters are supported"
} }
self.preferences.data[k] = v
self.preferences.ping() return new Promise<void>((resolve, reject) => {
console.debug(`Preference ${k} written!`)
} this.auth.xhr(
) {
method: "PUT",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
headers: { "Content-Type": "text/plain" },
content: v,
},
(error) => {
if (error) {
console.warn(`Could not set preference "${k}"'`, error)
reject(error)
return
}
resolve()
},
)
})
}
async removeAllWithPrefix(prefix: string) {
const keys = this.seenKeys
for (const key in keys) {
await this.deleteKeyDirectly(key)
}
}
getExistingPreference(key: string, defaultValue: undefined, prefix: string): UIEventSource<string> {
if (prefix) {
key = prefix + key
}
key = key.replace(/[:/"' {}.%\\]/g, "")
return this.preferences[key]
} }
} }

View file

@ -0,0 +1,55 @@
import GeocodingProvider, { SearchResult, GeocodingOptions, GeocodeResult } from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { Store, Stores } from "../UIEventSource"
export default class CombinedSearcher implements GeocodingProvider {
private _providers: ReadonlyArray<GeocodingProvider>
private _providersWithSuggest: ReadonlyArray<GeocodingProvider>
constructor(...providers: ReadonlyArray<GeocodingProvider>) {
this._providers = Utils.NoNull(providers)
this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined)
}
/**
* Merges the geocode-results from various sources.
* If the same osm-id is mentioned multiple times, only the first result will be kept
* @param geocoded
* @private
*/
public static merge(geocoded: GeocodeResult[][]): GeocodeResult[] {
const results: GeocodeResult[] = []
const seenIds = new Set<string>()
for (const geocodedElement of geocoded) {
if(geocodedElement === undefined){
continue
}
for (const entry of geocodedElement) {
if (entry.osm_id === undefined) {
throw "Invalid search result: a search result always must have an osm_id to be able to merge results from different sources"
}
const id = (entry["osm_type"] ?? "") + entry.osm_id
if (seenIds.has(id)) {
continue
}
seenIds.add(id)
results.push(entry)
}
}
return results
}
async search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
const results = (await Promise.all(this._providers.map(pr => pr.search(query, options))))
return CombinedSearcher.merge(results)
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return Stores.concat(
this._providersWithSuggest.map(pr => pr.suggest(query, options)))
.map(gcrss => CombinedSearcher.merge(gcrss))
}
}

View file

@ -0,0 +1,98 @@
import GeocodingProvider, { GeocodeResult } from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { ImmutableStore, Store } from "../UIEventSource"
/**
* A simple search-class which interprets possible locations
*/
export default class CoordinateSearch implements GeocodingProvider {
private static readonly latLonRegexes: ReadonlyArray<RegExp> = [
/^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lng[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/https:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/(-?[0-9]+\.[0-9]+)\/(-?[0-9]+\.[0-9]+)/,
/https:\/\/www.google.com\/maps\/@(-?[0-9]+.[0-9]+),(-?[0-9]+.[0-9]+).*/,
]
private static readonly lonLatRegexes: ReadonlyArray<RegExp> = [
/^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/lng[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
]
/**
*
* const ls = new CoordinateSearch()
* const results = ls.directSearch("https://www.openstreetmap.org/search?query=Brugge#map=11/51.2611/3.2217")
* results.length // => 1
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate", "osm_id": "3.2217/51.2611","source": "coordinate:latlon"}
*
* const ls = new CoordinateSearch()
* const results = ls.directSearch("https://www.openstreetmap.org/#map=11/51.2611/3.2217")
* results.length // => 1
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate","osm_id": "3.2217/51.2611","source": "coordinate:latlon"}
*
* const ls = new CoordinateSearch()
* const results = ls.directSearch("51.2611 3.2217")
* results.length // => 2
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate", "osm_id": "3.2217/51.2611","source": "coordinate:latlon"}
* results[1] // => {lon: 51.2611, lat: 3.2217, display_name: "lon: 51.2611, lat: 3.2217", "category": "coordinate", "osm_id": "51.2611/3.2217","source": "coordinate:lonlat"}
*
* // Test format mentioned in 1599
* const ls = new CoordinateSearch()
* const results = ls.directSearch("51.2611/3.2217")
* results.length // => 2
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate", "source": "coordinate:latlon", "osm_id": "3.2217/51.2611",}
* results[1] // => {lon: 51.2611, lat: 3.2217, display_name: "lon: 51.2611, lat: 3.2217", "category": "coordinate", "osm_id": "51.2611/3.2217","source": "coordinate:lonlat"}
*
* // test OSM-XML format
* const ls = new CoordinateSearch()
* const results = ls.directSearch(' lat="57.5802905" lon="12.7202538"')
* results.length // => 1
* results[0] // => {lat: 57.5802905, lon: 12.7202538, "display_name": "lon: 12.720254, lat: 57.580291", "category": "coordinate", "osm_id": "12.720254/57.580291","source": "coordinate:latlon"}
*
* // should work with negative coordinates
* const ls = new CoordinateSearch()
* const results = ls.directSearch(' lat="-57.5802905" lon="-12.7202538"')
* results.length // => 1
* results[0] // => {lat: -57.5802905, lon: -12.7202538, "display_name": "lon: -12.720254, lat: -57.58029", "category": "coordinate","osm_id": "-12.720254/-57.58029", "source": "coordinate:latlon"}
*/
private directSearch(query: string): GeocodeResult[] {
const matches = Utils.NoNull(CoordinateSearch.latLonRegexes.map(r => query.match(r)))
.map(m => CoordinateSearch.asResult(m[2], m[1], "latlon") )
const matchesLonLat = Utils.NoNull(CoordinateSearch.lonLatRegexes.map(r => query.match(r)))
.map(m => CoordinateSearch.asResult(m[1], m[2], "lonlat"))
return matches.concat(matchesLonLat)
}
private static round6(n: number): string {
return "" + (Math.round(n * 1000000) / 1000000)
}
private static asResult(lonIn: string, latIn: string, source: string): GeocodeResult {
const lon = Number(lonIn)
const lat = Number(latIn)
const lonStr = CoordinateSearch.round6(lon)
const latStr = CoordinateSearch.round6(lat)
return {
lat,
lon,
display_name: "lon: " + lonStr + ", lat: " + latStr,
category: "coordinate",
source: "coordinate:"+source,
osm_id: lonStr + "/" + latStr,
}
}
suggest(query: string): Store<GeocodeResult[]> {
return new ImmutableStore(this.directSearch(query))
}
async search(query: string): Promise<GeocodeResult[]> {
return this.directSearch(query)
}
}

View file

@ -0,0 +1,131 @@
import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"
import Constants from "../../Models/Constants"
import FilterConfig, { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayerState from "../State/LayerState"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export type FilterSearchResult = { option: FilterConfigOption, filter: FilterConfig, layer: LayerConfig, index: number }
/**
* Searches matching filters
*/
export default class FilterSearch {
private readonly _state: {layerState: LayerState, layout: LayoutConfig}
constructor(state: {layerState: LayerState, layout: LayoutConfig}) {
this._state = state
}
public search(query: string): FilterSearchResult[] {
if (query.length === 0) {
return []
}
const queries = query.split(" ").map(query => {
if (!Utils.isEmoji(query)) {
return Utils.simplifyStringForSearch(query)
}
return query
}).filter(q => q.length > 0)
const possibleFilters: FilterSearchResult[] = []
for (const layer of this._state.layout.layers) {
if (!Array.isArray(layer.filters)) {
continue
}
if (layer.filterIsSameAs !== undefined) {
continue
}
for (const filter of layer.filters ?? []) {
for (let i = 0; i < filter.options.length; i++) {
const option = filter.options[i]
if (option === undefined) {
continue
}
if (!option.osmTags) {
continue
}
if(option.fields.length > 0){
// Filters with a search field are not supported as of now, see #2141
continue
}
let terms = ([option.question.txt,
...(option.searchTerms?.[Locale.language.data] ?? option.searchTerms?.["en"] ?? [])]
.flatMap(term => [term, ...(term?.split(" ") ?? [])]))
terms = terms.map(t => Utils.simplifyStringForSearch(t))
terms.push(option.emoji)
Utils.NoNullInplace(terms)
const distances = queries.flatMap(query => terms.map(entry => {
const d = Utils.levenshteinDistance(query, entry.slice(0, query.length))
const dRelative = d / query.length
return dRelative
}))
const levehnsteinD = Math.min(...distances)
if (levehnsteinD > 0.25) {
continue
}
possibleFilters.push({
option, layer, filter, index:
i,
})
}
}
}
return possibleFilters
}
/**
* Create a random list of filters
*/
getSuggestions(): FilterSearchResult[] {
const result: FilterSearchResult[] = []
for (const [id, filteredLayer] of this._state.layerState.filteredLayers) {
if (!Array.isArray(filteredLayer.layerDef.filters)) {
continue
}
if (Constants.priviliged_layers.indexOf(<any> id) >= 0) {
continue
}
for (const filter of filteredLayer.layerDef.filters) {
const singleFilterResults: FilterSearchResult[] = []
for (let i = 0; i < Math.min(filter.options.length, 5); i++) {
const option = filter.options[i]
if (option.osmTags === undefined) {
continue
}
singleFilterResults.push({
option,
filter,
index: i,
layer: filteredLayer.layerDef,
})
}
Utils.shuffle(singleFilterResults)
result.push(...singleFilterResults.slice(0, 3))
}
}
Utils.shuffle(result)
return result.slice(0, 6)
}
/**
* Partitions the list of filters in such a way that identically appearing filters will be in the same sublist.
*
* Note that this depends on the language and the displayed text. For example, two filters {"en": "A", "nl": "B"} and {"en": "X", "nl": "B"} will be joined for dutch but not for English
*
*/
static mergeSemiIdenticalLayers<T extends FilterSearchResult = FilterSearchResult>(filters: ReadonlyArray<T>, language: string):T[][] {
const results : Record<string, T[]> = {}
for (const filter of filters) {
const txt = filter.option.question.textFor(language)
if(results[txt]){
results[txt].push(filter)
}else{
results[txt] = [filter]
}
}
return Object.values(results)
}
}

View file

@ -0,0 +1,45 @@
import { SearchResult } from "./GeocodingProvider"
import { Store } from "../UIEventSource"
import { FeatureSource } from "../FeatureSource/FeatureSource"
import { Feature, Geometry } from "geojson"
export default class GeocodingFeatureSource implements FeatureSource {
public features: Store<Feature<Geometry, Record<string, string>>[]>
constructor(provider: Store<SearchResult[]>) {
this.features = provider.map(geocoded => {
if(geocoded === undefined){
return []
}
const features: Feature[] = []
for (const gc of geocoded) {
if (gc.lat === undefined || gc.lon === undefined) {
continue
}
features.push({
type: "Feature",
properties: {
id: "search_result_" + gc.osm_type + "/" + gc.osm_id,
category: gc.category,
description: gc.description,
display_name: gc.display_name,
osm_id: gc.osm_type + "/" + gc.osm_id,
osm_key: gc.feature?.properties?.osm_key,
osm_value: gc.feature?.properties?.osm_value,
source: gc.source
},
geometry: {
type: "Point",
coordinates: [gc.lon, gc.lat]
}
})
}
return features
})
}
}

View file

@ -0,0 +1,155 @@
import { BBox } from "../BBox"
import { Feature, Geometry } from "geojson"
import { DefaultPinIcon } from "../../Models/Constants"
import { Store } from "../UIEventSource"
import * as search from "../../assets/generated/layers/search.json"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import { GeoOperations } from "../GeoOperations"
export type GeocodingCategory =
"coordinate"
| "city"
| "house"
| "street"
| "locality"
| "country"
| "train_station"
| "county"
| "airport"
| "shop"
export type GeocodeResult = {
/**
* The name of the feature being displayed
*/
display_name: string
/**
* Some optional, extra information
*/
description?: string | Promise<string>,
feature?: Feature,
lat: number
lon: number
/**
* Format:
* [lat, lat, lon, lon]
*/
boundingbox?: number[]
osm_type?: "node" | "way" | "relation"
osm_id: string,
category?: GeocodingCategory,
payload?: object,
source?: string
}
export type SearchResult =
| GeocodeResult
export interface GeocodingOptions {
bbox?: BBox
}
export default interface GeocodingProvider {
search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]>
/**
* @param query
* @param options
*/
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]>
}
export type ReverseGeocodingResult = Feature<Geometry, {
osm_id: number,
osm_type: "node" | "way" | "relation",
country: string,
city: string,
countrycode: string,
type: GeocodingCategory,
street: string
}>
export interface ReverseGeocodingProvider {
reverseSearch(
coordinate: { lon: number; lat: number },
zoom: number,
language?: string,
): Promise<ReverseGeocodingResult[]>;
}
export class GeocodingUtils {
public static searchLayer = GeocodingUtils.initSearchLayer()
private static initSearchLayer(): LayerConfig {
if (search["id"] === undefined) {
// We are resetting the layeroverview; trying to parse is useless
return undefined
}
return new LayerConfig(<LayerConfigJson>search, "search")
}
public static categoryToZoomLevel: Record<GeocodingCategory, number> = {
city: 12,
county: 10,
coordinate: 16,
country: 8,
house: 16,
locality: 14,
street: 15,
train_station: 14,
airport: 13,
shop: 16,
}
public static mergeSimilarResults(results: GeocodeResult[]){
const byName: Record<string, GeocodeResult[]> = {}
for (const result of results) {
const nm = result.display_name
if(!byName[nm]) {
byName[nm] = []
}
byName[nm].push(result)
}
const merged: GeocodeResult[] = []
for (const nm in byName) {
const options = byName[nm]
const added = options[0]
merged.push(added)
const centers: [number,number][] = [[added.lon, added.lat]]
for (const other of options) {
const otherCenter:[number,number] = [other.lon, other.lat]
const nearbyFound= centers.some(center => GeoOperations.distanceBetween(center, otherCenter) < 500)
if(!nearbyFound){
merged.push(other)
centers.push(otherCenter)
}
}
}
return merged
}
public static categoryToIcon: Record<GeocodingCategory, DefaultPinIcon> = {
city: "building_office_2",
coordinate: "globe_alt",
country: "globe_alt",
house: "house",
locality: "building_office_2",
street: "globe_alt",
train_station: "train",
county: "building_office_2",
airport: "airport",
shop: "building_storefront",
}
}

View file

@ -0,0 +1,53 @@
import Constants from "../../Models/Constants"
import SearchUtils from "./SearchUtils"
import ThemeSearch from "./ThemeSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Utils } from "../../Utils"
export default class LayerSearch {
private readonly _layout: LayoutConfig
private readonly _layerWhitelist : Set<string>
constructor(layout: LayoutConfig) {
this._layout = layout
this._layerWhitelist = new Set(layout.layers.map(l => l.id).filter(id => Constants.added_by_default.indexOf(<any> id) < 0))
}
static scoreLayers(query: string, layerWhitelist?: Set<string>): Record<string, number> {
const result: Record<string, number> = {}
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
for (const id in ThemeSearch.officialThemes.layers) {
if(layerWhitelist !== undefined && !layerWhitelist.has(id)){
continue
}
const keywords = ThemeSearch.officialThemes.layers[id]
const distance = Math.min(... queryParts.map(q => SearchUtils.scoreKeywords(q, keywords)))
result[id] = distance
}
return result
}
public search(query: string, limit: number): LayerConfig[] {
if (query.length < 1) {
return []
}
const scores = LayerSearch.scoreLayers(query, this._layerWhitelist)
const asList:({layer: LayerConfig, score:number})[] = []
for (const layer in scores) {
asList.push({
layer: this._layout.getLayer(layer),
score: scores[layer]
})
}
asList.sort((a, b) => a.score - b.score)
return asList
.filter(sorted => sorted.score < 2)
.slice(0, limit)
.map(l => l.layer)
}
}

View file

@ -0,0 +1,124 @@
import GeocodingProvider, { SearchResult, GeocodingOptions } from "./GeocodingProvider"
import ThemeViewState from "../../Models/ThemeViewState"
import { Utils } from "../../Utils"
import { Feature } from "geojson"
import { GeoOperations } from "../GeoOperations"
import { ImmutableStore, Store, Stores } from "../UIEventSource"
import OpenStreetMapIdSearch from "./OpenStreetMapIdSearch"
type IntermediateResult = {
feature: Feature,
/**
* Lon, lat
*/
center: [number, number],
levehnsteinD: number,
physicalDistance: number,
searchTerms: string[],
description: string
}
export default class LocalElementSearch implements GeocodingProvider {
private readonly _state: ThemeViewState
private readonly _limit: number
constructor(state: ThemeViewState, limit: number) {
this._state = state
this._limit = limit
}
async search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
return this.searchEntries(query, options, false).data
}
private getPartialResult(query: string, candidateId: string | undefined, matchStart: boolean, centerpoint: [number, number], features: Feature[]): IntermediateResult[] {
const results: IntermediateResult [] = []
for (const feature of features) {
const props = feature.properties
const searchTerms: string[] = Utils.NoNull([props.name, props.alt_name, props.local_name,
(props["addr:street"] && props["addr:number"]) ?
props["addr:street"] + props["addr:number"] : undefined])
let levehnsteinD: number
if (candidateId === props.id) {
levehnsteinD = 0
} else {
levehnsteinD = Math.min(...searchTerms.flatMap(entry => entry.split(/ /)).map(entry => {
let simplified = Utils.simplifyStringForSearch(entry)
if (matchStart) {
simplified = simplified.slice(0, query.length)
}
return Utils.levenshteinDistance(query, simplified)
}))
}
const center = GeoOperations.centerpointCoordinates(feature)
if ((levehnsteinD / query.length) <= 0.3) {
let description = ""
if (feature.properties["addr:street"]) {
description += "" + feature.properties["addr:street"]
}
if (feature.properties["addr:housenumber"]) {
description += " " + feature.properties["addr:housenumber"]
}
results.push({
feature,
center,
physicalDistance: GeoOperations.distanceBetween(centerpoint, center),
levehnsteinD,
searchTerms,
description: description !== "" ? description : undefined,
})
}
}
return results
}
searchEntries(query: string, options?: GeocodingOptions, matchStart?: boolean): Store<SearchResult[]> {
if (query.length < 3) {
return new ImmutableStore([])
}
const center: { lon: number; lat: number } = this._state.mapProperties.location.data
const centerPoint: [number, number] = [center.lon, center.lat]
const properties = this._state.perLayer
const candidateId = OpenStreetMapIdSearch.extractId(query)
query = Utils.simplifyStringForSearch(query)
const partials: Store<IntermediateResult[]>[] = []
for (const [_, geoIndexedStore] of properties) {
const partialResult = geoIndexedStore.features.map(features => this.getPartialResult(query, candidateId, matchStart, centerPoint, features))
partials.push(partialResult)
}
const listed: Store<IntermediateResult[]> = Stores.concat(partials).map(l => l.flatMap(x => x))
return listed.mapD(results => {
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
if (this._limit) {
results = results.slice(0, this._limit)
}
return results.map(entry => {
const [osm_type, osm_id] = entry.feature.properties.id.split("/")
return <SearchResult>{
lon: entry.center[0],
lat: entry.center[1],
osm_type,
osm_id,
display_name: entry.searchTerms[0],
source: "localElementSearch",
feature: entry.feature,
importance: 1,
description: entry.description,
}
})
})
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return this.searchEntries(query, options, true)
}
}

View file

@ -0,0 +1,41 @@
import { Utils } from "../../Utils"
import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
import { FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale"
import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider"
export class NominatimGeocoding implements GeocodingProvider {
private readonly _host ;
private readonly limit: number
constructor(limit: number = 3, host: string = Constants.nominatimEndpoint) {
this.limit = limit
this._host = host
}
public search(query: string, options?:GeocodingOptions): Promise<SearchResult[]> {
const b = options?.bbox ?? BBox.global
const url = `${
this._host
}search?format=json&limit=${this.limit}&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${
Locale.language.data
}&q=${query}`
return Utils.downloadJson(url)
}
async reverseSearch(
coordinate: { lon: number; lat: number },
zoom: number = 17,
language?: string
): Promise<FeatureCollection> {
// https://nominatim.org/release-docs/develop/api/Reverse/
// IF the zoom is low, it'll only return a country instead of an address
const url = `${this._host}reverse?format=geojson&lat=${coordinate.lat}&lon=${
coordinate.lon
}&zoom=${Math.ceil(zoom) + 1}&accept-language=${language}`
return Utils.downloadJson(url)
}
}

View file

@ -0,0 +1,96 @@
import { Store, UIEventSource } from "../UIEventSource"
import GeocodingProvider, { GeocodingOptions, GeocodeResult } from "./GeocodingProvider"
import { OsmId } from "../../Models/OsmFeature"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils"
export default class OpenStreetMapIdSearch implements GeocodingProvider {
private static readonly regex = /((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/
private static readonly types: Readonly<Record<string, "node" | "way" | "relation">> = {
"n": "node",
"w": "way",
"r": "relation",
}
private readonly _state: SpecialVisualizationState
constructor(state: SpecialVisualizationState) {
this._state = state
}
/**
*
* OpenStreetMapIdSearch.extractId("osm.org/node/42") // => "node/42"
* OpenStreetMapIdSearch.extractId("https://openstreetmap.org/node/42#map=19/51.204245/3.212731") // => "node/42"
* OpenStreetMapIdSearch.extractId("node/42") // => "node/42"
* OpenStreetMapIdSearch.extractId("way/42") // => "way/42"
* OpenStreetMapIdSearch.extractId("n123456789") // => "node/123456789"
* OpenStreetMapIdSearch.extractId("node123456789") // => "node/123456789"
* OpenStreetMapIdSearch.extractId("node 123456789") // => "node/123456789"
* OpenStreetMapIdSearch.extractId("w123456789") // => "way/123456789"
* OpenStreetMapIdSearch.extractId("way123456789") // => "way/123456789"
* OpenStreetMapIdSearch.extractId("way 123456789") // => "way/123456789"
* OpenStreetMapIdSearch.extractId("https://www.openstreetmap.org/node/5212733638") // => "node/5212733638"
*/
public static extractId(query: string): OsmId | undefined {
const match = query.match(OpenStreetMapIdSearch.regex)
if (match) {
let type = match.at(-2)
const id = match.at(-1)
if (type.length === 1) {
type = OpenStreetMapIdSearch.types[type]
}
return <OsmId>(type + "/" + id)
}
return undefined
}
private async getInfoAbout(id: OsmId): Promise<GeocodeResult> {
const [osm_type, osm_id] = id.split("/")
const obj = await this._state.osmObjectDownloader.DownloadObjectAsync(id)
if (obj === "deleted") {
return {
display_name: id + " was deleted",
category: "coordinate",
osm_type: <"node" | "way" | "relation">osm_type,
osm_id,
lat: 0, lon: 0,
source: "osmid",
}
}
const [lat, lon] = obj.centerpoint()
return {
lat, lon,
display_name: obj.tags.name ?? obj.tags.alt_name ?? obj.tags.local_name ?? obj.tags.ref ?? id,
description: osm_type,
osm_type: <"node" | "way" | "relation">osm_type,
osm_id,
source: "osmid",
}
}
async search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> {
if (!isNaN(Number(query))) {
const n = Number(query)
return Utils.NoNullInplace(await Promise.all([
this.getInfoAbout(`node/${n}`).catch(x => undefined),
this.getInfoAbout(`way/${n}`).catch(x => undefined),
this.getInfoAbout(`relation/${n}`).catch(() => undefined),
]))
}
const id = OpenStreetMapIdSearch.extractId(query)
if (!id) {
return []
}
return [await this.getInfoAbout(id)]
}
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return UIEventSource.FromPromise(this.search(query, options))
}
}

View file

@ -0,0 +1,148 @@
import Constants from "../../Models/Constants"
import GeocodingProvider, {
GeocodeResult,
GeocodingCategory,
GeocodingOptions, GeocodingUtils,
ReverseGeocodingProvider,
ReverseGeocodingResult,
} from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { Feature, FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale"
import { GeoOperations } from "../GeoOperations"
import { Store, Stores } from "../UIEventSource"
export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider {
private _endpoint: string
private supportedLanguages = ["en", "de", "fr"]
private static readonly types = {
"R": "relation",
"W": "way",
"N": "node",
}
private readonly suggestionLimit: number = 5
private readonly searchLimit: number = 1
constructor(suggestionLimit:number = 5, searchLimit:number = 1, endpoint?: string) {
this.suggestionLimit = suggestionLimit
this.searchLimit = searchLimit
this._endpoint = endpoint ?? Constants.photonEndpoint ?? "https://photon.komoot.io/"
}
async reverseSearch(coordinate: {
lon: number;
lat: number
}, zoom: number, language?: string): Promise<ReverseGeocodingResult[]> {
const url = `${this._endpoint}/reverse?lon=${coordinate.lon}&lat=${coordinate.lat}&${this.getLanguage(language)}`
const result = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
for (const f of result.features) {
f.properties.osm_type = PhotonSearch.types[f.properties.osm_type]
}
return <ReverseGeocodingResult[]>result.features
}
/**
* Gets a `&lang=en` if the current/requested language is supported
* @param language
* @private
*/
private getLanguage(language?: string): string {
language ??= Locale.language.data
if (this.supportedLanguages.indexOf(language) < 0) {
return ""
}
return `&lang=${language}`
}
suggest(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return Stores.FromPromise(this.search(query, options, this.suggestionLimit))
}
private buildDescription(entry: Feature) {
const p = entry.properties
const type = <GeocodingCategory>p.type
function ifdef(prefix: string, str: string) {
if (str) {
return prefix + str
}
return ""
}
switch (type) {
case "house": {
const addr = ifdef("", p.street) + ifdef(" ", p.housenumber)
if (!addr) {
return p.city
}
return addr + ifdef(", ", p.city)
}
case "coordinate":
case "street":
return p.city ?? p.country
case "city":
case "locality":
if (p.state) {
return p.state + ifdef(", ", p.country)
}
return p.country
case "country":
return undefined
}
}
private getCategory(entry: Feature) {
const p = entry.properties
if (p.osm_key === "shop") {
return "shop"
}
if (p.osm_value === "train_station" || p.osm_key === "railway") {
return "train_station"
}
if (p.osm_value === "aerodrome" || p.osm_key === "aeroway") {
return "airport"
}
return p.type
}
async search(query: string, options?: GeocodingOptions, limit?: number): Promise<GeocodeResult[]> {
if (query.length < 3) {
return []
}
limit ??= this.searchLimit
let bbox = ""
if (options?.bbox) {
const [lon, lat] = options.bbox.center()
bbox = `&lon=${lon}&lat=${lat}`
}
const url = `${this._endpoint}/api/?q=${encodeURIComponent(query)}&limit=${limit}${this.getLanguage()}${bbox}`
const results = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
const encoded= results.features.map(f => {
const [lon, lat] = GeoOperations.centerpointCoordinates(f)
let boundingbox: number[] = undefined
if (f.properties.extent) {
const [lon0, lat0, lon1, lat1] = f.properties.extent
boundingbox = [lat0, lat1, lon0, lon1]
}
return <GeocodeResult>{
feature: f,
osm_id: f.properties.osm_id,
display_name: f.properties.name,
description: this.buildDescription(f),
osm_type: PhotonSearch.types[f.properties.osm_type],
category: this.getCategory(f),
boundingbox,
lon, lat,
source: this._endpoint,
}
})
return GeocodingUtils.mergeSimilarResults(encoded)
}
}

View file

@ -0,0 +1,75 @@
import Locale from "../../UI/i18n/Locale"
import { Utils } from "../../Utils"
import ThemeSearch from "./ThemeSearch"
export default class SearchUtils {
/** Applies special search terms, such as 'studio', 'osmcha', ...
* Returns 'false' if nothing is matched.
* Doesn't return control flow if a match is found (navigates to another page in this case)
*/
public static applySpecialSearch(searchTerm: string, ) {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return false
}
if (searchTerm === "personal") {
window.location.href = ThemeSearch.createUrlFor({ id: "personal" }, undefined)
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
if (searchTerm === "studio") {
window.location.href = "./studio.html"
}
return false
}
/**
* Searches for the smallest distance in words; will split both the query and the terms
*
* SearchUtils.scoreKeywords("drinking water", {"en": ["A layer with drinking water points"]}, "en") // => 0
* SearchUtils.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
*
*/
public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number {
if(!keywords){
return Infinity
}
language ??= Locale.language.data
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
let terms: string[]
if (Array.isArray(keywords)) {
terms = keywords
} else {
terms = (keywords[language] ?? []).concat(keywords["*"])
}
const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" "))
let distanceSummed = 0
for (let i = 0; i < queryParts.length; i++) {
const q = queryParts[i]
let minDistance: number = 99
for (const term of termsAll) {
const d = Utils.levenshteinDistance(q, Utils.simplifyStringForSearch(term))
if (d < minDistance) {
minDistance = d
}
}
distanceSummed += minDistance
}
return distanceSummed
}
}

View file

@ -0,0 +1,150 @@
import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { Store } from "../UIEventSource"
import UserRelatedState from "../State/UserRelatedState"
import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"
import themeOverview from "../../assets/generated/theme_overview.json"
import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
import { OsmConnection } from "../Osm/OsmConnection"
type ThemeSearchScore = {
theme: MinimalLayoutInformation,
lowest: number,
perLayer?: Record<string, number>,
other: number
}
export default class ThemeSearch {
public static readonly officialThemes: {
themes: MinimalLayoutInformation[],
layers: Record<string, Record<string, string[]>>
} = <any> themeOverview
public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>()
static {
for (const th of ThemeSearch.officialThemes.themes ?? []) {
ThemeSearch.officialThemesById.set(th.id, th)
}
}
private readonly _knownHiddenThemes: Store<Set<string>>
private readonly _layersToIgnore: string[]
private readonly _otherThemes: MinimalLayoutInformation[]
constructor(state: {osmConnection: OsmConnection, layout: LayoutConfig}) {
this._layersToIgnore = state.layout.layers.map(l => l.id)
this._knownHiddenThemes = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection).map(list => new Set(list))
this._otherThemes = ThemeSearch.officialThemes.themes
.filter(th => th.id !== state.layout.id)
}
public search(query: string, limit: number): MinimalLayoutInformation[] {
if (query.length < 1) {
return []
}
const sorted = ThemeSearch.sortedByLowestScores(query, this._otherThemes, this._layersToIgnore)
return sorted
.filter(sorted => sorted.lowest < 2)
.map(th => th.theme)
.filter(th => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id))
.slice(0, limit)
}
public static createUrlFor(
layout: { id: string },
state?: { layoutToUse?: { id } },
): string {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
return `${linkPrefix}`
}
private static scoreThemes(query: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): Record<string, ThemeSearchScore> {
if (query?.length < 1) {
return undefined
}
themes = Utils.NoNullInplace(themes)
const layerScores = LayerSearch.scoreLayers(query)
for (const ignoreLayer of ignoreLayers) {
delete layerScores[ignoreLayer]
}
const results: Record<string, ThemeSearchScore> = {}
for (const layoutInfo of themes) {
const theme = layoutInfo.id
if (theme === "personal") {
continue
}
if (Utils.simplifyStringForSearch(theme) === query) {
results[theme] = {
theme: layoutInfo,
lowest: -1,
other: 0,
}
continue
}
const perLayer = Utils.asRecord(
layoutInfo.layers ?? [], layer => layerScores[layer],
)
const language = Locale.language.data
const keywords = Utils.NoNullInplace([layoutInfo.shortDescription, layoutInfo.title])
.map(item => typeof item === "string" ? item : (item[language] ?? item["*"]))
const other = Math.min(SearchUtils.scoreKeywords(query, keywords), SearchUtils.scoreKeywords(query, layoutInfo.keywords))
const lowest = Math.min(other, ...Object.values(perLayer))
results[theme] = {
theme: layoutInfo,
perLayer,
other,
lowest,
}
}
return results
}
public static sortedByLowestScores(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): ThemeSearchScore[] {
const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers))
scored.sort((a, b) => a.lowest - b.lowest)
return scored
}
public static sortedByLowest(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): MinimalLayoutInformation[] {
return this.sortedByLowestScores(search, themes, ignoreLayers)
.map(th => th.theme)
}
}

View file

@ -6,7 +6,6 @@ import { UIEventSource } from "../UIEventSource"
import { QueryParameters } from "../Web/QueryParameters" import { QueryParameters } from "../Web/QueryParameters"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Query } from "pg"
import { eliCategory } from "../../Models/RasterLayerProperties" import { eliCategory } from "../../Models/RasterLayerProperties"
import { AvailableRasterLayers } from "../../Models/RasterLayers" import { AvailableRasterLayers } from "../../Models/RasterLayers"
import MarkdownUtils from "../../Utils/MarkdownUtils" import MarkdownUtils from "../../Utils/MarkdownUtils"

View file

@ -7,7 +7,14 @@ import { Tag } from "../Tags/Tag"
import Translations from "../../UI/i18n/Translations" import Translations from "../../UI/i18n/Translations"
import { RegexTag } from "../Tags/RegexTag" import { RegexTag } from "../Tags/RegexTag"
import { Or } from "../Tags/Or" import { Or } from "../Tags/Or"
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import Constants from "../../Models/Constants"
export type ActiveFilter = {
layer: LayerConfig,
filter: FilterConfig,
control: UIEventSource<string | number | undefined>
}
/** /**
* The layer state keeps track of: * The layer state keeps track of:
* - Which layers are enabled * - Which layers are enabled
@ -26,6 +33,13 @@ export default class LayerState {
* Which layers are enabled in the current theme and what filters are applied onto them * Which layers are enabled in the current theme and what filters are applied onto them
*/ */
public readonly filteredLayers: ReadonlyMap<string, FilteredLayer> public readonly filteredLayers: ReadonlyMap<string, FilteredLayer>
private readonly _activeFilters: UIEventSource<ActiveFilter[]> = new UIEventSource([])
public readonly activeFilters: Store<ActiveFilter[]> = this._activeFilters
private readonly _activeLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(undefined)
public readonly activeLayers: Store<FilteredLayer[]> = this._activeLayers
private readonly _nonactiveLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(undefined)
public readonly nonactiveLayers: Store<FilteredLayer[]> = this._nonactiveLayers
private readonly osmConnection: OsmConnection private readonly osmConnection: OsmConnection
/** /**
@ -56,6 +70,52 @@ export default class LayerState {
} }
this.filteredLayers = filteredLayers this.filteredLayers = filteredLayers
layers.forEach((l) => LayerState.linkFilterStates(l, filteredLayers)) layers.forEach((l) => LayerState.linkFilterStates(l, filteredLayers))
this.filteredLayers.forEach(fl => {
fl.isDisplayed.addCallback(() => this.updateActiveFilters())
for (const [_, appliedFilter] of fl.appliedFilters) {
appliedFilter.addCallback(() => this.updateActiveFilters())
}
})
this.updateActiveFilters()
}
private updateActiveFilters(){
const filters: ActiveFilter[] = []
const activeLayers: FilteredLayer[] = []
const nonactiveLayers: FilteredLayer[] = []
this.filteredLayers.forEach(fl => {
if(!fl.isDisplayed.data){
nonactiveLayers.push(fl)
return
}
activeLayers.push(fl)
if(fl.layerDef.filterIsSameAs){
return
}
for (const [filtername, appliedFilter] of fl.appliedFilters) {
if (appliedFilter.data === undefined) {
continue
}
const filter = fl.layerDef.filters.find(f => f.id === filtername)
console.log("Updating active filters for flayer", fl.layerDef.id,"with filterconfig",filter)
if(typeof appliedFilter.data === "number"){
if(filter.options[appliedFilter.data].osmTags === undefined){
// This is probably the first, generic option which doesn't _actually_ filter
continue
}
}
filters.push({
layer: fl.layerDef,
control: appliedFilter,
filter,
})
}
})
this._activeLayers.set(activeLayers)
this._nonactiveLayers.set(nonactiveLayers)
this._activeFilters.set(filters)
} }
/** /**

View file

@ -0,0 +1,149 @@
import GeocodingProvider, { type SearchResult } from "../Search/GeocodingProvider"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
import CombinedSearcher from "../Search/CombinedSearcher"
import FilterSearch, { FilterSearchResult } from "../Search/FilterSearch"
import LocalElementSearch from "../Search/LocalElementSearch"
import CoordinateSearch from "../Search/CoordinateSearch"
import ThemeSearch from "../Search/ThemeSearch"
import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch"
import PhotonSearch from "../Search/PhotonSearch"
import ThemeViewState from "../../Models/ThemeViewState"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { Translation } from "../../UI/i18n/Translation"
import GeocodingFeatureSource from "../Search/GeocodingFeatureSource"
import LayerSearch from "../Search/LayerSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FeatureSource } from "../FeatureSource/FeatureSource"
import { Feature } from "geojson"
export default class SearchState {
public readonly feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
public readonly searchTerm: UIEventSource<string> = new UIEventSource<string>("")
public readonly searchIsFocused = new UIEventSource(false)
public readonly suggestions: Store<SearchResult[]>
public readonly filterSuggestions: Store<FilterSearchResult[]>
public readonly themeSuggestions: Store<MinimalLayoutInformation[]>
public readonly layerSuggestions: Store<LayerConfig[]>
public readonly locationSearchers: ReadonlyArray<GeocodingProvider>
private readonly state: ThemeViewState
public readonly showSearchDrawer: UIEventSource<boolean>
public readonly suggestionsSearchRunning: Store<boolean>
public readonly locationResults: FeatureSource
constructor(state: ThemeViewState) {
this.state = state
this.locationSearchers = [
new LocalElementSearch(state, 5),
new CoordinateSearch(),
new OpenStreetMapIdSearch(state),
new PhotonSearch(), // new NominatimGeocoding(),
]
const bounds = state.mapProperties.bounds
const suggestionsList = this.searchTerm.stabilized(250).mapD(search => {
if (search.length === 0) {
return undefined
}
return this.locationSearchers.map(ls => ls.suggest(search, { bbox: bounds.data }))
}, [bounds],
)
this.suggestionsSearchRunning = suggestionsList.bind(suggestions => {
if (suggestions === undefined) {
return new ImmutableStore(true)
}
return Stores.concat(suggestions).map(suggestions => suggestions.some(list => list === undefined))
})
this.suggestions = suggestionsList.bindD(suggestions =>
Stores.concat(suggestions).map(suggestions => CombinedSearcher.merge(suggestions)),
)
const themeSearch = new ThemeSearch(state)
this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.search(query, 3))
const layerSearch = new LayerSearch(state.layout)
this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.search(query, 5))
const filterSearch = new FilterSearch(state)
this.filterSuggestions = this.searchTerm.stabilized(50)
.mapD(query => filterSearch.search(query))
.mapD(filterResult => {
const active = state.layerState.activeFilters.data
return filterResult.filter(({ filter, index, layer }) => {
const foundMatch = active.some(active =>
active.filter.id === filter.id && layer.id === active.layer.id && active.control.data === index)
return !foundMatch
})
}, [state.layerState.activeFilters])
this.locationResults = new GeocodingFeatureSource(this.suggestions.stabilized(250))
this.showSearchDrawer = new UIEventSource(false)
this.searchIsFocused.addCallbackAndRunD(sugg => {
if (sugg) {
this.showSearchDrawer.set(true)
}
})
}
public async apply(result: FilterSearchResult[] | LayerConfig) {
if (result instanceof LayerConfig) {
return this.applyLayer(result)
}
return this.applyFilter(result)
}
private async applyLayer(layer: LayerConfig) {
for (const [name, otherLayer] of this.state.layerState.filteredLayers) {
otherLayer.isDisplayed.setData(name === layer.id)
}
}
private async applyFilter(payload: FilterSearchResult[]) {
const state = this.state
const layers = payload.map(fsr => fsr.layer.id)
for (const [name, otherLayer] of state.layerState.filteredLayers) {
const layer = otherLayer.layerDef
if (!layer.isNormal()) {
continue
}
if(otherLayer.layerDef.minzoom > state.mapProperties.minzoom.data) {
// Currently not displayed, we don't hide
continue
}
otherLayer.isDisplayed.setData(layers.indexOf(layer.id) > 0)
}
for (const { filter, index, layer } of payload) {
const flayer = state.layerState.filteredLayers.get(layer.id)
flayer.isDisplayed.set(true)
const filtercontrol = flayer.appliedFilters.get(filter.id)
if (filtercontrol.data === index) {
filtercontrol.setData(undefined)
} else {
filtercontrol.setData(index)
}
}
}
closeIfFullscreen() {
if (window.innerWidth < 640) {
this.showSearchDrawer.set(false)
}
}
clickedOnMap(feature: Feature) {
const osmid = feature.properties.osm_id
const localElement = this.state.indexedFeatures.featuresById.data.get(osmid)
if (localElement) {
this.state.selectedElement.set(localElement)
return
}
}
}

View file

@ -1,4 +1,4 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../Osm/OsmConnection" import { OsmConnection } from "../Osm/OsmConnection"
import { MangroveIdentity } from "../Web/MangroveReviews" import { MangroveIdentity } from "../Web/MangroveReviews"
import { Store, Stores, UIEventSource } from "../UIEventSource" import { Store, Stores, UIEventSource } from "../UIEventSource"
@ -19,6 +19,101 @@ import { QueryParameters } from "../Web/QueryParameters"
import { ThemeMetaTagging } from "./UserSettingsMetaTagging" import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
import { MapProperties } from "../../Models/MapProperties" import { MapProperties } from "../../Models/MapProperties"
import Showdown from "showdown" import Showdown from "showdown"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { GeocodeResult } from "../Search/GeocodingProvider"
export class OptionallySyncedHistory<T> {
public readonly syncPreference: UIEventSource<"sync" | "local" | "no">
public readonly value: Store<T[]>
private readonly synced: UIEventSource<T[]>
private readonly local: UIEventSource<T[]>
private readonly thisSession: UIEventSource<T[]>
private readonly _maxHistory: number
private readonly _isSame: (a: T, b: T) => boolean
private osmconnection: OsmConnection
constructor(key: string, osmconnection: OsmConnection, maxHistory: number = 20, isSame?: (a: T, b: T) => boolean) {
this.osmconnection = osmconnection
this._maxHistory = maxHistory
this._isSame = isSame
this.syncPreference = osmconnection.getPreference(
"preference-" + key + "-history",
"sync",
)
const synced = this.synced = UIEventSource.asObject<T[]>(osmconnection.GetLongPreference(key + "-history"), [])
const local = this.local = LocalStorageSource.GetParsed<T[]>(key + "-history", [])
const thisSession = this.thisSession = new UIEventSource<T[]>([], "optionally-synced:"+key+"(session only)")
this.syncPreference.addCallback(syncmode => {
if (syncmode === "sync") {
let list = [...thisSession.data, ...synced.data].slice(0, maxHistory)
if (this._isSame) {
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
if (this._isSame(list[i], list[j])) {
list.splice(j, 1)
}
}
}
}
synced.set(list)
} else if (syncmode === "local") {
local.set(synced.data?.slice(0, maxHistory))
synced.set([])
} else {
synced.set([])
local.set([])
}
})
this.value = this.syncPreference.bind(syncPref => this.getAppropriateStore(syncPref))
}
private getAppropriateStore(syncPref?: string) {
syncPref ??= this.syncPreference.data
if (syncPref === "sync") {
return this.synced
}
if (syncPref === "local") {
return this.local
}
return this.thisSession
}
public add(t: T) {
const store = this.getAppropriateStore()
let oldList = store.data ?? []
if (this._isSame) {
oldList = oldList.filter(x => !this._isSame(t, x))
}
store.set([t, ...oldList].slice(0, this._maxHistory))
}
/**
* Adds the value when the user is actually logged in
* @param t
*/
public addDefferred(t: T) {
if (t === undefined) {
return
}
this.osmconnection.isLoggedIn.addCallbackAndRun(loggedIn => {
if (!loggedIn) {
return
}
this.add(t)
return true
})
}
clear() {
this.getAppropriateStore().set([])
}
}
/** /**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection, * The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
@ -62,7 +157,7 @@ export default class UserRelatedState {
*/ */
public readonly gpsLocationHistoryRetentionTime = new UIEventSource( public readonly gpsLocationHistoryRetentionTime = new UIEventSource(
7 * 24 * 60 * 60, 7 * 24 * 60 * 60,
"gps_location_retention" "gps_location_retention",
) )
public readonly addNewFeatureMode = new UIEventSource< public readonly addNewFeatureMode = new UIEventSource<
@ -82,61 +177,62 @@ export default class UserRelatedState {
public readonly preferencesAsTags: UIEventSource<Record<string, string>> public readonly preferencesAsTags: UIEventSource<Record<string, string>>
private readonly _mapProperties: MapProperties private readonly _mapProperties: MapProperties
public readonly recentlyVisitedThemes: OptionallySyncedHistory<string>
public readonly recentlyVisitedSearch: OptionallySyncedHistory<GeocodeResult>
constructor( constructor(
osmConnection: OsmConnection, osmConnection: OsmConnection,
layout?: LayoutConfig, layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState, featureSwitches?: FeatureSwitchState,
mapProperties?: MapProperties mapProperties?: MapProperties,
) { ) {
this.osmConnection = osmConnection this.osmConnection = osmConnection
this._mapProperties = mapProperties this._mapProperties = mapProperties
this.showAllQuestionsAtOnce = UIEventSource.asBoolean( this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
this.osmConnection.GetPreference("show-all-questions", "false", { this.osmConnection.getPreference("show-all-questions", "false"),
documentation:
"Either 'true' or 'false'. If set, all questions will be shown all at once",
})
) )
this.language = this.osmConnection.GetPreference("language") this.language = this.osmConnection.getPreference("language")
this.showTags = this.osmConnection.GetPreference("show_tags") this.showTags = this.osmConnection.getPreference("show_tags")
this.showCrosshair = this.osmConnection.GetPreference("show_crosshair") this.showCrosshair = this.osmConnection.getPreference("show_crosshair")
this.fixateNorth = this.osmConnection.GetPreference("fixate-north") this.fixateNorth = this.osmConnection.getPreference("fixate-north")
this.morePrivacy = this.osmConnection.GetPreference("more_privacy", "no") this.morePrivacy = this.osmConnection.getPreference("more_privacy", "no")
this.a11y = this.osmConnection.GetPreference("a11y") this.a11y = this.osmConnection.getPreference("a11y")
this.mangroveIdentity = new MangroveIdentity( this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove"), this.osmConnection.getPreference("identity", undefined,"mangrove"),
this.osmConnection.GetPreference("identity-creation-date", "mangrove") this.osmConnection.getPreference("identity-creation-date", undefined,"mangrove"),
)
this.preferredBackgroundLayer = this.osmConnection.GetPreference(
"preferred-background-layer",
undefined,
{
documentation:
"The ID of a layer or layer category that MapComplete uses by default",
}
) )
this.preferredBackgroundLayer = this.osmConnection.getPreference("preferred-background-layer")
this.addNewFeatureMode = this.osmConnection.GetPreference( this.addNewFeatureMode = this.osmConnection.getPreference(
"preferences-add-new-mode", "preferences-add-new-mode",
"button_click_right", "button_click_right",
{
documentation: "How adding a new feature is done",
}
) )
this.showScale = UIEventSource.asBoolean(this.osmConnection.GetPreference("preference-show-scale","false")) this.showScale = UIEventSource.asBoolean(this.osmConnection.GetPreference("preference-show-scale","false"))
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", { this.imageLicense = this.osmConnection.getPreference("pictures-license", "CC0")
documentation: "The license under which new images are uploaded", this.installedUserThemes = UserRelatedState.initInstalledUserThemes(osmConnection)
})
this.installedUserThemes = this.InitInstalledUserThemes()
this.translationMode = this.initTranslationMode() this.translationMode = this.initTranslationMode()
this.homeLocation = this.initHomeLocation() this.homeLocation = this.initHomeLocation()
this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches) this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches)
this.recentlyVisitedThemes = new OptionallySyncedHistory<string>(
"theme",
this.osmConnection,
10,
(a, b) => a === b,
)
this.recentlyVisitedSearch = new OptionallySyncedHistory<GeocodeResult>("places",
this.osmConnection,
15,
(a, b) => a.osm_id === b.osm_id && a.osm_type === b.osm_type,
)
this.syncLanguage() this.syncLanguage()
this.recentlyVisitedThemes.addDefferred(layout?.id)
} }
private syncLanguage() { private syncLanguage() {
@ -149,7 +245,7 @@ export default class UserRelatedState {
private initTranslationMode(): UIEventSource<"false" | "true" | "mobile" | undefined | string> { private initTranslationMode(): UIEventSource<"false" | "true" | "mobile" | undefined | string> {
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> = const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
this.osmConnection.GetPreference("translation-mode", "false") this.osmConnection.getPreference("translation-mode", "false")
translationMode.addCallbackAndRunD((mode) => { translationMode.addCallbackAndRunD((mode) => {
mode = mode.toLowerCase() mode = mode.toLowerCase()
if (mode === "true" || mode === "yes") { if (mode === "true" || mode === "yes") {
@ -176,17 +272,7 @@ export default class UserRelatedState {
} }
} }
public GetUnofficialTheme(id: string): public getUnofficialTheme(id: string): (MinimalLayoutInformation & { definition }) | undefined {
| {
id: string
icon: string
title: any
shortDescription: any
definition?: any
isOfficial: boolean
}
| undefined {
console.log("GETTING UNOFFICIAL THEME")
const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id) const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id)
const str = pref.data const str = pref.data
@ -196,22 +282,13 @@ export default class UserRelatedState {
} }
try { try {
const value: { return <MinimalLayoutInformation & { definition: string }>JSON.parse(str)
id: string
icon: string
title: any
shortDescription: any
definition?: any
isOfficial: boolean
} = JSON.parse(str)
value.isOfficial = false
return value
} catch (e) { } catch (e) {
console.warn( console.warn(
"Removing theme " + "Removing theme " +
id + id +
" as it could not be parsed from the preferences; the content is:", " as it could not be parsed from the preferences; the content is:",
str str,
) )
pref.setData(null) pref.setData(null)
return undefined return undefined
@ -227,7 +304,7 @@ export default class UserRelatedState {
this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => { this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
if (loggedIn) { if (loggedIn) {
this.osmConnection this.osmConnection
.GetPreference("hidden-theme-" + layout?.id + "-enabled") .getPreference("hidden-theme-" + layout?.id + "-enabled")
.setData("true") .setData("true")
return true return true
} }
@ -241,18 +318,31 @@ export default class UserRelatedState {
title: layout.title.translations, title: layout.title.translations,
shortDescription: layout.shortDescription.translations, shortDescription: layout.shortDescription.translations,
definition: layout["definition"], definition: layout["definition"],
}) }),
) )
} }
} }
private InitInstalledUserThemes(): Store<string[]> { public static initInstalledUserThemes(osmConnection: OsmConnection): Store<string[]> {
const prefix = "mapcomplete-unofficial-theme-" const prefix = "mapcomplete-unofficial-theme-"
const postfix = "-combined-length" return osmConnection.preferencesHandler.allPreferences.map((prefs) =>
return this.osmConnection.preferencesHandler.preferences.map((prefs) =>
Object.keys(prefs) Object.keys(prefs)
.filter((k) => k.startsWith(prefix) && k.endsWith(postfix)) .filter((k) => k.startsWith(prefix))
.map((k) => k.substring(prefix.length, k.length - postfix.length)) .map((k) => k.substring(prefix.length)),
)
}
/**
* List of all hidden themes that have been seen before
* @param osmConnection
*/
public static initDiscoveredHiddenThemes(osmConnection: OsmConnection): Store<string[]> {
const prefix = "mapcomplete-hidden-theme-"
const userPreferences = osmConnection.preferencesHandler.allPreferences
return userPreferences.map((preferences) =>
Object.keys(preferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length)),
) )
} }
@ -268,7 +358,7 @@ export default class UserRelatedState {
return undefined return undefined
} }
return [home.lon, home.lat] return [home.lon, home.lat]
}) }),
).map((homeLonLat) => { ).map((homeLonLat) => {
if (homeLonLat === undefined) { if (homeLonLat === undefined) {
return empty return empty
@ -298,7 +388,7 @@ export default class UserRelatedState {
* */ * */
private initAmendedPrefs( private initAmendedPrefs(
layout?: LayoutConfig, layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState featureSwitches?: FeatureSwitchState,
): UIEventSource<Record<string, string>> { ): UIEventSource<Record<string, string>> {
const amendedPrefs = new UIEventSource<Record<string, string>>({ const amendedPrefs = new UIEventSource<Record<string, string>>({
_theme: layout?.id, _theme: layout?.id,
@ -319,23 +409,13 @@ export default class UserRelatedState {
} }
const osmConnection = this.osmConnection const osmConnection = this.osmConnection
osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => { osmConnection.preferencesHandler.allPreferences.addCallback((newPrefs) => {
for (const k in newPrefs) { for (const k in newPrefs) {
const v = newPrefs[k] const v = newPrefs[k]
if (v === "undefined" || !v) { if (v === "undefined" || v === "null" || !v) {
continue continue
} }
if (k.endsWith("-combined-length")) { amendedPrefs.data[k] = newPrefs[k] ?? ""
const l = Number(v)
const key = k.substring(0, k.length - "length".length)
let combined = ""
for (let i = 0; i < l; i++) {
combined += newPrefs[key + i]
}
amendedPrefs.data[key.substring(0, key.length - "-combined-".length)] = combined
} else {
amendedPrefs.data[k] = newPrefs[k]
}
} }
amendedPrefs.ping() amendedPrefs.ping()
@ -354,19 +434,19 @@ export default class UserRelatedState {
const missingLayers = Utils.Dedup( const missingLayers = Utils.Dedup(
untranslated untranslated
.filter((k) => k.startsWith("layers:")) .filter((k) => k.startsWith("layers:"))
.map((k) => k.slice("layers:".length).split(".")[0]) .map((k) => k.slice("layers:".length).split(".")[0]),
) )
const zenLinks: { link: string; id: string }[] = Utils.NoNull([ const zenLinks: { link: string; id: string }[] = Utils.NoNull([
hasMissingTheme hasMissingTheme
? { ? {
id: "theme:" + layout.id, id: "theme:" + layout.id,
link: LinkToWeblate.hrefToWeblateZen( link: LinkToWeblate.hrefToWeblateZen(
language, language,
"themes", "themes",
layout.id layout.id,
), ),
} }
: undefined, : undefined,
...missingLayers.map((id) => ({ ...missingLayers.map((id) => ({
id: "layer:" + id, id: "layer:" + id,
@ -383,7 +463,7 @@ export default class UserRelatedState {
} }
amendedPrefs.ping() amendedPrefs.ping()
}, },
[this.translationMode] [this.translationMode],
) )
this.mangroveIdentity.getKeyId().addCallbackAndRun((kid) => { this.mangroveIdentity.getKeyId().addCallbackAndRun((kid) => {
@ -402,7 +482,7 @@ export default class UserRelatedState {
.makeHtml(userDetails.description) .makeHtml(userDetails.description)
?.replace(/&gt;/g, ">") ?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<") ?.replace(/&lt;/g, "<")
?.replace(/\n/g, "") ?.replace(/\n/g, ""),
) )
} }
@ -413,7 +493,7 @@ export default class UserRelatedState {
(c: { contributor: string; commits: number }) => { (c: { contributor: string; commits: number }) => {
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "") const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
return replaced === simplifiedName return replaced === simplifiedName
} },
) )
if (isTranslator) { if (isTranslator) {
amendedPrefs.data["_translation_contributions"] = "" + isTranslator.commits amendedPrefs.data["_translation_contributions"] = "" + isTranslator.commits
@ -422,7 +502,7 @@ export default class UserRelatedState {
(c: { contributor: string; commits: number }) => { (c: { contributor: string; commits: number }) => {
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "") const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
return replaced === simplifiedName return replaced === simplifiedName
} },
) )
if (isCodeContributor) { if (isCodeContributor) {
amendedPrefs.data["_code_contributions"] = "" + isCodeContributor.commits amendedPrefs.data["_code_contributions"] = "" + isCodeContributor.commits
@ -433,17 +513,15 @@ export default class UserRelatedState {
amendedPrefs.addCallbackD((tags) => { amendedPrefs.addCallbackD((tags) => {
for (const key in tags) { for (const key in tags) {
if (key.startsWith("_") || key === "mapcomplete-language") { if (key.startsWith("_") || key === "mapcomplete-language") {
// Language is managed seperately // Language is managed separately
continue continue
} }
if (tags[key + "-combined-0"]) { if(tags[key] === null){
// A combined value exists continue
this.osmConnection.GetLongPreference(key, "").setData(tags[key])
} else {
this.osmConnection
.GetPreference(key, undefined, { prefix: "" })
.setData(tags[key])
} }
let pref = this.osmConnection.GetPreference(key, undefined, {prefix: ""})
pref.set(tags[key])
} }
}) })

View file

@ -41,8 +41,25 @@ export class Stores {
return src return src
} }
public static flatten<X>(source: Store<Store<X>>, possibleSources?: Store<any>[]): Store<X> { public static concat<T>(stores: Store<T[] | undefined>[]): Store<(T[] | undefined)[]> {
return UIEventSource.flatten(source, possibleSources) const newStore = new UIEventSource<(T[] | undefined)[]>([])
function update() {
if (newStore._callbacks.isDestroyed) {
return true // unregister
}
const results: (T[] | undefined)[] = []
for (const store of stores) {
results.push(store.data)
}
newStore.setData(results)
}
for (const store of stores) {
store.addCallback(() => update())
}
update()
return newStore
} }
/** /**
@ -73,6 +90,20 @@ export class Stores {
}) })
return stable return stable
} }
/**
*
* Constructs a new store, but tries to keep the value 'defined'
* If a defined value was in the stream once, a defined value will be returned
* @param store
*/
static holdDefined<T>(store: Store<T | undefined>): Store<T | undefined> {
const newStore = new UIEventSource(store.data)
store.addCallbackD(t => {
newStore.setData(t)
})
return newStore
}
} }
export abstract class Store<T> implements Readable<T> { export abstract class Store<T> implements Readable<T> {
@ -104,7 +135,7 @@ export abstract class Store<T> implements Readable<T> {
extraStoresToWatch: Store<any>[], extraStoresToWatch: Store<any>[],
callbackDestroyFunction: (f: () => void) => void callbackDestroyFunction: (f: () => void) => void
): Store<J> ): Store<J>
M
public mapD<J>( public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J, f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[], extraStoresToWatch?: Store<any>[],
@ -118,7 +149,7 @@ export abstract class Store<T> implements Readable<T> {
return null return null
} }
return f(<Exclude<T, undefined | null>>t) return f(<Exclude<T, undefined | null>>t)
}, extraStoresToWatch) }, extraStoresToWatch, callbackDestroyFunction)
} }
/** /**
@ -206,8 +237,8 @@ export abstract class Store<T> implements Readable<T> {
* src.setData(0) * src.setData(0)
* lastValue // => "def" * lastValue // => "def"
*/ */
public bind<X>(f: (t: T) => Store<X>): Store<X> { public bind<X>(f: (t: T) => Store<X>, extraSources: Store<object>[] = []): Store<X> {
const mapped = this.map(f) const mapped = this.map(f, extraSources)
const sink = new UIEventSource<X>(undefined) const sink = new UIEventSource<X>(undefined)
const seenEventSources = new Set<Store<X>>() const seenEventSources = new Set<Store<X>>()
mapped.addCallbackAndRun((newEventSource) => { mapped.addCallbackAndRun((newEventSource) => {
@ -229,13 +260,16 @@ export abstract class Store<T> implements Readable<T> {
if (mapped.data === newEventSource) { if (mapped.data === newEventSource) {
sink.setData(resultData) sink.setData(resultData)
} }
if (sink._callbacks.isDestroyed) {
return true // unregister
}
}) })
}) })
return sink return sink
} }
public bindD<X>(f: (t: Exclude<T, undefined | null>) => Store<X>): Store<X> { public bindD<X>(f: (t: Exclude<T, undefined | null>) => Store<X>, extraSources: UIEventSource<object>[] = []): Store<X> {
return this.bind((t) => { return this.bind((t) => {
if (t === null) { if (t === null) {
return null return null
@ -244,8 +278,9 @@ export abstract class Store<T> implements Readable<T> {
return undefined return undefined
} }
return f(<Exclude<T, undefined | null>>t) return f(<Exclude<T, undefined | null>>t)
}) }, extraSources)
} }
public stabilized(millisToStabilize): Store<T> { public stabilized(millisToStabilize): Store<T> {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return this return this
@ -305,18 +340,22 @@ export abstract class Store<T> implements Readable<T> {
run(v) run(v)
}) })
} }
public abstract destroy()
} }
export class ImmutableStore<T> extends Store<T> { export class ImmutableStore<T> extends Store<T> {
public readonly data: T public readonly data: T
static FALSE = new ImmutableStore<boolean>(false) static FALSE = new ImmutableStore<boolean>(false)
static TRUE = new ImmutableStore<boolean>(true) static TRUE = new ImmutableStore<boolean>(true)
constructor(data: T) { constructor(data: T) {
super() super()
this.data = data this.data = data
} }
private static readonly pass: () => void = () => {} private static readonly pass: () => void = () => {
}
addCallback(_: (data: T) => void): () => void { addCallback(_: (data: T) => void): () => void {
// pass: data will never change // pass: data will never change
@ -356,6 +395,10 @@ export class ImmutableStore<T> extends Store<T> {
bind<X>(f: (t: T) => Store<X>): Store<X> { bind<X>(f: (t: T) => Store<X>): Store<X> {
return f(this.data) return f(this.data)
} }
destroy() {
// pass
}
} }
/** /**
@ -364,6 +407,7 @@ export class ImmutableStore<T> extends Store<T> {
class ListenerTracker<T> { class ListenerTracker<T> {
public pingCount = 0 public pingCount = 0
private readonly _callbacks: ((t: T) => boolean | void | any)[] = [] private readonly _callbacks: ((t: T) => boolean | void | any)[] = []
public isDestroyed = false
/** /**
* Adds a callback which can be called; a function to unregister is returned * Adds a callback which can be called; a function to unregister is returned
@ -424,6 +468,11 @@ class ListenerTracker<T> {
length() { length() {
return this._callbacks.length return this._callbacks.length
} }
public destroy() {
this.isDestroyed = true
this._callbacks.splice(0, this._callbacks.length)
}
} }
/** /**
@ -579,10 +628,15 @@ class MappedStore<TIn, T> extends Store<T> {
this._data = newData this._data = newData
this._callbacks.ping(this._data) this._callbacks.ping(this._data)
} }
destroy() {
this.unregisterFromUpstream()
}
} }
export class UIEventSource<T> extends Store<T> implements Writable<T> { export class UIEventSource<T> extends Store<T> implements Writable<T> {
private static readonly pass: () => {} private static readonly pass: (() => void) = () => {
}
public data: T public data: T
_callbacks: ListenerTracker<T> = new ListenerTracker<T>() _callbacks: ListenerTracker<T> = new ListenerTracker<T>()
@ -591,9 +645,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
this.data = data this.data = data
} }
public destroy() {
this._callbacks.destroy()
}
public static flatten<X>( public static flatten<X>(
source: Store<Store<X>>, source: Store<Store<X>>,
possibleSources?: Store<any>[] possibleSources?: Store<object>[]
): UIEventSource<X> { ): UIEventSource<X> {
const sink = new UIEventSource<X>(source.data?.data) const sink = new UIEventSource<X>(source.data?.data)
@ -617,12 +675,12 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
} }
/** /**
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated. * Converts a promise into a UIventsource, sets the UIeventSource when the result is calculated.
* If the promise fails, the value will stay undefined, but 'onError' will be called * If the promise fails, the value will stay undefined, but 'onError' will be called
*/ */
public static FromPromise<T>( public static FromPromise<T>(
promise: Promise<T>, promise: Promise<T>,
onError: (e: any) => void = undefined onError: (e) => void = undefined
): UIEventSource<T> { ): UIEventSource<T> {
const src = new UIEventSource<T>(undefined) const src = new UIEventSource<T>(undefined)
promise?.then((d) => src.setData(d)) promise?.then((d) => src.setData(d))
@ -666,7 +724,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static asInt(source: UIEventSource<string>): UIEventSource<number> { public static asInt(source: UIEventSource<string>): UIEventSource<number> {
return source.sync( return source.sync(
(str) => { (str) => {
let parsed = parseInt(str) const parsed = parseInt(str)
return isNaN(parsed) ? undefined : parsed return isNaN(parsed) ? undefined : parsed
}, },
[], [],
@ -697,7 +755,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static asFloat(source: UIEventSource<string>): UIEventSource<number> { public static asFloat(source: UIEventSource<string>): UIEventSource<number> {
return source.sync( return source.sync(
(str) => { (str) => {
let parsed = parseFloat(str) const parsed = parseFloat(str)
return isNaN(parsed) ? undefined : parsed return isNaN(parsed) ? undefined : parsed
}, },
[], [],
@ -718,6 +776,24 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
) )
} }
static asObject<T extends object>(stringUIEventSource: UIEventSource<string>, defaultV: T): UIEventSource<T> {
return stringUIEventSource.sync(
(str) => {
if (str === undefined || str === null || str === "") {
return defaultV
}
try {
return <T>JSON.parse(str)
} catch (e) {
console.error("Could not parse value", str, "due to", e)
return defaultV
}
},
[],
(b) => JSON.stringify(b) ?? ""
)
}
/** /**
* Create a new UIEVentSource. Whenever 'source' changes, the returned UIEventSource will get this value as well. * Create a new UIEVentSource. Whenever 'source' changes, the returned UIEventSource will get this value as well.
* However, this value can be overriden without affecting source * However, this value can be overriden without affecting source
@ -863,7 +939,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee) const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee)
const update = function () { const update = function() {
newSource.setData(f(self.data)) newSource.setData(f(self.data))
return allowUnregister && newSource._callbacks.length() === 0 return allowUnregister && newSource._callbacks.length() === 0
} }

View file

@ -22,6 +22,9 @@ export class MangroveIdentity {
this.mangroveIdentity = mangroveIdentity this.mangroveIdentity = mangroveIdentity
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
mangroveIdentity.addCallbackAndRunD(async (data) => { mangroveIdentity.addCallbackAndRunD(async (data) => {
if(data === ""){
return
}
await this.setKeypair(data) await this.setKeypair(data)
}) })
} }

View file

@ -4,6 +4,7 @@ import { Utils } from "../Utils"
import { AuthConfig } from "../Logic/Osm/AuthConfig" import { AuthConfig } from "../Logic/Osm/AuthConfig"
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number] export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
export type DefaultPinIcon = (typeof Constants._defaultPinIcons)[number]
export default class Constants { export default class Constants {
public static vNumber: string = packagefile.version public static vNumber: string = packagefile.version
@ -25,6 +26,7 @@ export default class Constants {
"last_click", "last_click",
"favourite", "favourite",
"summary", "summary",
"search",
"geocoded_image" "geocoded_image"
] as const ] as const
/** /**
@ -126,14 +128,19 @@ export default class Constants {
public static countryCoderEndpoint: string = Constants.config.country_coder_host public static countryCoderEndpoint: string = Constants.config.country_coder_host
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
public static nominatimEndpoint: string = Constants.config.nominatimEndpoint public static nominatimEndpoint: string = Constants.config.nominatimEndpoint
public static photonEndpoint: string = Constants.config.photonEndpoint
public static linkedDataProxy: string = Constants.config["jsonld-proxy"] public static linkedDataProxy: string = Constants.config["jsonld-proxy"]
/** /**
* These are the values that are allowed to use as 'backdrop' icon for a map pin * These are the values that are allowed to use as 'backdrop' icon for a map pin
*/ */
private static readonly _defaultPinIcons = [ public static readonly _defaultPinIcons = [
"addSmall", "addSmall",
"airport",
"brick_wall_round", "brick_wall_round",
"brick_wall_square", "brick_wall_square",
"building_office_2",
"building_storefront",
"bug", "bug",
"checkmark", "checkmark",
"checkmark", "checkmark",
@ -148,12 +155,14 @@ export default class Constants {
"desktop", "desktop",
"direction", "direction",
"gear", "gear",
"globe_alt",
"gps_arrow", "gps_arrow",
"heart", "heart",
"heart_outline", "heart_outline",
"help", "help",
"help", "help",
"home", "home",
"house",
"key", "key",
"invalid", "invalid",
"invalid", "invalid",
@ -175,7 +184,9 @@ export default class Constants {
"square_rounded", "square_rounded",
"teardrop", "teardrop",
"teardrop_with_hole_green", "teardrop_with_hole_green",
"train",
"triangle", "triangle",
"user_circle",
"wifi", "wifi",
] as const ] as const
public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons
@ -198,6 +209,7 @@ export default class Constants {
Constants.countryCoderEndpoint, Constants.countryCoderEndpoint,
Constants.osmAuthConfig.url, Constants.osmAuthConfig.url,
Constants.nominatimEndpoint, Constants.nominatimEndpoint,
Constants.photonEndpoint,
Constants.linkedDataProxy, Constants.linkedDataProxy,
...Constants.defaultOverpassUrls, ...Constants.defaultOverpassUrls,
] ]

View file

@ -30,6 +30,9 @@ export interface MapProperties {
* @param f * @param f
*/ */
onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean): () => void onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean): () => void
flyTo(lon: number, lat: number, zoom: number): void
} }
export interface ExportableMap { export interface ExportableMap {

View file

@ -23,7 +23,7 @@ export class AvailableRasterLayers {
const eli = await Utils.downloadJson<{ features: EditorLayerIndex }>( const eli = await Utils.downloadJson<{ features: EditorLayerIndex }>(
"./assets/data/editor-layer-index.json" "./assets/data/editor-layer-index.json"
) )
this._editorLayerIndex = eli.features.filter((l) => l.properties.id !== "Bing") this._editorLayerIndex = eli.features?.filter((l) => l.properties.id !== "Bing") ?? []
this._editorLayerIndexStore.set(this._editorLayerIndex) this._editorLayerIndexStore.set(this._editorLayerIndex)
return this._editorLayerIndex return this._editorLayerIndex
} }

View file

@ -10,10 +10,7 @@ import {
SetDefault, SetDefault,
} from "./Conversion" } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import { import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
MinimalTagRenderingConfigJson,
TagRenderingConfigJson,
} from "../Json/TagRenderingConfigJson"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import RewritableConfigJson from "../Json/RewritableConfigJson" import RewritableConfigJson from "../Json/RewritableConfigJson"
import SpecialVisualizations from "../../../UI/SpecialVisualizations" import SpecialVisualizations from "../../../UI/SpecialVisualizations"
@ -21,7 +18,7 @@ import Translations from "../../../UI/i18n/Translations"
import { Translation } from "../../../UI/i18n/Translation" import { Translation } from "../../../UI/i18n/Translation"
import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json" import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json"
import { AddContextToTranslations } from "./AddContextToTranslations" import { AddContextToTranslations } from "./AddContextToTranslations"
import FilterConfigJson from "../Json/FilterConfigJson" import FilterConfigJson, { FilterConfigOptionJson } from "../Json/FilterConfigJson"
import predifined_filters from "../../../../assets/layers/filters/filters.json" import predifined_filters from "../../../../assets/layers/filters/filters.json"
import { TagConfigJson } from "../Json/TagConfigJson" import { TagConfigJson } from "../Json/TagConfigJson"
import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson" import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson"
@ -34,15 +31,81 @@ import { ConversionContext } from "./ConversionContext"
import { ExpandRewrite } from "./ExpandRewrite" import { ExpandRewrite } from "./ExpandRewrite"
import { TagUtils } from "../../../Logic/Tags/TagUtils" import { TagUtils } from "../../../Logic/Tags/TagUtils"
class AddFiltersFromTagRenderings extends DesugaringStep<LayerConfigJson> {
constructor() {
super("Inspects all the tagRenderings. If some tagRenderings have the `filter` attribute set, introduce those filters. This step might introduce shorthand filter names, thus 'ExpandFilter' should be run afterwards. Can be disabled with \"#filter\":\"no-auto\"", ["filter"], "AddFiltersFromTagRenderings")
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
const noAutoFilters = json["#filter"] === "no-auto"
if(noAutoFilters){
return json
}
if(json.filter?.["sameAs"]){
return json
}
const filters: (FilterConfigJson | string)[] = [...<any>json.filter ?? []]
function filterExists(filterName: string): boolean {
return filters.some((existing) => {
const id: string = existing["id"] ?? existing
return (
filterName === id ||
(filterName.startsWith("filters.") && filterName.endsWith("." + id))
)
})
}
for (let i = 0; i < json.tagRenderings?.length; i++) {
const tagRendering = <TagRenderingConfigJson>json.tagRenderings[i]
if (!tagRendering?.filter) {
continue
}
if (tagRendering.filter === true) {
if (filterExists(tagRendering["id"])) {
continue
}
filters.push(ExpandFilter.buildFilterFromTagRendering(tagRendering, context.enters("tagRenderings", i, "filter")))
continue
}
for (const filterName of tagRendering.filter ?? []) {
if (typeof filterName !== "string") {
context.enters("tagRenderings", i, "filter").err("Not a string: " + filterName)
}
if (filterExists(filterName)) {
// This filter has already been added
continue
}
if (!filterName) {
context.err("Got undefined as filter expansion in " + tagRendering["id"])
continue
}
filters.push(filterName)
}
}
if(filters.length === 0){
return json
}
return { ...json, filter: filters }
}
}
class ExpandFilter extends DesugaringStep<LayerConfigJson> { class ExpandFilter extends DesugaringStep<LayerConfigJson> {
private static readonly predefinedFilters = ExpandFilter.load_filters() private static readonly predefinedFilters = ExpandFilter.load_filters()
private _state: DesugaringContext private _state: DesugaringContext
constructor(state: DesugaringContext) { constructor(state: DesugaringContext) {
super( super(
"Expands filters: replaces a shorthand by the value found in 'filters.json'. If the string is formatted 'layername.filtername, it will be looked up into that layer instead", ["Expands filters: replaces a shorthand by the value found in 'filters.json'.",
"If the string is formatted 'layername.filtername, it will be looked up into that layer instead."].join(" "),
["filter"], ["filter"],
"ExpandFilter" "ExpandFilter",
) )
this._state = state this._state = state
} }
@ -55,6 +118,38 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
return filters return filters
} }
public static buildFilterFromTagRendering(tr: TagRenderingConfigJson, context: ConversionContext): FilterConfigJson {
if (!(tr.mappings?.length >= 1)) {
context.err(
"Found a matching tagRendering to base a filter on, but this tagRendering does not contain any mappings",
)
}
const options = (<QuestionableTagRenderingConfigJson>tr).mappings.map((mapping) => {
let icon: string = mapping.icon?.["path"] ?? mapping.icon
let emoji: string = undefined
if (Utils.isEmoji(icon)) {
emoji = icon
icon = undefined
}
return (<FilterConfigOptionJson>{
question: mapping.then,
osmTags: mapping.if,
searchTerms: mapping.searchTerms,
icon, emoji,
})
})
// Add default option
options.unshift({
question: tr["question"] ?? Translations.t.general.filterPanel.allTypes,
osmTags: undefined,
searchTerms: undefined,
})
return ({
id: tr["id"],
options,
})
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson { convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
if (json?.filter === undefined || json?.filter === null) { if (json?.filter === undefined || json?.filter === null) {
return json // Nothing to change here return json // Nothing to change here
@ -64,36 +159,14 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
return json // Nothing to change here return json // Nothing to change here
} }
const newFilters: FilterConfigJson[] = [] const newFilters: FilterConfigJson[] = []
const filters = <(FilterConfigJson | string)[]>json.filter const filters = <(FilterConfigJson | string)[]>json.filter
for (let i = 0; i < json.tagRenderings?.length; i++) {
const tagRendering = <TagRenderingConfigJson>json.tagRenderings[i]
if (!tagRendering?.filter) {
continue
}
for (const filterName of tagRendering.filter ?? []) {
if (typeof filterName !== "string") {
context.enters("tagRenderings", i, "filter").err("Not a string: " + filterName)
}
const exists = filters.some((existing) => {
const id: string = existing["id"] ?? existing
return (
filterName === id ||
(filterName.startsWith("filters.") && filterName.endsWith("." + id))
)
})
if (exists) {
continue
}
if (!filterName) {
context.err("Got undefined as filter expansion in " + tagRendering["id"])
continue
}
filters.push(filterName)
}
}
/**
* Create filters based on builtin filters or create them based on the tagRendering
*/
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
const filter = filters[i] const filter = filters[i]
if (filter === undefined) { if (filter === undefined) {
@ -108,53 +181,34 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
json.tagRenderings.find((tr) => !!tr && tr["id"] === filter) json.tagRenderings.find((tr) => !!tr && tr["id"] === filter)
) )
if (matchingTr) { if (matchingTr) {
if (!(matchingTr.mappings?.length >= 1)) { const filter = ExpandFilter.buildFilterFromTagRendering(matchingTr, context.enters("filter", i))
context newFilters.push(filter)
.enters("filter", i)
.err(
"Found a matching tagRendering to base a filter on, but this tagRendering does not contain any mappings"
)
}
const options = matchingTr.mappings.map((mapping) => ({
question: mapping.then,
osmTags: mapping.if,
}))
options.unshift({
question: matchingTr["question"] ?? {
en: "All types",
},
osmTags: undefined,
})
newFilters.push({
id: filter,
options,
})
continue continue
} }
if (filter.indexOf(".") > 0) { if (filter.indexOf(".") > 0) {
if (this._state.sharedLayers.size > 0) { if (!(this._state.sharedLayers?.size > 0)) {
const split = filter.split(".")
if (split.length > 2) {
context.err(
"invalid filter name: " + filter + ", expected `layername.filterid`"
)
}
const layer = this._state.sharedLayers.get(split[0])
if (layer === undefined) {
context.err("Layer '" + split[0] + "' not found")
}
const expectedId = split[1]
const expandedFilter = (<(FilterConfigJson | string)[]>layer.filter).find(
(f) => typeof f !== "string" && f.id === expectedId
)
if (expandedFilter === undefined) {
context.err("Did not find filter with name " + filter)
} else {
newFilters.push(<FilterConfigJson>expandedFilter)
}
} else {
// This is a bootstrapping-run, we can safely ignore this // This is a bootstrapping-run, we can safely ignore this
continue
}
const split = filter.split(".")
if (split.length > 2) {
context.err(
"invalid filter name: " + filter + ", expected `layername.filterid`",
)
}
const layer = this._state.sharedLayers.get(split[0])
if (layer === undefined) {
context.err("Layer '" + split[0] + "' not found")
}
const expectedId = split[1]
const expandedFilter = (<(FilterConfigJson | string)[]>layer.filter).find(
(f) => typeof f !== "string" && f.id === expectedId,
)
if (expandedFilter === undefined) {
context.err("Did not find filter with name " + filter)
} else {
newFilters.push(<FilterConfigJson>expandedFilter)
} }
continue continue
} }
@ -164,15 +218,15 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
const suggestions = Utils.sortedByLevenshteinDistance( const suggestions = Utils.sortedByLevenshteinDistance(
filter, filter,
Array.from(ExpandFilter.predefinedFilters.keys()), Array.from(ExpandFilter.predefinedFilters.keys()),
(t) => t (t) => t,
) )
context context
.enter(filter) .enter(filter)
.err( .err(
"While searching for predefined filter " + "While searching for predefined filter " +
filter + filter +
": this filter is not found. Perhaps you meant one of: " + ": this filter is not found. Perhaps you meant one of: " +
suggestions suggestions,
) )
} }
newFilters.push(found) newFilters.push(found)
@ -185,9 +239,9 @@ class ExpandTagRendering extends Conversion<
| string | string
| TagRenderingConfigJson | TagRenderingConfigJson
| { | {
builtin: string | string[] builtin: string | string[]
override: any override: any
}, },
TagRenderingConfigJson[] TagRenderingConfigJson[]
> { > {
private readonly _state: DesugaringContext private readonly _state: DesugaringContext
@ -209,12 +263,12 @@ class ExpandTagRendering extends Conversion<
noHardcodedStrings?: false | boolean noHardcodedStrings?: false | boolean
// If set, a question will be added to the 'sharedTagRenderings'. Should only be used for 'questions.json' // If set, a question will be added to the 'sharedTagRenderings'. Should only be used for 'questions.json'
addToContext?: false | boolean addToContext?: false | boolean
} },
) { ) {
super( super(
"Converts a tagRenderingSpec into the full tagRendering, e.g. by substituting the tagRendering by the shared-question and reusing the builtins", "Converts a tagRenderingSpec into the full tagRendering, e.g. by substituting the tagRendering by the shared-question and reusing the builtins",
[], [],
"ExpandTagRendering" "ExpandTagRendering",
) )
this._state = state this._state = state
this._self = self this._self = self
@ -234,7 +288,7 @@ class ExpandTagRendering extends Conversion<
public convert( public convert(
spec: string | any, spec: string | any,
ctx: ConversionContext ctx: ConversionContext,
): QuestionableTagRenderingConfigJson[] { ): QuestionableTagRenderingConfigJson[] {
const trs = this.convertOnce(spec, ctx) const trs = this.convertOnce(spec, ctx)
@ -347,8 +401,8 @@ class ExpandTagRendering extends Conversion<
found, found,
ConversionContext.construct( ConversionContext.construct(
[layer.id, "tagRenderings", found["id"]], [layer.id, "tagRenderings", found["id"]],
["AddContextToTranslations"] ["AddContextToTranslations"],
) ),
) )
matchingTrs[i] = found matchingTrs[i] = found
} }
@ -376,17 +430,17 @@ class ExpandTagRendering extends Conversion<
ctx.warn( ctx.warn(
`A literal rendering was detected: ${tr} `A literal rendering was detected: ${tr}
Did you perhaps forgot to add a layer name as 'layername.${tr}'? ` + Did you perhaps forgot to add a layer name as 'layername.${tr}'? ` +
Array.from(state.sharedLayers.keys()).join(", ") Array.from(state.sharedLayers.keys()).join(", "),
) )
} }
if (this._options?.noHardcodedStrings && this._state?.sharedLayers?.size > 0) { if (this._options?.noHardcodedStrings && this._state?.sharedLayers?.size > 0) {
ctx.err( ctx.err(
"Detected an invocation to a builtin tagRendering, but this tagrendering was not found: " + "Detected an invocation to a builtin tagRendering, but this tagrendering was not found: " +
tr + tr +
" \n Did you perhaps forget to add the layer as prefix, such as `icons." + " \n Did you perhaps forget to add the layer as prefix, such as `icons." +
tr + tr +
"`? " "`? ",
) )
} }
@ -421,9 +475,9 @@ class ExpandTagRendering extends Conversion<
} }
ctx.err( ctx.err(
"An object calling a builtin can only have keys `builtin` or `override`, but a key with name `" + "An object calling a builtin can only have keys `builtin` or `override`, but a key with name `" +
key + key +
"` was found. This won't be picked up! The full object is: " + "` was found. This won't be picked up! The full object is: " +
JSON.stringify(tr) JSON.stringify(tr),
) )
} }
@ -442,39 +496,39 @@ class ExpandTagRendering extends Conversion<
const candidates = Utils.sortedByLevenshteinDistance( const candidates = Utils.sortedByLevenshteinDistance(
layerName, layerName,
Array.from(state.sharedLayers.keys()), Array.from(state.sharedLayers.keys()),
(s) => s (s) => s,
) )
if (state.sharedLayers.size === 0) { if (state.sharedLayers.size === 0) {
ctx.warn( ctx.warn(
"BOOTSTRAPPING. Rerun generate layeroverview. While reusing tagrendering: " + "BOOTSTRAPPING. Rerun generate layeroverview. While reusing tagrendering: " +
name + name +
": layer " + ": layer " +
layerName + layerName +
" not found for now, but ignoring as this is a bootstrapping run. " " not found for now, but ignoring as this is a bootstrapping run. ",
) )
} else { } else {
ctx.err( ctx.err(
": While reusing tagrendering: " + ": While reusing tagrendering: " +
name + name +
": layer " + ": layer " +
layerName + layerName +
" not found. Maybe you meant one of " + " not found. Maybe you meant one of " +
candidates.slice(0, 3).join(", ") candidates.slice(0, 3).join(", "),
) )
} }
continue continue
} }
candidates = Utils.NoNull(layer.tagRenderings.map((tr) => tr["id"])).map( candidates = Utils.NoNull(layer.tagRenderings.map((tr) => tr["id"])).map(
(id) => layerName + "." + id (id) => layerName + "." + id,
) )
} }
candidates = Utils.sortedByLevenshteinDistance(name, candidates, (i) => i) candidates = Utils.sortedByLevenshteinDistance(name, candidates, (i) => i)
ctx.err( ctx.err(
"The tagRendering with identifier " + "The tagRendering with identifier " +
name + name +
" was not found.\n\tDid you mean one of " + " was not found.\n\tDid you mean one of " +
candidates.join(", ") + candidates.join(", ") +
"?\n(Hint: did you add a new label and are you trying to use this label at the same time? Run 'reset:layeroverview' first" "?\n(Hint: did you add a new label and are you trying to use this label at the same time? Run 'reset:layeroverview' first",
) )
continue continue
} }
@ -499,13 +553,13 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
super( super(
"If no 'inline' is set on the freeform key, it will be automatically added. If no special renderings are used, it'll be set to true", "If no 'inline' is set on the freeform key, it will be automatically added. If no special renderings are used, it'll be set to true",
["freeform.inline"], ["freeform.inline"],
"DetectInline" "DetectInline",
) )
} }
convert( convert(
json: QuestionableTagRenderingConfigJson, json: QuestionableTagRenderingConfigJson,
context: ConversionContext context: ConversionContext,
): QuestionableTagRenderingConfigJson { ): QuestionableTagRenderingConfigJson {
if (json.freeform === undefined) { if (json.freeform === undefined) {
return json return json
@ -528,7 +582,7 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
if (json.freeform.inline === true) { if (json.freeform.inline === true) {
context.err( context.err(
"'inline' is set, but the rendering contains a special visualisation...\n " + "'inline' is set, but the rendering contains a special visualisation...\n " +
spec[key] spec[key],
) )
} }
json = JSON.parse(JSON.stringify(json)) json = JSON.parse(JSON.stringify(json))
@ -558,7 +612,7 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
super( super(
"Adds a 'questions'-object if no question element is added yet", "Adds a 'questions'-object if no question element is added yet",
["tagRenderings"], ["tagRenderings"],
"AddQuestionBox" "AddQuestionBox",
) )
} }
@ -582,18 +636,18 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
json.tagRenderings = [...json.tagRenderings] json.tagRenderings = [...json.tagRenderings]
const allSpecials: Exclude<RenderingSpecification, string>[] = <any>( const allSpecials: Exclude<RenderingSpecification, string>[] = <any>(
ValidationUtils.getAllSpecialVisualisations( ValidationUtils.getAllSpecialVisualisations(
<QuestionableTagRenderingConfigJson[]>json.tagRenderings <QuestionableTagRenderingConfigJson[]>json.tagRenderings,
).filter((spec) => typeof spec !== "string") ).filter((spec) => typeof spec !== "string")
) )
const questionSpecials = allSpecials.filter((sp) => sp.func.funcName === "questions") const questionSpecials = allSpecials.filter((sp) => sp.func.funcName === "questions")
const noLabels = questionSpecials.filter( const noLabels = questionSpecials.filter(
(sp) => sp.args.length === 0 || sp.args[0].trim() === "" (sp) => sp.args.length === 0 || sp.args[0].trim() === "",
) )
if (noLabels.length > 1) { if (noLabels.length > 1) {
context.err( context.err(
"Multiple 'questions'-visualisations found which would show _all_ questions. Don't do this" "Multiple 'questions'-visualisations found which would show _all_ questions. Don't do this",
) )
} }
@ -601,9 +655,9 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
const allLabels = new Set( const allLabels = new Set(
[].concat( [].concat(
...json.tagRenderings.map( ...json.tagRenderings.map(
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? [] (tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? [],
) ),
) ),
) )
const seen: Set<string> = new Set() const seen: Set<string> = new Set()
for (const questionSpecial of questionSpecials) { for (const questionSpecial of questionSpecials) {
@ -621,20 +675,20 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
if (blacklisted?.length > 0 && used?.length > 0) { if (blacklisted?.length > 0 && used?.length > 0) {
context.err( context.err(
"The {questions()}-special rendering only supports either a blacklist OR a whitelist, but not both." + "The {questions()}-special rendering only supports either a blacklist OR a whitelist, but not both." +
"\n Whitelisted: " + "\n Whitelisted: " +
used.join(", ") + used.join(", ") +
"\n Blacklisted: " + "\n Blacklisted: " +
blacklisted.join(", ") blacklisted.join(", "),
) )
} }
for (const usedLabel of used) { for (const usedLabel of used) {
if (!allLabels.has(usedLabel)) { if (!allLabels.has(usedLabel)) {
context.err( context.err(
"This layers specifies a special question element for label `" + "This layers specifies a special question element for label `" +
usedLabel + usedLabel +
"`, but this label doesn't exist.\n" + "`, but this label doesn't exist.\n" +
" Available labels are " + " Available labels are " +
Array.from(allLabels).join(", ") Array.from(allLabels).join(", "),
) )
} }
seen.add(usedLabel) seen.add(usedLabel)
@ -667,7 +721,7 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
super( super(
"Add some editing elements, such as the delete button or the move button if they are configured. These used to be handled by the feature info box, but this has been replaced by special visualisation elements", "Add some editing elements, such as the delete button or the move button if they are configured. These used to be handled by the feature info box, but this has been replaced by special visualisation elements",
[], [],
"AddEditingElements" "AddEditingElements",
) )
this._desugaring = desugaring this._desugaring = desugaring
this.builtinQuestions = Array.from(this._desugaring.tagRenderings?.values() ?? []) this.builtinQuestions = Array.from(this._desugaring.tagRenderings?.values() ?? [])
@ -697,13 +751,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
json.tagRenderings = [...(json.tagRenderings ?? [])] json.tagRenderings = [...(json.tagRenderings ?? [])]
const allIds = new Set<string>(json.tagRenderings.map((tr) => tr["id"])) const allIds = new Set<string>(json.tagRenderings.map((tr) => tr["id"]))
const specialVisualisations = ValidationUtils.getAllSpecialVisualisations( const specialVisualisations = ValidationUtils.getAllSpecialVisualisations(
<any>json.tagRenderings <any>json.tagRenderings,
) )
const usedSpecialFunctions = new Set( const usedSpecialFunctions = new Set(
specialVisualisations.map((sv) => specialVisualisations.map((sv) =>
typeof sv === "string" ? undefined : sv.func.funcName typeof sv === "string" ? undefined : sv.func.funcName,
) ),
) )
/***** ADD TO TOP ****/ /***** ADD TO TOP ****/
@ -771,7 +825,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
super( super(
"Converts a 'special' translation into a regular translation which uses parameters", "Converts a 'special' translation into a regular translation which uses parameters",
["special"], ["special"],
"RewriteSpecial" "RewriteSpecial",
) )
} }
@ -862,12 +916,12 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
private static convertIfNeeded( private static convertIfNeeded(
input: input:
| (object & { | (object & {
special: { special: {
type: string type: string
} }
}) })
| any, | any,
context: ConversionContext context: ConversionContext,
): any { ): any {
const special = input["special"] const special = input["special"]
if (special === undefined) { if (special === undefined) {
@ -877,7 +931,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const type = special["type"] const type = special["type"]
if (type === undefined) { if (type === undefined) {
context.err( context.err(
"A 'special'-block should define 'type' to indicate which visualisation should be used" "A 'special'-block should define 'type' to indicate which visualisation should be used",
) )
return undefined return undefined
} }
@ -887,10 +941,10 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const options = Utils.sortedByLevenshteinDistance( const options = Utils.sortedByLevenshteinDistance(
type, type,
SpecialVisualizations.specialVisualizations, SpecialVisualizations.specialVisualizations,
(sp) => sp.funcName (sp) => sp.funcName,
) )
context.err( context.err(
`Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md` `Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md`,
) )
return undefined return undefined
} }
@ -911,7 +965,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const byDistance = Utils.sortedByLevenshteinDistance( const byDistance = Utils.sortedByLevenshteinDistance(
wrongArg, wrongArg,
argNamesList, argNamesList,
(x) => x (x) => x,
) )
return `Unexpected argument in special block at ${context} with name '${wrongArg}'. Did you mean ${ return `Unexpected argument in special block at ${context} with name '${wrongArg}'. Did you mean ${
byDistance[0] byDistance[0]
@ -930,8 +984,8 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
`Obligated parameter '${arg.name}' in special rendering of type ${ `Obligated parameter '${arg.name}' in special rendering of type ${
vis.funcName vis.funcName
} not found.\n The full special rendering specification is: '${JSON.stringify( } not found.\n The full special rendering specification is: '${JSON.stringify(
input input,
)}'\n ${arg.name}: ${arg.doc}` )}'\n ${arg.name}: ${arg.doc}`,
) )
} }
} }
@ -1033,7 +1087,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
continue continue
} }
Utils.WalkPath(path.path, json, (leaf, travelled) => Utils.WalkPath(path.path, json, (leaf, travelled) =>
RewriteSpecial.convertIfNeeded(leaf, context.enter(travelled)) RewriteSpecial.convertIfNeeded(leaf, context.enter(travelled)),
) )
} }
@ -1067,7 +1121,7 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
} = badgesJson[i] } = badgesJson[i]
const expanded = this._expand.convert( const expanded = this._expand.convert(
<QuestionableTagRenderingConfigJson>iconBadge.then, <QuestionableTagRenderingConfigJson>iconBadge.then,
context.enters("iconBadges", i) context.enters("iconBadges", i),
) )
if (expanded === undefined) { if (expanded === undefined) {
iconBadges.push(iconBadge) iconBadges.push(iconBadge)
@ -1078,7 +1132,7 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
...expanded.map((resolved) => ({ ...expanded.map((resolved) => ({
if: iconBadge.if, if: iconBadge.if,
then: <MinimalTagRenderingConfigJson>resolved, then: <MinimalTagRenderingConfigJson>resolved,
})) })),
) )
} }
@ -1095,11 +1149,11 @@ class PreparePointRendering extends Fuse<PointRenderingConfigJson> {
new Each( new Each(
new On( new On(
"icon", "icon",
new FirstOf(new ExpandTagRendering(state, layer, { applyCondition: false })) new FirstOf(new ExpandTagRendering(state, layer, { applyCondition: false })),
) ),
) ),
), ),
new ExpandIconBadges(state, layer) new ExpandIconBadges(state, layer),
) )
} }
} }
@ -1109,7 +1163,7 @@ class SetFullNodeDatabase extends DesugaringStep<LayerConfigJson> {
super( super(
"sets the fullNodeDatabase-bit if needed", "sets the fullNodeDatabase-bit if needed",
["fullNodeDatabase"], ["fullNodeDatabase"],
"SetFullNodeDatabase" "SetFullNodeDatabase",
) )
} }
@ -1138,7 +1192,7 @@ class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> {
super( super(
"Expands tagRenderings in the icons, if needed", "Expands tagRenderings in the icons, if needed",
["icon", "color"], ["icon", "color"],
"ExpandMarkerRenderings" "ExpandMarkerRenderings",
) )
this._layer = layer this._layer = layer
this._state = state this._state = state
@ -1170,7 +1224,7 @@ class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> {
super( super(
"Adds the favourite heart to the title and the rendering badges", "Adds the favourite heart to the title and the rendering badges",
[], [],
"AddFavouriteBadges" "AddFavouriteBadges",
) )
} }
@ -1195,7 +1249,7 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
super( super(
"Adds the 'rating'-element if a reviews-element is used in the tagRenderings", "Adds the 'rating'-element if a reviews-element is used in the tagRenderings",
["titleIcons"], ["titleIcons"],
"AddRatingBadge" "AddRatingBadge",
) )
} }
@ -1214,8 +1268,8 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
const specialVis: Exclude<RenderingSpecification, string>[] = < const specialVis: Exclude<RenderingSpecification, string>[] = <
Exclude<RenderingSpecification, string>[] Exclude<RenderingSpecification, string>[]
>ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter( >ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter(
(rs) => typeof rs !== "string" (rs) => typeof rs !== "string",
) )
const funcs = new Set<string>(specialVis.map((rs) => rs.func.funcName)) const funcs = new Set<string>(specialVis.map((rs) => rs.func.funcName))
@ -1231,12 +1285,12 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
super( super(
"The auto-icon creates a (non-clickable) title icon based on a tagRendering which has icons", "The auto-icon creates a (non-clickable) title icon based on a tagRendering which has icons",
["titleIcons"], ["titleIcons"],
"AutoTitleIcon" "AutoTitleIcon",
) )
} }
private createTitleIconsBasedOn( private createTitleIconsBasedOn(
tr: QuestionableTagRenderingConfigJson tr: QuestionableTagRenderingConfigJson,
): TagRenderingConfigJson | undefined { ): TagRenderingConfigJson | undefined {
const mappings: { if: TagConfigJson; then: string }[] = tr.mappings const mappings: { if: TagConfigJson; then: string }[] = tr.mappings
?.filter((m) => m.icon !== undefined) ?.filter((m) => m.icon !== undefined)
@ -1266,7 +1320,7 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
return undefined return undefined
} }
return this.createTitleIconsBasedOn(<any>tr) return this.createTitleIconsBasedOn(<any>tr)
}) }),
) )
json.titleIcons.splice(allAutoIndex, 1, ...generated) json.titleIcons.splice(allAutoIndex, 1, ...generated)
return json return json
@ -1295,8 +1349,8 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
.enters("titleIcons", i) .enters("titleIcons", i)
.warn( .warn(
"TagRendering with id " + "TagRendering with id " +
trId + trId +
" does not have any icons, not generating an icon for this" " does not have any icons, not generating an icon for this",
) )
continue continue
} }
@ -1311,7 +1365,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
super( super(
"If no source is given, automatically derives the osmTags by 'or'-ing all the preset tags", "If no source is given, automatically derives the osmTags by 'or'-ing all the preset tags",
["source"], ["source"],
"DeriveSource" "DeriveSource",
) )
} }
@ -1321,7 +1375,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
} }
if (!json.presets) { if (!json.presets) {
context.err( context.err(
"No source tags given. Trying to derive the source-tags based on the presets, but no presets are given" "No source tags given. Trying to derive the source-tags based on the presets, but no presets are given",
) )
return json return json
} }
@ -1347,7 +1401,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
export class PrepareLayer extends Fuse<LayerConfigJson> { export class PrepareLayer extends Fuse<LayerConfigJson> {
constructor( constructor(
state: DesugaringContext, state: DesugaringContext,
options?: { addTagRenderingsToContext?: false | boolean } options?: { addTagRenderingsToContext?: false | boolean },
) { ) {
super( super(
"Fully prepares and expands a layer for the LayerConfig.", "Fully prepares and expands a layer for the LayerConfig.",
@ -1360,8 +1414,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new Concat( new Concat(
new ExpandTagRendering(state, layer, { new ExpandTagRendering(state, layer, {
addToContext: options?.addTagRenderingsToContext ?? false, addToContext: options?.addTagRenderingsToContext ?? false,
}) }),
) ),
), ),
new On("tagRenderings", new Each(new DetectInline())), new On("tagRenderings", new Each(new DetectInline())),
new AddQuestionBox(), new AddQuestionBox(),
@ -1374,11 +1428,11 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new On<PointRenderingConfigJson[], LayerConfigJson>( new On<PointRenderingConfigJson[], LayerConfigJson>(
"pointRendering", "pointRendering",
(layer) => (layer) =>
new Each(new On("marker", new Each(new ExpandMarkerRenderings(state, layer)))) new Each(new On("marker", new Each(new ExpandMarkerRenderings(state, layer)))),
), ),
new On<PointRenderingConfigJson[], LayerConfigJson>( new On<PointRenderingConfigJson[], LayerConfigJson>(
"pointRendering", "pointRendering",
(layer) => new Each(new PreparePointRendering(state, layer)) (layer) => new Each(new PreparePointRendering(state, layer)),
), ),
new SetDefault("titleIcons", ["icons.defaults"]), new SetDefault("titleIcons", ["icons.defaults"]),
new AddRatingBadge(), new AddRatingBadge(),
@ -1387,9 +1441,10 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new On( new On(
"titleIcons", "titleIcons",
(layer) => (layer) =>
new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true })) new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true })),
), ),
new ExpandFilter(state) new AddFiltersFromTagRenderings(),
new ExpandFilter(state),
) )
} }
} }

View file

@ -103,6 +103,10 @@ export class DoesImageExist extends DesugaringStep<string> {
return image return image
} }
if(Utils.isEmoji(image)){
return image
}
if (!this._knownImagePaths.has(image)) { if (!this._knownImagePaths.has(image)) {
if (this.doesPathExist === undefined) { if (this.doesPathExist === undefined) {
context.err( context.err(
@ -779,7 +783,8 @@ export class ValidateLayer extends Conversion<
try { try {
layerConfig = new LayerConfig(json, "validation", true) layerConfig = new LayerConfig(json, "validation", true)
} catch (e) { } catch (e) {
context.err("Could not parse layer due to:" + e) console.error("Could not parse layer due to", e)
context.err("Could not parse layer due to: " + e)
return undefined return undefined
} }

View file

@ -13,6 +13,9 @@ import Validators, { ValidatorType } from "../../UI/InputElement/Validators"
export type FilterConfigOption = { export type FilterConfigOption = {
question: Translation question: Translation
searchTerms: Record<string, string[]>
icon?: string
emoji?: string
osmTags: TagsFilter | undefined osmTags: TagsFilter | undefined
/* Only set if fields are present. Used to create `osmTags` (which are used to _actually_ filter) when the field is written*/ /* Only set if fields are present. Used to create `osmTags` (which are used to _actually_ filter) when the field is written*/
readonly originalTagsSpec: TagConfigJson readonly originalTagsSpec: TagConfigJson
@ -107,8 +110,11 @@ export default class FilterConfig {
return { return {
question: question, question: question,
osmTags: osmTags, osmTags: osmTags,
searchTerms: option.searchTerms,
fields, fields,
originalTagsSpec: option.osmTags originalTagsSpec: option.osmTags,
icon: option.icon,
emoji: option.emoji,
} }
}) })
@ -153,7 +159,7 @@ export default class FilterConfig {
} }
public initState(layerId: string): UIEventSource<undefined | number | string> { public initState(layerId: string): UIEventSource<undefined | number | string> {
let defaultValue = "" let defaultValue: string
if (this.options.length > 1) { if (this.options.length > 1) {
defaultValue = "" + (this.defaultSelection ?? 0) defaultValue = "" + (this.defaultSelection ?? 0)
} else if (this.options[0].fields?.length > 0) { } else if (this.options[0].fields?.length > 0) {

View file

@ -1,5 +1,20 @@
import { TagConfigJson } from "./TagConfigJson" import { TagConfigJson } from "./TagConfigJson"
import { Translatable } from "./Translatable"
export interface FilterConfigOptionJson {
question: Translatable
searchTerms?: Record<string, string[]>
emoji?: string
icon?: string
osmTags?: TagConfigJson
default?: boolean
fields?: {
/**
* If name is `search`, use "_first_comment~.*{search}.*" as osmTags
*/
name: string
type?: string | "string"
}[]
}
export default interface FilterConfigJson { export default interface FilterConfigJson {
/** /**
* An id/name for this filter, used to set the URL parameters * An id/name for this filter, used to set the URL parameters
@ -33,18 +48,7 @@ export default interface FilterConfigJson {
* } * }
* ``` * ```
*/ */
options: { options: FilterConfigOptionJson[]
question: string | any
osmTags?: TagConfigJson
default?: boolean
fields?: {
/**
* If name is `search`, use "_first_comment~.*{search}.*" as osmTags
*/
name: string
type?: string | "string"
}[]
}[]
/** /**
* Used for comments or to disable a check * Used for comments or to disable a check

View file

@ -41,14 +41,22 @@ export interface LayerConfigJson {
name?: Translatable name?: Translatable
/** /**
* question: How would you describe the features that are shown on this layer?
*
* A description for the features shown in this layer. * A description for the features shown in this layer.
* This often resembles the introduction of the wiki.osm.org-page for this feature. * This often resembles the introduction of the wiki.osm.org-page for this feature.
* *
* group: Basic * group: Basic
* question: How would you describe the features that are shown on this layer?
*/ */
description?: Translatable description?: Translatable
/**
* question: What are some other terms used to describe these objects?
*
* This is used in the search functionality
*/
searchTerms?: Record<string, string[]>
/** /**
* Question: Where should the data be fetched from? * Question: Where should the data be fetched from?
* title: Data Source * title: Data Source
@ -434,10 +442,15 @@ export interface LayerConfigJson {
* 2. search 'filters.json' for the appropriate filter or * 2. search 'filters.json' for the appropriate filter or
* 3. will try to parse it as `layername.filterid` and us that one. * 3. will try to parse it as `layername.filterid` and us that one.
* *
* Note: adding "#filter":"no-auto" will disable the filters added by tagRenderings
* *
* group: filters * group: filters
*/ */
filter?: (FilterConfigJson | string)[] | { sameAs: string } filter?: (FilterConfigJson | string)[] | { sameAs: string }
/**
* Set this to disable the feature that tagRenderings can introduce filters
*/
"#filter"?: "no-auto"
/** /**
* This block defines under what circumstances the delete dialog is shown for objects of this layer. * This block defines under what circumstances the delete dialog is shown for objects of this layer.

View file

@ -321,7 +321,13 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
editButtonAriaLabel?: Translatable editButtonAriaLabel?: Translatable
/** /**
* What labels should be applied on this tagRendering?
*
* A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer * A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer
*
* Special values:
* - "hidden": do not show this tagRendering. Useful in it is used by e.g. an accordion
* - "description": this label is a description used in the search
*/ */
labels?: string[] labels?: string[]
} }

View file

@ -226,5 +226,5 @@ export interface TagRenderingConfigJson {
/** /**
* This tagRendering can introduce this builtin filter * This tagRendering can introduce this builtin filter
*/ */
filter?: string[] filter?: string[] | true
} }

View file

@ -23,12 +23,14 @@ import Constants from "../Constants"
import { QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson" import { QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson"
import MarkdownUtils from "../../Utils/MarkdownUtils" import MarkdownUtils from "../../Utils/MarkdownUtils"
import { And } from "../../Logic/Tags/And" import { And } from "../../Logic/Tags/And"
import Combine from "../../UI/Base/Combine"
export default class LayerConfig extends WithContextLoader { export default class LayerConfig extends WithContextLoader {
public static readonly syncSelectionAllowed = ["no", "local", "theme-only", "global"] as const public static readonly syncSelectionAllowed = ["no", "local", "theme-only", "global"] as const
public readonly id: string public readonly id: string
public readonly name: Translation public readonly name: Translation
public readonly description: Translation public readonly description: Translation
public readonly searchTerms: Record<string, string[]>
/** /**
* Only 'null' for special, privileged layers * Only 'null' for special, privileged layers
*/ */
@ -83,9 +85,12 @@ export default class LayerConfig extends WithContextLoader {
} }
this.syncSelection = json.syncSelection ?? "no" this.syncSelection = json.syncSelection ?? "no"
if (!json.source) { if(!json.source) {
if(json.presets === undefined){
throw "Error while parsing "+json.id+" in "+context+"; no source given"
}
this.source = new SourceConfig({ this.source = new SourceConfig({
osmTags: TagUtils.Tag({ or: json.presets.map((pr) => ({ and: pr.tags })) }), osmTags: TagUtils.Tag({or: json.presets.map(pr => ({and:pr.tags}))}),
}) })
} else if (typeof json.source !== "string") { } else if (typeof json.source !== "string") {
this.maxAgeOfCache = json.source["maxCacheAge"] ?? 24 * 60 * 60 * 30 this.maxAgeOfCache = json.source["maxCacheAge"] ?? 24 * 60 * 60 * 30
@ -112,8 +117,8 @@ export default class LayerConfig extends WithContextLoader {
json.description = undefined json.description = undefined
} }
} }
this.description = Translations.T(json.description, translationContext + ".description") this.description = Translations.T(json.description, translationContext + ".description")
this.searchTerms = json.searchTerms ?? {}
this.calculatedTags = undefined this.calculatedTags = undefined
if (json.calculatedTags !== undefined) { if (json.calculatedTags !== undefined) {
@ -353,11 +358,13 @@ export default class LayerConfig extends WithContextLoader {
if (this.mapRendering === undefined || this.mapRendering === null) { if (this.mapRendering === undefined || this.mapRendering === null) {
return undefined return undefined
} }
const mapRendering = this.mapRendering.filter((r) => r.location.has("point"))[0] const mapRenderings = this.mapRendering.filter((r) => r.location.has("point"))
if (mapRendering === undefined) { if (mapRenderings.length === 0) {
return undefined return undefined
} }
return mapRendering.GetBaseIcon(properties ?? this.GetBaseTags()) return new Combine(mapRenderings.map(
mr => mr.GetBaseIcon(properties ?? this.GetBaseTags()).SetClass("absolute left-0 top-0 w-full h-full"))
).SetClass("relative block w-full h-full")
} }
public GetBaseTags(): Record<string, string> { public GetBaseTags(): Record<string, string> {
@ -634,4 +641,25 @@ export default class LayerConfig extends WithContextLoader {
} }
return mostShadowed ?? matchingPresets[0] return mostShadowed ?? matchingPresets[0]
} }
/**
* Indicates if this is a normal layer, meaning that it can be toggled by the user in normal circumstances
* Thus: name is set, not a note import layer, not synced with another filter, ...
*/
public isNormal(){
if(this.id.startsWith("note_import")){
return false
}
if(Constants.added_by_default.indexOf(<any> this.id) >=0){
return false
}
if(this.filterIsSameAs !== undefined){
return false
}
if(!this.name ){
return false
}
return true
}
} }

View file

@ -12,7 +12,21 @@ import { RasterLayerProperties } from "../RasterLayerProperties"
import { ConversionContext } from "./Conversion/ConversionContext" import { ConversionContext } from "./Conversion/ConversionContext"
import { Translatable } from "./Json/Translatable" import { Translatable } from "./Json/Translatable"
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
/**
* Minimal information about a theme
**/
export class MinimalLayoutInformation {
id: string
icon: string
title: Translatable
shortDescription: Translatable
mustHaveLanguage?: boolean
hideFromOverview?: boolean
keywords?: Record<string, string[]>
layers: string[]
}
/** /**
* Minimal information about a theme * Minimal information about a theme
**/ **/
@ -27,6 +41,8 @@ export class LayoutInformation {
keywords?: (Translatable | Translation)[] keywords?: (Translatable | Translation)[]
} }
export default class LayoutConfig implements LayoutInformation { export default class LayoutConfig implements LayoutInformation {
public static readonly defaultSocialImage = "assets/SocialImage.png" public static readonly defaultSocialImage = "assets/SocialImage.png"
public readonly id: string public readonly id: string
@ -324,7 +340,7 @@ export default class LayoutConfig implements LayoutInformation {
} }
} }
} }
console.log("Fallthrough", this, tags) console.trace("Fallthrough: could not find the appropriate layer for an object with tags", tags, "within layout", this)
return undefined return undefined
} }
@ -338,7 +354,7 @@ export default class LayoutConfig implements LayoutInformation {
...json, ...json,
layers: json.layers.filter((l) => l["id"] !== "favourite"), layers: json.layers.filter((l) => l["id"] !== "favourite"),
} }
const usedImages = json._usedImages const usedImages = jsonNoFavourites._usedImages
usedImages.sort() usedImages.sort()
this.usedImages = Utils.Dedup(usedImages) this.usedImages = Utils.Dedup(usedImages)

View file

@ -73,6 +73,9 @@ import { LayerConfigJson } from "./ThemeConfig/Json/LayerConfigJson"
import Hash from "../Logic/Web/Hash" import Hash from "../Logic/Web/Hash"
import { GeoOperations } from "../Logic/GeoOperations" import { GeoOperations } from "../Logic/GeoOperations"
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
import { GeocodeResult, GeocodingUtils } from "../Logic/Search/GeocodingProvider"
import SearchState from "../Logic/State/SearchState"
import { ShowDataLayerOptions } from "../UI/Map/ShowDataLayerOptions"
/** /**
* *
@ -158,6 +161,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
*/ */
public readonly geocodedImages: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([ ]) public readonly geocodedImages: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([ ])
public readonly searchState: SearchState
constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) { constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) {
Utils.initDomPurify() Utils.initDomPurify()
this.layout = layout this.layout = layout
@ -380,6 +385,9 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.featureSummary = this.setupSummaryLayer() this.featureSummary = this.setupSummaryLayer()
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
this.searchState = new SearchState(this)
this.initActors() this.initActors()
this.drawSpecialLayers() this.drawSpecialLayers()
this.initHotkeys() this.initHotkeys()
@ -557,10 +565,17 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.previewedImage.setData(undefined) this.previewedImage.setData(undefined)
return return
} }
if (this.selectedElement.data) {
this.selectedElement.setData(undefined)
return
}
if (this.searchState.showSearchDrawer.data) {
this.searchState.showSearchDrawer.set(false)
return
}
if (this.guistate.closeAll()) { if (this.guistate.closeAll()) {
return return
} }
this.selectedElement.setData(undefined)
Zoomcontrol.resetzoom() Zoomcontrol.resetzoom()
this.focusOnMap() this.focusOnMap()
}) })
@ -569,6 +584,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.guistate.pageStates.favourites.set(true) this.guistate.pageStates.favourites.set(true)
}) })
Hotkeys.RegisterHotkey( Hotkeys.RegisterHotkey(
{ {
nomod: " ", nomod: " ",
@ -582,6 +598,9 @@ export default class ThemeViewState implements SpecialVisualizationState {
if (this.guistate.isSomethingOpen() || this.previewedImage.data !== undefined) { if (this.guistate.isSomethingOpen() || this.previewedImage.data !== undefined) {
return return
} }
if (document.activeElement.tagName === "button" || document.activeElement.tagName === "input") {
return
}
this.selectClosestAtCenter(0) this.selectClosestAtCenter(0)
} }
) )
@ -605,6 +624,12 @@ export default class ThemeViewState implements SpecialVisualizationState {
) )
} }
Hotkeys.RegisterHotkey({ ctrl: "F" }, Translations.t.hotkeyDocumentation.selectSearch, () => {
this.searchState.feedback.set(undefined)
this.searchState.searchIsFocused.set(true)
})
this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => { this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => {
if (!enable) { if (!enable) {
return return
@ -770,7 +795,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
current_view: this.currentView, current_view: this.currentView,
favourite: this.favourites, favourite: this.favourites,
summary: this.featureSummary, summary: this.featureSummary,
last_click: this.lastClickObject last_click: this.lastClickObject,
search: this.searchState.locationResults
} }
this.closestFeatures.registerSource(specialLayers.favourite, "favourite") this.closestFeatures.registerSource(specialLayers.favourite, "favourite")
@ -820,13 +846,21 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
this.featureProperties.trackFeatureSource(features) this.featureProperties.trackFeatureSource(features)
new ShowDataLayer(this.map, { const options: ShowDataLayerOptions & { layer: LayerConfig } = {
features, features,
doShowLayer: flayer.isDisplayed, doShowLayer: flayer.isDisplayed,
layer: flayer.layerDef, layer: flayer.layerDef,
metaTags: this.userRelatedState.preferencesAsTags, metaTags: this.userRelatedState.preferencesAsTags,
selectedElement: this.selectedElement selectedElement: this.selectedElement
})
}
if (flayer.layerDef.id === "search") {
options.onClick = (feature) => {
this.searchState.clickedOnMap(feature)
}
delete options.selectedElement
}
new ShowDataLayer(this.map, options)
}) })
const summaryLayerConfig = new LayerConfig(<LayerConfigJson>summaryLayer, "summaryLayer") const summaryLayerConfig = new LayerConfig(<LayerConfigJson>summaryLayer, "summaryLayer")
new ShowDataLayer(this.map, { new ShowDataLayer(this.map, {
@ -889,6 +923,29 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
}) })
}) })
// Add the selected element to the recently visited history
this.selectedElement.addCallbackD(selected => {
const [osm_type, osm_id] = selected.properties.id.split("/")
const [lon, lat] = GeoOperations.centerpointCoordinates(selected)
const layer = this.layout.getMatchingLayer(selected.properties)
const nameOptions = [
selected?.properties?.name,
selected?.properties?.alt_name, selected?.properties?.local_name,
layer?.title.GetRenderValue(selected?.properties ?? {}).txt,
selected.properties.display_name,
selected.properties.id
]
const r = <GeocodeResult>{
feature: selected,
display_name: nameOptions.find(opt => opt !== undefined),
osm_id, osm_type,
lon, lat
}
this.userRelatedState.recentlyVisitedSearch.add(r)
})
this.userRelatedState.showScale.addCallbackAndRun(showScale => { this.userRelatedState.showScale.addCallbackAndRun(showScale => {
this.mapProperties.showScale.set(showScale) this.mapProperties.showScale.set(showScale)
}) })
@ -912,7 +969,38 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.selectedElement.setData(this.currentView.features?.data?.[0]) this.selectedElement.setData(this.currentView.features?.data?.[0])
} }
/**
* Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the layout
*/
public getMatchingLayer(properties: Record<string, string>) {
const id = properties.id
if (id.startsWith("summary_")) {
// We don't select 'summary'-objects
return undefined
}
if (id === "settings") {
return UserRelatedState.usersettingsConfig
}
if (id.startsWith(LastClickFeatureSource.newPointElementId)) {
return this.layout.layers.find((l) => l.id === "last_click")
}
if (id.startsWith("search_result")) {
return GeocodingUtils.searchLayer
}
if (id === "location_track") {
return this.layout.layers.find((l) => l.id === "gps_track")
}
return this.layout.getMatchingLayer(properties)
}
public async reportError(message: string | Error | XMLHttpRequest, extramessage: string = "") { public async reportError(message: string | Error | XMLHttpRequest, extramessage: string = "") {
if (Utils.runningFromConsole) {
console.error("Got (in themeViewSTate.reportError):", message, extramessage)
return
}
const isTesting = this.featureSwitchIsTesting.data const isTesting = this.featureSwitchIsTesting.data
console.log( console.log(
isTesting isTesting

View file

@ -7,20 +7,14 @@
import Translations from "./i18n/Translations" import Translations from "./i18n/Translations"
import Logo from "../assets/svg/Logo.svelte" import Logo from "../assets/svg/Logo.svelte"
import Tr from "./Base/Tr.svelte" import Tr from "./Base/Tr.svelte"
import MoreScreen from "./BigComponents/MoreScreen"
import LoginToggle from "./Base/LoginToggle.svelte" import LoginToggle from "./Base/LoginToggle.svelte"
import Pencil from "../assets/svg/Pencil.svelte" import Pencil from "../assets/svg/Pencil.svelte"
import Constants from "../Models/Constants" import Constants from "../Models/Constants"
import { Store, UIEventSource } from "../Logic/UIEventSource" import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import { placeholder } from "../Utils/placeholder"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import ThemesList from "./BigComponents/ThemesList.svelte" import ThemesList from "./BigComponents/ThemesList.svelte"
import { LayoutInformation } from "../Models/ThemeConfig/LayoutConfig" import { MinimalLayoutInformation } from "../Models/ThemeConfig/LayoutConfig"
import * as themeOverview from "../assets/generated/theme_overview.json"
import UnofficialThemeList from "./BigComponents/UnofficialThemeList.svelte"
import Eye from "../assets/svg/Eye.svelte" import Eye from "../assets/svg/Eye.svelte"
import LoginButton from "./Base/LoginButton.svelte" import LoginButton from "./Base/LoginButton.svelte"
import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight"
import Mastodon from "../assets/svg/Mastodon.svelte" import Mastodon from "../assets/svg/Mastodon.svelte"
import Liberapay from "../assets/svg/Liberapay.svelte" import Liberapay from "../assets/svg/Liberapay.svelte"
import Bug from "../assets/svg/Bug.svelte" import Bug from "../assets/svg/Bug.svelte"
@ -28,6 +22,9 @@
import { Utils } from "../Utils" import { Utils } from "../Utils"
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
import Searchbar from "./Base/Searchbar.svelte" import Searchbar from "./Base/Searchbar.svelte"
import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight"
import ThemeSearch from "../Logic/Search/ThemeSearch"
import SearchUtils from "../Logic/Search/SearchUtils"
const featureSwitches = new OsmConnectionFeatureSwitches() const featureSwitches = new OsmConnectionFeatureSwitches()
const osmConnection = new OsmConnection({ const osmConnection = new OsmConnection({
@ -36,41 +33,75 @@
"oauth_token", "oauth_token",
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), )
}) })
const state = new UserRelatedState(osmConnection) const state = new UserRelatedState(osmConnection)
const t = Translations.t.index const t = Translations.t.index
const tu = Translations.t.general
const tr = Translations.t.general.morescreen const tr = Translations.t.general.morescreen
let userLanguages = osmConnection.userDetails.map((ud) => ud.languages) let userLanguages = osmConnection.userDetails.map((ud) => ud.languages)
let themeSearchText: UIEventSource<string | undefined> = new UIEventSource<string>("") let search: UIEventSource<string | undefined> = new UIEventSource<string>("")
let searchStable = search.stabilized(100)
document.addEventListener("keydown", function (event) { const officialThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === false)
const hiddenThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === true)
let visitedHiddenThemes: Store<MinimalLayoutInformation[]> = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection)
.map((knownIds) => hiddenThemes.filter((theme) =>
knownIds.indexOf(theme.id) >= 0 || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
))
const customThemes: Store<MinimalLayoutInformation[]> = Stores.ListStabilized<string>(state.installedUserThemes)
.mapD(stableIds => Utils.NoNullInplace(stableIds.map(id => state.getUnofficialTheme(id))))
function filtered(themes: MinimalLayoutInformation[]): Store<MinimalLayoutInformation[]> {
const prefiltered = themes.filter(th => th.id !== "personal")
return searchStable.map(search => {
if (!search) {
return themes
}
const scores = ThemeSearch.sortedByLowestScores(search, prefiltered)
const strict = scores.filter(sc => sc.lowest < 2)
if (strict.length > 0) {
return strict.map(sc => sc.theme)
}
return scores.filter(sc => sc.lowest < 4).slice(0, 6).map(sc => sc.theme)
})
}
let officialSearched = filtered(officialThemes)
let hiddenSearched = visitedHiddenThemes.bindD(visited => filtered(visited))
let customSearched = customThemes.bindD(customThemes => filtered(customThemes))
let searchIsFocussed = new UIEventSource(false)
document.addEventListener("keydown", function(event) {
if (event.ctrlKey && event.code === "KeyF") { if (event.ctrlKey && event.code === "KeyF") {
document.getElementById("theme-search")?.focus() searchIsFocussed.set(true)
event.preventDefault() event.preventDefault()
} }
}) })
let visitedHiddenThemes: Store<LayoutInformation[]> function applySearch() {
const hiddenThemes: LayoutInformation[] = const didRedirect = SearchUtils.applySpecialSearch(search.data)
(themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? [] console.log("Did redirect?", didRedirect)
{ if (didRedirect) {
const prefix = "mapcomplete-hidden-theme-" // Just for style and readability; won't _actually_ reach this
const userPreferences = state.osmConnection.preferencesHandler.preferences return
visitedHiddenThemes = userPreferences.map((preferences) => { }
const knownIds = new Set<string>(
Object.keys(preferences) const candidate = officialSearched.data[0] ?? hiddenSearched.data[0] ?? customSearched.data[0]
.filter((key) => key.startsWith(prefix)) if (!candidate) {
.map((key) => key.substring(prefix.length, key.length - "-enabled".length)) return
) }
return hiddenThemes.filter(
(theme) => window.location.href = ThemeSearch.createUrlFor(candidate, undefined)
knownIds.has(theme.id) ||
state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
)
})
} }
</script> </script>
<main> <main>
@ -102,24 +133,19 @@
</div> </div>
</div> </div>
<Searchbar <Searchbar value={search} placeholder={tr.searchForATheme} on:search={() => applySearch()} isFocused={searchIsFocussed} />
value={themeSearchText}
placeholder={tr.searchForATheme}
on:search={() => MoreScreen.applySearch(themeSearchText.data)}
/>
<ThemesList search={themeSearchText} {state} themes={MoreScreen.officialThemes} /> <ThemesList {search} {state} themes={$officialSearched} />
<LoginToggle {state}> <LoginToggle {state}>
<LoginButton clss="primary" {osmConnection} slot="not-logged-in"> <LoginButton clss="primary" {osmConnection} slot="not-logged-in">
<Tr t={t.logIn} /> <Tr t={t.logIn} />
</LoginButton> </LoginButton>
<ThemesList <ThemesList
hideThemes={false} {search}
isCustom={false}
search={themeSearchText}
{state} {state}
themes={$visitedHiddenThemes} themes={$hiddenSearched}
hasSelection={$officialSearched.length === 0}
> >
<svelte:fragment slot="title"> <svelte:fragment slot="title">
<h3> <h3>
@ -136,7 +162,19 @@
</svelte:fragment> </svelte:fragment>
</ThemesList> </ThemesList>
<UnofficialThemeList search={themeSearchText} {state} /> {#if $customThemes.length > 0}
<ThemesList {search} {state} themes={$customSearched}
hasSelection={$officialSearched.length === 0 && $hiddenSearched.length === 0}
>
<svelte:fragment slot="title">
<h3>
<Tr t={tu.customThemeTitle} />
</h3>
<Tr t={tu.customThemeIntro} />
</svelte:fragment>
</ThemesList>
{/if}
</LoginToggle> </LoginToggle>
<a <a

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import DotsCircleHorizontal from "@rgossiaux/svelte-heroicons/solid/DotsCircleHorizontal"
/**
* A menu, opened by a dot
*/
export let open = new UIEventSource(false)
function toggle() {
open.set(!open.data)
}
</script>
<div class="relative" style="z-index: 50">
<div
class="sidebar-unit absolute right-0 top-0 collapsable normal-background button-unstyled"
class:collapsed={!$open}>
<slot />
</div>
<DotsCircleHorizontal class={ `absolute top-0 right-0 w-6 h-6 dots-menu transition-colors ${$open?"dots-menu-opened":""}`} on:click={toggle} />
</div>
<style>
.dots-menu{
z-index: 50;
}
:global(.dots-menu > path) {
fill: var(--interactive-background);
transition: fill 350ms linear;
cursor: pointer;
}
:global(.dots-menu:hover > path, .dots-menu-opened > path) {
fill: var(--interactive-foreground)
}
.collapsable {
max-width: 100rem;
max-height: 100rem;
transition: border 150ms linear, max-width 500ms linear, max-height 500ms linear;
overflow: hidden;
flex-wrap: nowrap;
text-wrap: none;
width: max-content;
box-shadow: #ccc ;
white-space: nowrap;
border: 1px solid var(--button-background);
}
.collapsed {
max-width: 0;
max-height: 0;
border: 2px solid #00000000;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { Drawer } from "flowbite-svelte"
import { sineIn } from "svelte/easing"
import { Store } from "../../Logic/UIEventSource.js"
import { onMount } from "svelte"
export let shown: Store<boolean>
let transitionParams = {
x: 640,
duration: 200,
easing: sineIn
}
let hidden = !shown.data
shown.addCallback(sh => {
hidden = !sh
})
let height = 0
onMount(() => {
let topbar = document.getElementById("top-bar")
height = topbar.clientHeight
})
</script>
<Drawer placement="right"
transitionType="fly" {transitionParams}
activateClickOutside={false}
divClass="overflow-y-auto"
backdrop={false}
id="drawer-right"
width="w-full sm:w-80 md:w-96"
rightOffset="inset-y-0 right-0"
bind:hidden={hidden}>
<div class="low-interaction h-screen">
<div class="h-full" style={`padding-top: ${height}px`}>
<div class="flex flex-col h-full overflow-y-auto">
<slot />
</div>
</div>
</div>
</Drawer>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
/**
* The slotted element will be shown on the right side
*/
const dispatch = createEventDispatcher<{ close }>()
</script>
<div
aria-modal="true"
autofocus
class="normal-background absolute top-0 right-0 flex h-screen w-full flex-col overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12"
role="dialog"
style="max-width: 100vw; max-height: 100vh; z-index: 11"
tabindex="-1"
id="modal-right"
>
<slot name="close-button">
<button
class="absolute right-10 top-10 h-8 w-8 cursor-pointer rounded-full"
on:click={() => dispatch("close")}
>
<XCircleIcon />
</button>
</slot>
<div role="document">
<slot />
</div>
</div>
<!-- Experimental support for foldable devices -->
<style lang="scss">
@media (horizontal-viewport-segments: 2) {
#modal-right {
width: 50%;
}
}
</style>

View file

@ -6,33 +6,62 @@
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid" import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { ariaLabel } from "../../Utils/ariaLabel" import { ariaLabel } from "../../Utils/ariaLabel"
import { Translation } from "../i18n/Translation" import { Translation } from "../i18n/Translation"
import Backspace from "@babeard/svelte-heroicons/outline/Backspace"
export let value: UIEventSource<string> export let value: UIEventSource<string>
let _value = value.data ?? "" let _value = value.data ?? ""
value.addCallbackD((v) => { value.addCallbackD(v => {
_value = v _value = v
}) })
$: value.set(_value) $: value.set(_value)
const dispatch = createEventDispatcher<{ search }>() const dispatch = createEventDispatcher<{ search }>()
export let placeholder: Translation = Translations.t.general.search.search export let placeholder: Translation = Translations.t.general.search.search
export let isFocused: UIEventSource<boolean> = undefined
let inputElement: HTMLInputElement
isFocused?.addCallback(focussed => {
if (focussed) {
requestAnimationFrame(() => {
if (document.activeElement !== inputElement) {
inputElement?.focus()
inputElement?.select()
}
})
}
})
</script> </script>
<form class="flex justify-center" on:submit|preventDefault={() => dispatch("search")}>
<form
class="w-full"
on:submit|preventDefault={() => dispatch("search")}
>
<label <label
class="neutral-label box-shadow my-2 flex w-full items-center rounded-full border-2 border-black sm:w-1/2" class="neutral-label normal-background flex w-full items-center rounded-full border border-black box-shadow"
> >
<SearchIcon aria-hidden="true" class="h-8 w-8 ml-2" />
<input <input
bind:this={inputElement}
on:focus={() => {isFocused?.setData(true)}}
on:blur={() => {isFocused?.setData(false)}}
type="search" type="search"
style=" --tw-ring-color: rgb(0 0 0 / 0) !important;" style=" --tw-ring-color: rgb(0 0 0 / 0) !important;"
class="ml-4 w-full border-none pl-1 outline-none" class="px-0 ml-1 w-full outline-none border-none"
on:keypress={(keypr) => { on:keypress={(keypr) => {
return keypr.key === "Enter" ? dispatch("search") : undefined return keypr.key === "Enter" ? dispatch("search") : undefined
}} }}
bind:value={_value} bind:value={_value}
use:set_placeholder={placeholder} use:set_placeholder={placeholder}
use:ariaLabel={Translations.t.general.search.search} use:ariaLabel={placeholder}
/> />
<SearchIcon aria-hidden="true" class="mx-2 h-8 w-8" />
{#if $value.length > 0}
<Backspace on:click={() => value.set("")} color="var(--button-background)" class="w-6 h-6 mr-3 cursor-pointer" />
{:else}
<div class="w-6 mr-3" />
{/if}
</label> </label>
</form> </form>

View file

@ -7,23 +7,17 @@
import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource" import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource"
import Loading from "./Loading.svelte" import Loading from "./Loading.svelte"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { GeocodingUtils } from "../../Logic/Search/GeocodingProvider"
import ThemeViewState from "../../Models/ThemeViewState"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let selected: Feature export let selected: Feature
let tags = state.featureProperties.getStore(selected.properties.id) let tags = state.featureProperties.getStore(selected.properties.id)
export let absolute = true export let absolute = true
function getLayer(properties: Record<string, string>) { function getLayer(properties: Record<string, string>): LayerConfig {
if (properties.id === "settings") { return state.getMatchingLayer(properties)
return UserRelatedState.usersettingsConfig
}
if (properties.id.startsWith(LastClickFeatureSource.newPointElementId)) {
return state.layout.layers.find((l) => l.id === "last_click")
}
if (properties.id === "location_track") {
return state.layout.layers.find((l) => l.id === "gps_track")
}
return state.layout.getMatchingLayer(properties)
} }
let layer = getLayer(selected.properties) let layer = getLayer(selected.properties)

Some files were not shown because too many files have changed in this diff Show more