|
@ -10,6 +10,9 @@
|
|||
"type": "UI",
|
||||
"section": "User interface improvements"
|
||||
},
|
||||
{"type": "Search",
|
||||
"section": "Search related features"
|
||||
},
|
||||
{"type": "chore", "hidden": true},
|
||||
{"type": "docs", "hidden": true},
|
||||
{"type": "style", "hidden": true},
|
||||
|
|
BIN
Docs/Reasonings/Search/Apple0.png
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
Docs/Reasonings/Search/Apple1.png
Normal file
After Width: | Height: | Size: 192 KiB |
BIN
Docs/Reasonings/Search/Apple2.png
Normal file
After Width: | Height: | Size: 379 KiB |
BIN
Docs/Reasonings/Search/Bing0.png
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
Docs/Reasonings/Search/Bing1.png
Normal file
After Width: | Height: | Size: 895 KiB |
BIN
Docs/Reasonings/Search/Google0.png
Normal file
After Width: | Height: | Size: 762 KiB |
BIN
Docs/Reasonings/Search/Google1.png
Normal file
After Width: | Height: | Size: 559 KiB |
BIN
Docs/Reasonings/Search/Here0.png
Normal file
After Width: | Height: | Size: 522 KiB |
BIN
Docs/Reasonings/Search/Here1.png
Normal file
After Width: | Height: | Size: 822 KiB |
BIN
Docs/Reasonings/Search/Here2.png
Normal file
After Width: | Height: | Size: 541 KiB |
BIN
Docs/Reasonings/Search/OSM0.png
Normal file
After Width: | Height: | Size: 671 KiB |
115
Docs/Reasonings/Search/Research_UX_in_search.md
Normal 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
|
BIN
Docs/Reasonings/Search/img.png
Normal file
After Width: | Height: | Size: 603 KiB |
220
Docs/UserTests/2024-08-26 User Test Search.md
Normal 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?
|
||||
|
||||
|
BIN
Docs/UserTests/2024-08-26 Usertest-bold-question.png
Normal file
After Width: | Height: | Size: 49 KiB |
30
Docs/UserTests/2024-09-11 Usertest search Jewelry.md
Normal 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)
|
|
@ -338,6 +338,7 @@
|
|||
"tagRenderings": [
|
||||
"shops.*"
|
||||
],
|
||||
"#filter": "no-auto",
|
||||
"filter": [
|
||||
{
|
||||
"id": "sells_second-hand",
|
||||
|
@ -384,7 +385,8 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"open_now"
|
||||
],
|
||||
"deletion": true,
|
||||
"allowMove": true
|
||||
|
|
|
@ -24,6 +24,23 @@
|
|||
"ca": "Una capa que mostra fonts d'aigua potable",
|
||||
"cs": "Vrstva zobrazující fontány s pitnou vodou"
|
||||
},
|
||||
"searchTerms": {
|
||||
"en": [
|
||||
"drink",
|
||||
"water",
|
||||
"fountain",
|
||||
"bubbler"
|
||||
],
|
||||
"nl": [
|
||||
"drinken",
|
||||
"water",
|
||||
"drinkwater",
|
||||
"waterfontein",
|
||||
"fontein",
|
||||
"kraan",
|
||||
"kraantje"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"osmTags": {
|
||||
"and": [
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"#":"no-translations",
|
||||
"#dont-translate": "*",
|
||||
"#filter": "no-auto",
|
||||
"pointRendering": [
|
||||
{
|
||||
"location": [
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"id": "open_now",
|
||||
"options": [
|
||||
{
|
||||
"emoji": "⏰",
|
||||
"question": {
|
||||
"en": "Open now",
|
||||
"nl": "Nu open",
|
||||
|
@ -32,6 +33,7 @@
|
|||
"id": "accepts_cash",
|
||||
"options": [
|
||||
{
|
||||
"emoji": "🪙",
|
||||
"osmTags": "payment:cash=yes",
|
||||
"question": {
|
||||
"en": "Accepts cash",
|
||||
|
@ -50,6 +52,7 @@
|
|||
"id": "accepts_cards",
|
||||
"options": [
|
||||
{
|
||||
"emoji": "💳",
|
||||
"osmTags": "payment:cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts payment cards",
|
||||
|
@ -68,6 +71,7 @@
|
|||
"id": "accepts_debit_cards",
|
||||
"options": [
|
||||
{
|
||||
"emoji": "💳",
|
||||
"osmTags": "payment:debit_cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts debit cards",
|
||||
|
@ -83,6 +87,7 @@
|
|||
"id": "accepts_credit_cards",
|
||||
"options": [
|
||||
{
|
||||
"emoji": "💳",
|
||||
"osmTags": "payment:credit_cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts credit cards",
|
||||
|
@ -268,6 +273,7 @@
|
|||
{
|
||||
"question": {
|
||||
"en": "No preference towards dogs",
|
||||
"nl": "Geen voorkeur voor honden",
|
||||
"de": "Keine Bevorzugung von Hunden",
|
||||
"cs": "Bez preference psů"
|
||||
}
|
||||
|
@ -275,22 +281,27 @@
|
|||
{
|
||||
"question": {
|
||||
"en": "Dogs allowed",
|
||||
"nl": "Honden toegelaten",
|
||||
"de": "Hunde erlaubt",
|
||||
"cs": "Psi povoleny"
|
||||
},
|
||||
"emoji": "🐕",
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"dog=unleashed",
|
||||
"dog=yes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"icon": "./assets/layers/questions/dogs_allowed.svg"
|
||||
},
|
||||
{
|
||||
"question": {
|
||||
"en": "No dogs allowed",
|
||||
"nl": "Geen honden toegelaten",
|
||||
"de": "Keine Hunde erlaubt",
|
||||
"cs": "Psi nejsou povoleni"
|
||||
},
|
||||
"icon": "./assets/layers/questions/no_dogs.svg",
|
||||
"osmTags": "dog=no"
|
||||
}
|
||||
]
|
||||
|
@ -304,6 +315,7 @@
|
|||
"de": "Internetzugang vorhanden",
|
||||
"cs": "Nabízí internet"
|
||||
},
|
||||
"icon": "wifi",
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"internet_access=wlan",
|
||||
|
@ -355,6 +367,7 @@
|
|||
"cs": "Má bezlepkovou nabídku",
|
||||
"de": "Hat glutenfreie Angebote"
|
||||
},
|
||||
"icon": "./assets/layers/questions/glutenfree.svg",
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"diet:gluten_free=yes",
|
||||
|
@ -374,6 +387,7 @@
|
|||
"cs": "Má nabídku bez laktózy",
|
||||
"de": "Hat laktosefreie Angebote"
|
||||
},
|
||||
"icon": "./assets/layers/questions/lactose_free.svg",
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"diet:lactose_free=yes",
|
||||
|
|
|
@ -342,6 +342,7 @@
|
|||
"mappings": [
|
||||
{
|
||||
"if": "cuisine=pizza",
|
||||
"icon": "🍕",
|
||||
"then": {
|
||||
"en": "Pizzeria",
|
||||
"nl": "Pizzeria",
|
||||
|
@ -355,6 +356,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=friture",
|
||||
"icon": "🍟",
|
||||
"then": {
|
||||
"en": "Friture",
|
||||
"nl": "Frituur",
|
||||
|
@ -366,6 +368,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=pasta",
|
||||
"icon": "🍝",
|
||||
"then": {
|
||||
"en": "Serves mainly pasta",
|
||||
"nl": "Pastazaak",
|
||||
|
@ -379,6 +382,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=kebab",
|
||||
"icon": "🥙",
|
||||
"then": {
|
||||
"en": "Kebab shop",
|
||||
"nl": "Kebabzaak",
|
||||
|
@ -392,6 +396,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=sandwich",
|
||||
"icon": "🥪",
|
||||
"then": {
|
||||
"en": "Sandwich shop",
|
||||
"nl": "Broodjeszaak",
|
||||
|
@ -403,6 +408,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=burger",
|
||||
"icon": "🍔",
|
||||
"then": {
|
||||
"en": "Burgersrestaurant",
|
||||
"nl": "Hamburgerrestaurant",
|
||||
|
@ -415,6 +421,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=sushi",
|
||||
"icon": "🍣",
|
||||
"then": {
|
||||
"en": "Sushi restaurant",
|
||||
"nl": "Sushirestaurant",
|
||||
|
@ -427,6 +434,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=coffee",
|
||||
"icon": "☕",
|
||||
"then": {
|
||||
"en": "Coffeebar",
|
||||
"nl": "Koffiezaak",
|
||||
|
@ -439,6 +447,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=italian",
|
||||
"icon": "🇮🇹",
|
||||
"then": {
|
||||
"en": "Italian restaurant (which serves more than pasta and pizza)",
|
||||
"nl": "Italiaans restaurant (dat meer dan enkel pasta of pizza verkoopt)",
|
||||
|
@ -451,6 +460,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=french",
|
||||
"icon": "🇫🇷",
|
||||
"then": {
|
||||
"en": "French restaurant",
|
||||
"nl": "Frans restaurant",
|
||||
|
@ -463,6 +473,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=chinese",
|
||||
"icon": "🇨🇳",
|
||||
"then": {
|
||||
"en": "Chinese",
|
||||
"nl": "Chinees restaurant",
|
||||
|
@ -475,6 +486,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=greek",
|
||||
"icon": "🇬🇷",
|
||||
"then": {
|
||||
"en": "Greek",
|
||||
"nl": "Grieks restaurant",
|
||||
|
@ -487,6 +499,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=indian",
|
||||
"icon": "🇮🇳",
|
||||
"then": {
|
||||
"en": "Indian",
|
||||
"nl": "Indisch restaurant",
|
||||
|
@ -499,6 +512,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=turkish",
|
||||
"icon": "🇹🇷",
|
||||
"then": {
|
||||
"en": "Turkish restaurant",
|
||||
"nl": "Turks restaurant",
|
||||
|
@ -511,6 +525,7 @@
|
|||
},
|
||||
{
|
||||
"if": "cuisine=thai",
|
||||
"icon": "🇹🇭",
|
||||
"then": {
|
||||
"en": "Thai restaurant",
|
||||
"nl": "Thaïs restaurant",
|
||||
|
@ -519,9 +534,42 @@
|
|||
"ca": "Aquí es serveixen plats tailandesos",
|
||||
"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",
|
||||
|
@ -1291,6 +1339,7 @@
|
|||
"es": "Tiene menú vegetariano",
|
||||
"fr": "A un menu végétarien"
|
||||
},
|
||||
"icon": "./assets/layers/food/Vegetarian-mark.svg",
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"diet:vegetarian=yes",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -133,9 +133,8 @@
|
|||
}
|
||||
},
|
||||
"opening_hours",
|
||||
"phone",
|
||||
"email",
|
||||
"website",
|
||||
"contact",
|
||||
"payment-options",
|
||||
{
|
||||
"id": "wheelchair",
|
||||
"question": {
|
||||
|
|
66
assets/layers/search/search.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -260,6 +260,9 @@
|
|||
{
|
||||
"builtin": "id_presets.shop_types",
|
||||
"override": {
|
||||
"labels": [
|
||||
"description"
|
||||
],
|
||||
"question": {
|
||||
"en": "What kind of shop is this?",
|
||||
"nl": "Wat voor soort winkel is dit?",
|
||||
|
@ -307,7 +310,8 @@
|
|||
}
|
||||
],
|
||||
"condition": "craft=",
|
||||
"invalidValues": "shop=yes"
|
||||
"invalidValues": "shop=yes",
|
||||
"filter": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -1173,52 +1177,6 @@
|
|||
"description"
|
||||
],
|
||||
"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",
|
||||
"options": [
|
||||
|
|
|
@ -25,6 +25,31 @@
|
|||
"cs": "Vrstva zobrazující (veřejné) toalety",
|
||||
"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": {
|
||||
"osmTags": "amenity=toilets"
|
||||
},
|
||||
|
|
|
@ -305,6 +305,12 @@
|
|||
"*": "{logout()}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "title-map",
|
||||
"render": {
|
||||
"en": "<h3>Configure map</h3>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a11y-features",
|
||||
"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",
|
||||
"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",
|
||||
"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": {
|
||||
"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?"
|
||||
"en": "Should the thematic maps you visit be saved?"
|
||||
},
|
||||
"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."
|
||||
"en": "If you visit a map about a certain topic, MapComplete can remember this and offer this as suggestion."
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"if": "mapcomplete-show_tags=no",
|
||||
"if": "mapcomplete-preference-theme-history=sync",
|
||||
"alsoShowIf": "mapcomplete-preference-theme-history=",
|
||||
"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."
|
||||
"en": "Save the visited thematic maps and sync them via openstreetmap.org. OpenStreetMap and all apps you use can see this history"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "mapcomplete-show_tags=",
|
||||
"if": "mapcomplete-preference-theme-history=local",
|
||||
"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"
|
||||
"en": "Save the visited thematic maps on my device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "mapcomplete-show_tags=yes",
|
||||
"if": "mapcomplete-preference-theme-history=no",
|
||||
"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"
|
||||
"en": "Don't save visited thematic maps"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "all-questions-at-once",
|
||||
"id": "sync-visited-locations",
|
||||
"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_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?"
|
||||
"en": "Should the locations you search for and inspect be remembered?"
|
||||
},
|
||||
"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"
|
||||
"en": "Those locations will be offered in the search menu"
|
||||
},
|
||||
"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": "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",
|
||||
"if": "mapcomplete-preference-search-history=sync",
|
||||
"alsoShowIf": "mapcomplete-preference-search-history=",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "mapcomplete-preferences-add-new-mode=click",
|
||||
"if": "mapcomplete-preference-search-history=local",
|
||||
"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"
|
||||
"en": "Save the locations you search for and inspect on my device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "mapcomplete-preferences-add-new-mode=click_right",
|
||||
"if": "mapcomplete-preference-search-history=no",
|
||||
"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"
|
||||
"en": "Don't save the locations you search for and inspect "
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "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": "title-id",
|
||||
"render": {
|
||||
"en": "<h3>Mangrove ID management</h3>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mangrove-keys",
|
||||
|
|
4
assets/svg/airport.svg
Normal 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
	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
	L15,6.8182z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 385 B |
2
assets/svg/airport.svg.license
Normal file
|
@ -0,0 +1,2 @@
|
|||
SPDX-FileCopyrightText: Maki
|
||||
SPDX-License-Identifier: CC0-1.0
|
|
@ -59,6 +59,16 @@
|
|||
],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"path": "airport.svg",
|
||||
"license": "CC0-1.0",
|
||||
"authors": [
|
||||
"Maki"
|
||||
],
|
||||
"sources": [
|
||||
"https://github.com/mapbox/maki/blob/main/icons/airport.svg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "back.svg",
|
||||
"license": "CC0-1.0",
|
||||
|
@ -1183,6 +1193,16 @@
|
|||
"https://pngimg.com/image/46283"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "train.svg",
|
||||
"license": "CC0-1.0",
|
||||
"authors": [
|
||||
"Maki"
|
||||
],
|
||||
"sources": [
|
||||
"https://labs.mapbox.com/maki-icons/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "translate.svg",
|
||||
"license": "CC-BY-SA-3.0",
|
||||
|
|
24
assets/svg/train.svg
Normal 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 |
2
assets/svg/train.svg.license
Normal file
|
@ -0,0 +1,2 @@
|
|||
SPDX-FileCopyrightText: Maki
|
||||
SPDX-License-Identifier: CC0-1.0
|
|
@ -80,7 +80,7 @@
|
|||
"override": {
|
||||
"name=": null,
|
||||
"minzoom": 17,
|
||||
"filter": {
|
||||
"=filter": {
|
||||
"sameAs": "bike_shop"
|
||||
}
|
||||
}
|
||||
|
@ -182,7 +182,7 @@
|
|||
"builtin": "charging_station",
|
||||
"override": {
|
||||
"name": null,
|
||||
"filter": {
|
||||
"=filter": {
|
||||
"sameAs": "charging_station_ebikes"
|
||||
},
|
||||
"minzoom": 18,
|
||||
|
@ -201,6 +201,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"filter": null,
|
||||
"=presets": []
|
||||
}
|
||||
},
|
||||
|
@ -209,7 +210,7 @@
|
|||
"builtin": "vending_machine",
|
||||
"override": {
|
||||
"name": null,
|
||||
"filter": {
|
||||
"=filter": {
|
||||
"sameAs": "vending_machine_bicycle"
|
||||
},
|
||||
"minzoom": 18,
|
||||
|
|
|
@ -50,13 +50,24 @@
|
|||
},
|
||||
"icon": "./assets/themes/shops/shop.svg",
|
||||
"layers": [
|
||||
"shops",
|
||||
"pharmacy",
|
||||
"ice_cream",
|
||||
"trolley_bay"
|
||||
],
|
||||
"overrideAll": {
|
||||
"minzoom": 14,
|
||||
"syncSelection": "theme-only"
|
||||
}
|
||||
{
|
||||
"builtin": [
|
||||
"shops",
|
||||
"pharmacy",
|
||||
"ice_cream"
|
||||
],
|
||||
"override": {
|
||||
"minzoom": 12,
|
||||
"syncSelection": "theme-only"
|
||||
}
|
||||
},
|
||||
{
|
||||
"builtin": [
|
||||
"trolley_bay"
|
||||
],
|
||||
"override": {
|
||||
"syncSelection": "theme-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -262,6 +262,7 @@
|
|||
"examples": "Examples",
|
||||
"fewChangesBefore": "Please, answer a few questions of existing features before adding a new feature.",
|
||||
"filterPanel": {
|
||||
"allTypes": "All types",
|
||||
"disableAll": "Disable all",
|
||||
"enableAll": "Enable all"
|
||||
},
|
||||
|
@ -396,8 +397,22 @@
|
|||
"save": "Save",
|
||||
"screenToSmall": "Open <i>{theme}</i> in a new window",
|
||||
"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…",
|
||||
"instructions": "Use the search bar above to search for locations, filters or other thematic maps",
|
||||
"locations": "Locations",
|
||||
"nMoreFilters": "{n} more",
|
||||
"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",
|
||||
"searchShort": "Search…",
|
||||
"searching": "Searching…"
|
||||
|
|
|
@ -6894,15 +6894,6 @@
|
|||
}
|
||||
},
|
||||
"description": "Una botiga",
|
||||
"filter": {
|
||||
"1": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Sols mostrar botigues que venen {search}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "Botiga",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
|
@ -7201,21 +7201,7 @@
|
|||
},
|
||||
"description": "Obchod",
|
||||
"filter": {
|
||||
"1": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Zobrazit pouze obchody prodávající {search}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Zobrazit pouze obchody s názvem {search}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"0": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Zobrazit pouze obchody prodávající použité zboží"
|
||||
|
|
|
@ -1612,6 +1612,13 @@
|
|||
"arialabel": "Åbn på openstreetmap.org"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"then": {
|
||||
"special": {
|
||||
"arialabel": "Åbn på openstreetmap.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"render": {
|
||||
|
|
|
@ -5881,6 +5881,13 @@
|
|||
"arialabel": "Auf openstreetmap.org öffnen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"then": {
|
||||
"special": {
|
||||
"arialabel": "Auf openstreetmap.org öffnen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"render": {
|
||||
|
@ -9070,21 +9077,7 @@
|
|||
},
|
||||
"description": "Ein Geschäft",
|
||||
"filter": {
|
||||
"1": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Nur Geschäfte, die {search} verkaufen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Nur Geschäfte mit dem Namen {search} anzeigen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"0": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Nur Second-Hand-Geschäfte anzeigen"
|
||||
|
|
|
@ -5169,6 +5169,18 @@
|
|||
"14": {
|
||||
"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": {
|
||||
"then": "Serves mainly pasta"
|
||||
},
|
||||
|
@ -5889,6 +5901,13 @@
|
|||
"arialabel": "Open on openstreetmap.org"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"then": {
|
||||
"special": {
|
||||
"arialabel": "Open on openstreetmap.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"render": {
|
||||
|
@ -9127,6 +9146,14 @@
|
|||
"render": "School <i>{name}</i>"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"description": "Priviliged layer showing the search results",
|
||||
"tagRenderings": {
|
||||
"intro": {
|
||||
"render": "Search result"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selected_element": {
|
||||
"description": "Highlights the currently selected element. Override this layer to have different colors"
|
||||
},
|
||||
|
@ -9174,21 +9201,7 @@
|
|||
},
|
||||
"description": "A shop",
|
||||
"filter": {
|
||||
"1": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Only show shops selling {search}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Only show shops with name {search}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"0": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Only show shops selling second-hand items"
|
||||
|
@ -11734,6 +11747,48 @@
|
|||
"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."
|
||||
},
|
||||
"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": {
|
||||
"mappings": {
|
||||
"0": {
|
||||
|
|
|
@ -3990,22 +3990,6 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
|
@ -5815,22 +5815,6 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
|
@ -4032,6 +4032,19 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"options": {
|
||||
"0": {
|
||||
"question": "Geen voorkeur voor honden"
|
||||
},
|
||||
"1": {
|
||||
"question": "Honden toegelaten"
|
||||
},
|
||||
"2": {
|
||||
"question": "Geen honden toegelaten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"options": {
|
||||
"0": {
|
||||
|
@ -4296,6 +4309,18 @@
|
|||
"14": {
|
||||
"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": {
|
||||
"then": "Pastazaak"
|
||||
},
|
||||
|
@ -4885,6 +4910,13 @@
|
|||
"arialabel": "Bekijk op openstreetmap.org"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"then": {
|
||||
"special": {
|
||||
"arialabel": "Bekijk op openstreetmap.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"render": {
|
||||
|
@ -7349,22 +7381,6 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
|
@ -2180,6 +2180,13 @@
|
|||
"arialabel": "Otwórz na openstreetmap.org"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"then": {
|
||||
"special": {
|
||||
"arialabel": "Otwórz na openstreetmap.org"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"render": {
|
||||
|
|
|
@ -333,9 +333,21 @@
|
|||
"save": "Opslaan",
|
||||
"screenToSmall": "Open {theme} in een nieuw venster",
|
||||
"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…",
|
||||
"instructions": "Gebruik de zoekbalk om locaties, filters of om andere kaarten te zoeken",
|
||||
"locations": "Plaatsen",
|
||||
"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…",
|
||||
"searching": "Aan het zoeken…"
|
||||
},
|
||||
|
|
|
@ -51,6 +51,8 @@
|
|||
},
|
||||
"country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country",
|
||||
"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}",
|
||||
"protomaps": {
|
||||
"api-key": "2af8b969a9e8b692",
|
||||
|
@ -91,7 +93,7 @@
|
|||
"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",
|
||||
"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:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -",
|
||||
"clean:tests": "find . -type f -name \"*.doctest.ts\" | xargs -r rm",
|
||||
|
|
|
@ -1168,6 +1168,14 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
right: 33.333333%;
|
||||
}
|
||||
|
||||
.right-10 {
|
||||
right: 2.5rem;
|
||||
}
|
||||
|
||||
.top-10 {
|
||||
top: 2.5rem;
|
||||
}
|
||||
|
||||
.top-4 {
|
||||
top: 1rem;
|
||||
}
|
||||
|
@ -1414,6 +1422,11 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
|
@ -1439,11 +1452,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-right: -0.25rem;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mx-12 {
|
||||
margin-left: 3rem;
|
||||
margin-right: 3rem;
|
||||
|
@ -1470,6 +1478,14 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-0\.5 {
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
@ -1486,14 +1502,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
@ -1502,8 +1510,8 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
|
@ -1522,8 +1530,8 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
|
@ -1729,6 +1737,10 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
height: 0px;
|
||||
}
|
||||
|
||||
.h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.h-1\/2 {
|
||||
height: 50%;
|
||||
}
|
||||
|
@ -1741,10 +1753,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
height: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.h-11 {
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
@ -1991,14 +1999,14 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
width: 0px;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-7 {
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-11 {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
@ -2752,6 +2760,10 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.overflow-x-hidden {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.overflow-y-scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
@ -3908,6 +3920,11 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.px-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
|
@ -3973,11 +3990,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
padding-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.px-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.\!px-0 {
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
|
@ -4000,10 +4012,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-1 {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.pr-1 {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
@ -4036,6 +4044,10 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-1 {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.pb-1\.5 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
--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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.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, backdrop-filter;
|
||||
|
@ -4941,12 +4964,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
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-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
@ -4986,10 +5003,10 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
}
|
||||
|
||||
:root {
|
||||
/*
|
||||
* The main colour scheme of mapcomplete is configured here.
|
||||
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
|
||||
*/
|
||||
/*
|
||||
* The main colour scheme of mapcomplete is configured here.
|
||||
* 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 */
|
||||
color-scheme: only light;
|
||||
/* 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-font: #B8B8B8;
|
||||
/**
|
||||
* Base colour of interactive elements, mainly the 'subtle button'
|
||||
* @deprecated
|
||||
*/
|
||||
* Base colour of interactive elements, mainly the 'subtle button'
|
||||
* @deprecated
|
||||
*/
|
||||
--subtle-detail-color: #dbeafe;
|
||||
--subtle-detail-color-contrast: black;
|
||||
--subtle-detail-color-light-contrast: lightgrey;
|
||||
|
@ -5026,14 +5043,14 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
--catch-detail-color-contrast: #fb3afb;
|
||||
--image-carousel-height: 350px;
|
||||
/** Technical value, used by icon.svelte
|
||||
*/
|
||||
*/
|
||||
--svg-color: #000000;
|
||||
}
|
||||
|
||||
@font-face{
|
||||
font-family:"Source Sans Pro";
|
||||
@font-face {
|
||||
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;
|
||||
}
|
||||
|
||||
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 ******/
|
||||
|
||||
.hover-alert:hover {
|
||||
|
@ -5264,18 +5291,18 @@ select:hover {
|
|||
|
||||
.neutral-label {
|
||||
/** 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 should _contain_ the input element
|
||||
*/
|
||||
* Label should _contain_ the input element
|
||||
*/
|
||||
padding: 0.25rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
margin:0.25rem;
|
||||
margin: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
@ -5329,6 +5356,17 @@ h2.group {
|
|||
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 {
|
||||
/* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */
|
||||
background-color: var(--alert-color);
|
||||
|
@ -5496,6 +5534,10 @@ a.link-underline {
|
|||
color: unset !important;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background-color: var(--low-interaction-background);
|
||||
}
|
||||
|
||||
.disable-links a.must-link,
|
||||
.disable-links .must-link a {
|
||||
/* Hide links if they are disabled */
|
||||
|
@ -5514,7 +5556,7 @@ a.link-underline {
|
|||
|
||||
.selected svg:not(.noselect *) path.selectable {
|
||||
/* A marker on the map gets the 'selected' class when it's properties are displayed
|
||||
*/
|
||||
*/
|
||||
stroke: white !important;
|
||||
stroke-width: 20px !important;
|
||||
overflow: visible !important;
|
||||
|
@ -5528,7 +5570,7 @@ a.link-underline {
|
|||
|
||||
.selected svg {
|
||||
/* A marker on the map gets the 'selected' class when it's properties are displayed
|
||||
*/
|
||||
*/
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
|
@ -8060,14 +8102,14 @@ svg.apply-fill path {
|
|||
order: 9999;
|
||||
}
|
||||
|
||||
.sm\:m-2 {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.sm\:m-1 {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.sm\:m-2 {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.sm\:mx-1 {
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
|
@ -8082,10 +8124,6 @@ svg.apply-fill path {
|
|||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sm\:mt-0 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.sm\:mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
@ -8146,6 +8184,10 @@ svg.apply-fill path {
|
|||
width: 16rem;
|
||||
}
|
||||
|
||||
.sm\:w-80 {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.sm\:w-11 {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
@ -8359,6 +8401,10 @@ svg.apply-fill path {
|
|||
width: 50%;
|
||||
}
|
||||
|
||||
.md\:w-96 {
|
||||
width: 24rem;
|
||||
}
|
||||
|
||||
.md\:w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ export default abstract class Script {
|
|||
})
|
||||
.catch((e) => {
|
||||
console.log(`ERROR in script ${process.argv[1]}:`, e)
|
||||
process.exit(1)
|
||||
// process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ export class GenerateFavouritesLayer extends Script {
|
|||
this.addTagRenderings(proto)
|
||||
this.addTitle(proto)
|
||||
proto.titleIcons = this.generateTitleIcons()
|
||||
delete proto.filter
|
||||
const targetContent = JSON.stringify(proto, null, " ")
|
||||
const path = "./assets/layers/favourite/favourite.json"
|
||||
if (existsSync(path)) {
|
||||
|
|
|
@ -29,11 +29,14 @@ import LayerConfig from "../src/Models/ThemeConfig/LayerConfig"
|
|||
import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig"
|
||||
import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext"
|
||||
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 { Translatable } from "../src/Models/ThemeConfig/Json/Translatable"
|
||||
import { ValidateThemeAndLayers } from "../src/Models/ThemeConfig/Conversion/ValidateThemeAndLayers"
|
||||
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.
|
||||
// It spits out an overview of those to be used to load them
|
||||
|
@ -56,7 +59,7 @@ class ParseLayer extends Conversion<
|
|||
|
||||
convert(
|
||||
path: string,
|
||||
context: ConversionContext
|
||||
context: ConversionContext,
|
||||
): {
|
||||
parsed: LayerConfig
|
||||
raw: LayerConfigJson
|
||||
|
@ -85,7 +88,7 @@ class ParseLayer extends Conversion<
|
|||
context
|
||||
.enter("source")
|
||||
.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
|
||||
}
|
||||
|
@ -116,7 +119,7 @@ class AddIconSummary extends DesugaringStep<{ raw: LayerConfigJson; parsed: Laye
|
|||
const fixed = json.raw
|
||||
const layerConfig = json.parsed
|
||||
const pointRendering: PointRenderingConfig = layerConfig.mapRendering.find((pr) =>
|
||||
pr.location.has("point")
|
||||
pr.location.has("point"),
|
||||
)
|
||||
const defaultTags = layerConfig.GetBaseTags()
|
||||
fixed["_layerIcon"] = Utils.NoNull(
|
||||
|
@ -131,7 +134,7 @@ class AddIconSummary extends DesugaringStep<{ raw: LayerConfigJson; parsed: Laye
|
|||
result["color"] = c
|
||||
}
|
||||
return result
|
||||
})
|
||||
}),
|
||||
)
|
||||
return { raw: fixed, parsed: layerConfig }
|
||||
}
|
||||
|
@ -153,7 +156,7 @@ class LayerOverviewUtils extends Script {
|
|||
|
||||
private static extractLayerIdsFrom(
|
||||
themeFile: LayoutConfigJson,
|
||||
includeInlineLayers = true
|
||||
includeInlineLayers = true,
|
||||
): string[] {
|
||||
const publicLayerIds: string[] = []
|
||||
if (!Array.isArray(themeFile.layers)) {
|
||||
|
@ -185,7 +188,7 @@ class LayerOverviewUtils extends Script {
|
|||
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
|
||||
}
|
||||
|
||||
|
@ -208,11 +211,71 @@ class LayerOverviewUtils extends Script {
|
|||
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(
|
||||
themes: {
|
||||
id: string
|
||||
title: any
|
||||
shortDescription: any
|
||||
title: Translatable
|
||||
shortDescription: Translatable
|
||||
icon: string
|
||||
hideFromOverview: boolean
|
||||
mustHaveLanguage: boolean
|
||||
|
@ -220,29 +283,43 @@ class LayerOverviewUtils extends Script {
|
|||
| LayerConfigJson
|
||||
| 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) {
|
||||
const keywords: {}[] = []
|
||||
|
||||
const keywords: Record<string, string[]> = {}
|
||||
for (const layer of theme.layers ?? []) {
|
||||
const l = <LayerConfigJson>layer
|
||||
keywords.push({ "*": l.id })
|
||||
keywords.push(l.title)
|
||||
keywords.push(l.description)
|
||||
if(sharedLayers.has(l.id)){
|
||||
continue
|
||||
}
|
||||
if(l.id.startsWith("note_import")){
|
||||
continue
|
||||
}
|
||||
LayerOverviewUtils.mergeKeywords(keywords, this.layerKeywords(l))
|
||||
|
||||
}
|
||||
|
||||
const data = {
|
||||
const data = <MinimalLayoutInformation> {
|
||||
id: theme.id,
|
||||
title: theme.title,
|
||||
shortDescription: LayerOverviewUtils.cleanTranslation(theme.shortDescription),
|
||||
icon: theme.icon,
|
||||
hideFromOverview: theme.hideFromOverview,
|
||||
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)
|
||||
}
|
||||
|
@ -263,8 +340,8 @@ class LayerOverviewUtils extends Script {
|
|||
|
||||
writeFileSync(
|
||||
"./src/assets/generated/theme_overview.json",
|
||||
JSON.stringify(sorted, null, " "),
|
||||
{ encoding: "utf8" }
|
||||
JSON.stringify({ layers: layerKeywords, themes: sorted }, null, " "),
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -276,7 +353,7 @@ class LayerOverviewUtils extends Script {
|
|||
writeFileSync(
|
||||
`${LayerOverviewUtils.themePath}${theme.id}.json`,
|
||||
JSON.stringify(theme, null, " "),
|
||||
{ encoding: "utf8" }
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -287,12 +364,12 @@ class LayerOverviewUtils extends Script {
|
|||
writeFileSync(
|
||||
`${LayerOverviewUtils.layerPath}${layer.id}.json`,
|
||||
JSON.stringify(layer, null, " "),
|
||||
{ encoding: "utf8" }
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
}
|
||||
|
||||
static asDict(
|
||||
trs: QuestionableTagRenderingConfigJson[]
|
||||
trs: QuestionableTagRenderingConfigJson[],
|
||||
): Map<string, QuestionableTagRenderingConfigJson> {
|
||||
const d = new Map<string, QuestionableTagRenderingConfigJson>()
|
||||
for (const tr of trs) {
|
||||
|
@ -305,12 +382,12 @@ class LayerOverviewUtils extends Script {
|
|||
getSharedTagRenderings(
|
||||
doesImageExist: DoesImageExist,
|
||||
bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson>,
|
||||
bootstrapTagRenderingsOrder: string[]
|
||||
bootstrapTagRenderingsOrder: string[],
|
||||
): QuestionableTagRenderingConfigJson[]
|
||||
getSharedTagRenderings(
|
||||
doesImageExist: DoesImageExist,
|
||||
bootstrapTagRenderings: Map<string, QuestionableTagRenderingConfigJson> = null,
|
||||
bootstrapTagRenderingsOrder: string[] = []
|
||||
bootstrapTagRenderingsOrder: string[] = [],
|
||||
): QuestionableTagRenderingConfigJson[] {
|
||||
const prepareLayer = new PrepareLayer(
|
||||
{
|
||||
|
@ -321,7 +398,7 @@ class LayerOverviewUtils extends Script {
|
|||
},
|
||||
{
|
||||
addTagRenderingsToContext: true,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const path = "assets/layers/questions/questions.json"
|
||||
|
@ -341,7 +418,7 @@ class LayerOverviewUtils extends Script {
|
|||
return this.getSharedTagRenderings(
|
||||
doesImageExist,
|
||||
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) {
|
||||
console.warn(
|
||||
"The SVG at " +
|
||||
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"
|
||||
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++
|
||||
}
|
||||
|
@ -398,14 +475,14 @@ class LayerOverviewUtils extends Script {
|
|||
args
|
||||
.find((a) => a.startsWith("--themes="))
|
||||
?.substring("--themes=".length)
|
||||
?.split(",") ?? []
|
||||
?.split(",") ?? [],
|
||||
)
|
||||
|
||||
const layerWhitelist = new Set(
|
||||
args
|
||||
.find((a) => a.startsWith("--layers="))
|
||||
?.substring("--layers=".length)
|
||||
?.split(",") ?? []
|
||||
?.split(",") ?? [],
|
||||
)
|
||||
|
||||
const forceReload = args.some((a) => a == "--force")
|
||||
|
@ -425,6 +502,7 @@ class LayerOverviewUtils extends Script {
|
|||
// These two get a free pass
|
||||
priviliged.delete("summary")
|
||||
priviliged.delete("last_click")
|
||||
priviliged.delete("search")
|
||||
|
||||
const isBoostrapping = AllSharedLayers.getSharedLayersConfigs().size == 0
|
||||
if (!isBoostrapping && priviliged.size > 0) {
|
||||
|
@ -440,11 +518,11 @@ class LayerOverviewUtils extends Script {
|
|||
sharedLayers,
|
||||
recompiledThemes,
|
||||
forceReload,
|
||||
themeWhitelist
|
||||
themeWhitelist,
|
||||
)
|
||||
|
||||
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) {
|
||||
|
@ -452,7 +530,7 @@ class LayerOverviewUtils extends Script {
|
|||
"./src/assets/generated/known_layers.json",
|
||||
JSON.stringify({
|
||||
layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -473,7 +551,7 @@ class LayerOverviewUtils extends Script {
|
|||
const proto: LayoutConfigJson = JSON.parse(
|
||||
readFileSync("./assets/themes/mapcomplete-changes/mapcomplete-changes.proto.json", {
|
||||
encoding: "utf8",
|
||||
})
|
||||
}),
|
||||
)
|
||||
const protolayer = <LayerConfigJson>(
|
||||
proto.layers.filter((l) => l["id"] === "mapcomplete-changes")[0]
|
||||
|
@ -490,12 +568,12 @@ class LayerOverviewUtils extends Script {
|
|||
layers: ScriptUtils.getLayerFiles().map((f) => f.parsed),
|
||||
themes: ScriptUtils.getThemeFiles().map((f) => f.parsed),
|
||||
},
|
||||
ConversionContext.construct([], [])
|
||||
ConversionContext.construct([], []),
|
||||
)
|
||||
|
||||
for (const [_, theme] of sharedThemes) {
|
||||
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",
|
||||
JSON.stringify({
|
||||
themes: Array.from(sharedThemes.values()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -516,7 +594,7 @@ class LayerOverviewUtils extends Script {
|
|||
private parseLayer(
|
||||
doesImageExist: DoesImageExist,
|
||||
prepLayer: PrepareLayer,
|
||||
sharedLayerPath: string
|
||||
sharedLayerPath: string,
|
||||
): {
|
||||
raw: LayerConfigJson
|
||||
parsed: LayerConfig
|
||||
|
@ -527,7 +605,7 @@ class LayerOverviewUtils extends Script {
|
|||
const parsed = parser.convertStrict(sharedLayerPath, context)
|
||||
const result = AddIconSummary.singleton.convertStrict(
|
||||
parsed,
|
||||
context.inOperation("AddIconSummary")
|
||||
context.inOperation("AddIconSummary"),
|
||||
)
|
||||
return { ...result, context }
|
||||
}
|
||||
|
@ -535,7 +613,7 @@ class LayerOverviewUtils extends Script {
|
|||
private buildLayerIndex(
|
||||
doesImageExist: DoesImageExist,
|
||||
forceReload: boolean,
|
||||
whitelist: Set<string>
|
||||
whitelist: Set<string>,
|
||||
): Map<string, LayerConfigJson> {
|
||||
// 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.
|
||||
|
@ -590,17 +668,17 @@ class LayerOverviewUtils extends Script {
|
|||
|
||||
console.log(
|
||||
"Recompiled layers " +
|
||||
recompiledLayers.join(", ") +
|
||||
" and skipped " +
|
||||
skippedLayers.length +
|
||||
" layers. Detected " +
|
||||
warningCount +
|
||||
" warnings"
|
||||
recompiledLayers.join(", ") +
|
||||
" and skipped " +
|
||||
skippedLayers.length +
|
||||
" layers. Detected " +
|
||||
warningCount +
|
||||
" warnings",
|
||||
)
|
||||
// We always need the calculated tags of 'usersettings', so we export them separately
|
||||
this.extractJavascriptCodeForLayer(
|
||||
state.sharedLayers.get("usersettings"),
|
||||
"./src/Logic/State/UserSettingsMetaTagging.ts"
|
||||
"./src/Logic/State/UserSettingsMetaTagging.ts",
|
||||
)
|
||||
|
||||
return sharedLayers
|
||||
|
@ -617,8 +695,8 @@ class LayerOverviewUtils extends Script {
|
|||
private extractJavascriptCode(themeFile: LayoutConfigJson) {
|
||||
const allCode = [
|
||||
"import {Feature} from 'geojson'",
|
||||
'import { ExtraFuncType } from "../../../Logic/ExtraFunctions";',
|
||||
'import { Utils } from "../../../Utils"',
|
||||
"import { ExtraFuncType } from \"../../../Logic/ExtraFunctions\";",
|
||||
"import { Utils } from \"../../../Utils\"",
|
||||
"export class ThemeMetaTagging {",
|
||||
" public static readonly themeName = " + JSON.stringify(themeFile.id),
|
||||
"",
|
||||
|
@ -630,8 +708,8 @@ class LayerOverviewUtils extends Script {
|
|||
|
||||
allCode.push(
|
||||
" public metaTaggging_for_" +
|
||||
id +
|
||||
"(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {"
|
||||
id +
|
||||
"(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {",
|
||||
)
|
||||
allCode.push(" const {" + ExtraFunctions.types.join(", ") + "} = helperFunctions")
|
||||
for (const line of code) {
|
||||
|
@ -642,10 +720,10 @@ class LayerOverviewUtils extends Script {
|
|||
if (!isStrict) {
|
||||
allCode.push(
|
||||
" Utils.AddLazyProperty(feat.properties, '" +
|
||||
attributeName +
|
||||
"', () => " +
|
||||
expression +
|
||||
" ) "
|
||||
attributeName +
|
||||
"', () => " +
|
||||
expression +
|
||||
" ) ",
|
||||
)
|
||||
} else {
|
||||
attributeName = attributeName.substring(0, attributeName.length - 1).trim()
|
||||
|
@ -690,7 +768,7 @@ class LayerOverviewUtils extends Script {
|
|||
const code = l.calculatedTags ?? []
|
||||
|
||||
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) {
|
||||
const firstEq = line.indexOf("=")
|
||||
|
@ -700,10 +778,10 @@ class LayerOverviewUtils extends Script {
|
|||
if (!isStrict) {
|
||||
allCode.push(
|
||||
" Utils.AddLazyProperty(feat.properties, '" +
|
||||
attributeName +
|
||||
"', () => " +
|
||||
expression +
|
||||
" ) "
|
||||
attributeName +
|
||||
"', () => " +
|
||||
expression +
|
||||
" ) ",
|
||||
)
|
||||
} else {
|
||||
attributeName = attributeName.substring(0, attributeName.length - 2).trim()
|
||||
|
@ -728,14 +806,14 @@ class LayerOverviewUtils extends Script {
|
|||
sharedLayers: Map<string, LayerConfigJson>,
|
||||
recompiledThemes: string[],
|
||||
forceReload: boolean,
|
||||
whitelist: Set<string>
|
||||
whitelist: Set<string>,
|
||||
): Map<string, LayoutConfigJson> {
|
||||
console.log(" ---------- VALIDATING BUILTIN THEMES ---------")
|
||||
const themeFiles = ScriptUtils.getThemeFiles()
|
||||
const fixed = new Map<string, LayoutConfigJson>()
|
||||
|
||||
const publicLayers = LayerOverviewUtils.publicLayerIdsFrom(
|
||||
themeFiles.map((th) => th.parsed)
|
||||
themeFiles.map((th) => th.parsed),
|
||||
)
|
||||
|
||||
const trs = this.getSharedTagRenderings(new DoesImageExist(licensePaths, existsSync))
|
||||
|
@ -775,15 +853,15 @@ class LayerOverviewUtils extends Script {
|
|||
LayerOverviewUtils.themePath + "/" + themePath.substring(themePath.lastIndexOf("/"))
|
||||
|
||||
const usedLayers = Array.from(
|
||||
LayerOverviewUtils.extractLayerIdsFrom(themeFile, false)
|
||||
LayerOverviewUtils.extractLayerIdsFrom(themeFile, false),
|
||||
).map((id) => LayerOverviewUtils.layerPath + id + ".json")
|
||||
|
||||
if (!forceReload && !this.shouldBeUpdated([themePath, ...usedLayers], targetPath)) {
|
||||
fixed.set(
|
||||
themeFile.id,
|
||||
JSON.parse(
|
||||
readFileSync(LayerOverviewUtils.themePath + themeFile.id + ".json", "utf8")
|
||||
)
|
||||
readFileSync(LayerOverviewUtils.themePath + themeFile.id + ".json", "utf8"),
|
||||
),
|
||||
)
|
||||
ScriptUtils.erasableLog("Skipping", themeFile.id)
|
||||
skippedThemes.push(themeFile.id)
|
||||
|
@ -794,23 +872,23 @@ class LayerOverviewUtils extends Script {
|
|||
|
||||
new PrevalidateTheme().convertStrict(
|
||||
themeFile,
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"])
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"]),
|
||||
)
|
||||
try {
|
||||
themeFile = new PrepareTheme(convertState, {
|
||||
skipDefaultLayers: true,
|
||||
}).convertStrict(
|
||||
themeFile,
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"])
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"]),
|
||||
)
|
||||
new ValidateThemeAndLayers(
|
||||
new DoesImageExist(licensePaths, existsSync, knownTagRenderings),
|
||||
themePath,
|
||||
true,
|
||||
knownTagRenderings
|
||||
knownTagRenderings,
|
||||
).convertStrict(
|
||||
themeFile,
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"])
|
||||
ConversionContext.construct([themePath], ["PrepareLayer"]),
|
||||
)
|
||||
|
||||
if (themeFile.icon.endsWith(".svg")) {
|
||||
|
@ -860,7 +938,7 @@ class LayerOverviewUtils extends Script {
|
|||
const usedImages = Utils.Dedup(
|
||||
new ExtractImages(true, knownTagRenderings)
|
||||
.convertStrict(themeFile)
|
||||
.map((x) => x.path)
|
||||
.map((x) => x.path),
|
||||
)
|
||||
usedImages.sort()
|
||||
|
||||
|
@ -879,23 +957,24 @@ class LayerOverviewUtils extends Script {
|
|||
if (whitelist.size == 0) {
|
||||
this.writeSmallOverview(
|
||||
Array.from(fixed.values()).map((t) => {
|
||||
return {
|
||||
return <any> {
|
||||
...t,
|
||||
hideFromOverview: t.hideFromOverview ?? false,
|
||||
shortDescription:
|
||||
t.shortDescription ?? new Translation(t.description).FirstSentence(),
|
||||
mustHaveLanguage: t.mustHaveLanguage?.length > 0,
|
||||
}
|
||||
})
|
||||
}),
|
||||
sharedLayers
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Recompiled themes " +
|
||||
recompiledThemes.join(", ") +
|
||||
" and skipped " +
|
||||
skippedThemes.length +
|
||||
" themes"
|
||||
recompiledThemes.join(", ") +
|
||||
" and skipped " +
|
||||
skippedThemes.length +
|
||||
" themes",
|
||||
)
|
||||
|
||||
return fixed
|
||||
|
|
|
@ -327,8 +327,6 @@ class GenerateLayouts extends Script {
|
|||
): Promise<string> {
|
||||
const apiUrls: string[] = [
|
||||
...Constants.allServers,
|
||||
Constants.countryCoderEndpoint,
|
||||
Constants.nominatimEndpoint,
|
||||
"https://www.openstreetmap.org",
|
||||
"https://api.openstreetmap.org",
|
||||
"https://pietervdvn.goatcounter.com",
|
||||
|
|
16
scripts/initFiles.sh
Executable 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
|
|
@ -7,7 +7,7 @@ export class AllSharedLayers {
|
|||
public static sharedLayers: Map<string, LayerConfig> = AllSharedLayers.getSharedLayers()
|
||||
public static getSharedLayersConfigs(): 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
|
||||
sharedLayers.set(layer.id, layer)
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ export class AllSharedLayers {
|
|||
}
|
||||
private static getSharedLayers(): Map<string, LayerConfig> {
|
||||
const sharedLayers = new Map<string, LayerConfig>()
|
||||
for (const layer of known_layers.layers) {
|
||||
for (const layer of known_layers["layers"]) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const parsed = new LayerConfig(layer, "shared_layers")
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class TitleHandler {
|
|||
if (selected === undefined) {
|
||||
return defaultTitle
|
||||
}
|
||||
const layer = state.layout.getMatchingLayer(selected.properties)
|
||||
const layer = state.getMatchingLayer(selected.properties)
|
||||
if (layer === undefined) {
|
||||
return defaultTitle
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Feature, Polygon } from "geojson"
|
|||
export class BBox {
|
||||
static global: BBox = new BBox([
|
||||
[-180, -90],
|
||||
[180, 90],
|
||||
[180, 90]
|
||||
])
|
||||
readonly maxLat: number
|
||||
readonly maxLon: number
|
||||
|
@ -53,7 +53,7 @@ export class BBox {
|
|||
static fromLeafletBounds(bounds) {
|
||||
return new BBox([
|
||||
[bounds.getWest(), bounds.getNorth()],
|
||||
[bounds.getEast(), bounds.getSouth()],
|
||||
[bounds.getEast(), bounds.getSouth()]
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,7 @@ export class BBox {
|
|||
// Note: x is longitude
|
||||
f["bbox"] = new BBox([
|
||||
[minX, minY],
|
||||
[maxX, maxY],
|
||||
[maxX, maxY]
|
||||
])
|
||||
}
|
||||
return f["bbox"]
|
||||
|
@ -94,7 +94,7 @@ export class BBox {
|
|||
}
|
||||
return new BBox([
|
||||
[maxLon, maxLat],
|
||||
[minLon, minLat],
|
||||
[minLon, minLat]
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -121,7 +121,7 @@ export class BBox {
|
|||
public unionWith(other: BBox) {
|
||||
return new BBox([
|
||||
[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([
|
||||
[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)
|
||||
return new BBox([
|
||||
[this.minLon - lonDiff, this.minLat - latDiff],
|
||||
[this.maxLon + lonDiff, this.maxLat + latDiff],
|
||||
[this.maxLon + lonDiff, this.maxLat + latDiff]
|
||||
])
|
||||
}
|
||||
|
||||
padAbsolute(degrees: number): BBox {
|
||||
return new BBox([
|
||||
[this.minLon - degrees, this.minLat - degrees],
|
||||
[this.maxLon + degrees, this.maxLat + degrees],
|
||||
[this.maxLon + degrees, this.maxLat + degrees]
|
||||
])
|
||||
}
|
||||
|
||||
toLngLat(): [[number, number], [number, number]] {
|
||||
return [
|
||||
[this.minLon, this.minLat],
|
||||
[this.maxLon, this.maxLat],
|
||||
[this.maxLon, this.maxLat]
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -260,7 +260,7 @@ export class BBox {
|
|||
return {
|
||||
type: "Feature",
|
||||
properties: properties,
|
||||
geometry: this.asGeometry(),
|
||||
geometry: this.asGeometry()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -273,9 +273,9 @@ export class BBox {
|
|||
[this.maxLon, this.minLat],
|
||||
[this.maxLon, this.maxLat],
|
||||
[this.minLon, this.maxLat],
|
||||
[this.minLon, this.minLat],
|
||||
],
|
||||
],
|
||||
[this.minLon, this.minLat]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -302,7 +302,7 @@ export class BBox {
|
|||
minLon,
|
||||
maxLon,
|
||||
minLat,
|
||||
maxLat,
|
||||
maxLat
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -316,4 +316,8 @@ export class BBox {
|
|||
public overlapsWithFeature(f: Feature) {
|
||||
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
|
||||
}
|
||||
|
||||
center() {
|
||||
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ export default class FavouritesFeatureSource extends StaticFeatureSource {
|
|||
|
||||
constructor(state: SpecialVisualizationState) {
|
||||
const features: Store<Feature[]> = Stores.ListStabilized(
|
||||
state.osmConnection.preferencesHandler.preferences.map((prefs) => {
|
||||
state.osmConnection.preferencesHandler.allPreferences.map((prefs) => {
|
||||
const feats: Feature[] = []
|
||||
const allIds = new Set<string>()
|
||||
for (const key in prefs) {
|
||||
|
|
|
@ -28,10 +28,10 @@ export class SummaryTileSourceRewriter implements FeatureSource {
|
|||
!l.layerDef.id.startsWith("note_import")
|
||||
)
|
||||
this._summarySource = summarySource
|
||||
filteredLayers.forEach((v, k) => {
|
||||
v.isDisplayed.addCallback((_) => this.update())
|
||||
filteredLayers.forEach((v) => {
|
||||
v.isDisplayed.addCallback(() => this.update())
|
||||
})
|
||||
this._summarySource.features.addCallbackAndRunD((_) => this.update())
|
||||
this._summarySource.features.addCallbackAndRunD(() => this.update())
|
||||
}
|
||||
|
||||
private update() {
|
||||
|
|
|
@ -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(12800) // => "13km"
|
||||
* GeoOperations.distanceToHuman(128000) // => "130km"
|
||||
*
|
||||
*
|
||||
* @param meters
|
||||
*/
|
||||
|
@ -918,13 +921,13 @@ export class GeoOperations {
|
|||
if (meters === undefined) {
|
||||
return ""
|
||||
}
|
||||
meters = Math.round(meters)
|
||||
meters = Utils.roundHuman( Math.round(meters))
|
||||
if (meters < 1000) {
|
||||
return meters + "m"
|
||||
return Utils.roundHuman(meters) + "m"
|
||||
}
|
||||
|
||||
if (meters >= 10000) {
|
||||
const km = Math.round(meters / 1000)
|
||||
const km = Utils.roundHuman(Math.round(meters / 1000))
|
||||
return km + "km"
|
||||
}
|
||||
|
||||
|
|
|
@ -424,9 +424,11 @@ export default class MetaTagging {
|
|||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
|
||||
)
|
||||
if (!window.location.pathname.endsWith("theme.html")) {
|
||||
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 ?? []
|
||||
if (calculatedTags === undefined || calculatedTags.length === 0) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -137,7 +137,6 @@ export class OsmConnection {
|
|||
this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser)
|
||||
|
||||
if (options.oauth_token?.data !== undefined) {
|
||||
console.log(options.oauth_token.data)
|
||||
this.auth.bootstrapToken(options.oauth_token.data, (err, result) => {
|
||||
console.log("Bootstrap token called back", err, result)
|
||||
this.AttemptLogin()
|
||||
|
@ -155,20 +154,27 @@ export class OsmConnection {
|
|||
console.log("Not authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
public GetPreference<T extends string = string>(
|
||||
key: string,
|
||||
defaultValue: string = undefined,
|
||||
options?: {
|
||||
documentation?: string
|
||||
prefix?: string
|
||||
}
|
||||
): 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> {
|
||||
return this.preferencesHandler.GetLongPreference(key, prefix)
|
||||
return this.preferencesHandler.getPreference(key, prefix)
|
||||
}
|
||||
|
||||
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
|
||||
|
@ -183,7 +189,6 @@ export class OsmConnection {
|
|||
this.userDetails.ping()
|
||||
console.log("Logged out")
|
||||
this.loadingStatus.setData("not-attempted")
|
||||
this.preferencesHandler.preferences.setData(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,333 +1,341 @@
|
|||
import { UIEventSource } from "../UIEventSource"
|
||||
import UserDetails, { OsmConnection } from "./OsmConnection"
|
||||
import { Utils } from "../../Utils"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { OsmConnection } from "./OsmConnection"
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
// @ts-ignore
|
||||
import { osmAuth } from "osm-auth"
|
||||
import OSMAuthInstance = OSMAuth.OSMAuthInstance
|
||||
import OSMAuthInstance = OSMAuth.osmAuth
|
||||
import { Utils } from "../../Utils"
|
||||
|
||||
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
|
||||
* 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
|
||||
* Contains all the keys as returned by the OSM-preferences.
|
||||
* Used to clean up old preferences
|
||||
*/
|
||||
public preferences = LocalStorageSource.GetParsed<Record<string, string>>(
|
||||
"all-osm-preferences",
|
||||
{}
|
||||
)
|
||||
/**
|
||||
* 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 seenKeys: string[] = []
|
||||
|
||||
private readonly _allPreferences: UIEventSource<Record<string, string>> = new UIEventSource({})
|
||||
public readonly allPreferences: Store<Readonly<Record<string, string>>> = this._allPreferences
|
||||
private readonly _fakeUser: boolean
|
||||
private readonly auth: OSMAuthInstance
|
||||
private readonly osmConnection: OsmConnection
|
||||
|
||||
constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) {
|
||||
this.auth = auth
|
||||
this._fakeUser = fakeUser
|
||||
this.userDetails = osmConnection.userDetails
|
||||
this.osmConnection = osmConnection
|
||||
osmConnection.OnLoggedIn(() => {
|
||||
this.UpdatePreferences(true)
|
||||
this.loadBulkPreferences()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* OSM preferences can be at most 255 chars
|
||||
* @param key
|
||||
* @param prefix
|
||||
* @constructor
|
||||
*/
|
||||
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
if (this.longPreferences[prefix + key] !== undefined) {
|
||||
return this.longPreferences[prefix + key]
|
||||
|
||||
private setPreferencesAll(key: string, value: string) {
|
||||
if (this._allPreferences.data[key] !== value) {
|
||||
this._allPreferences.data[key] = value
|
||||
this._allPreferences.ping()
|
||||
}
|
||||
|
||||
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,
|
||||
defaultValue: string = undefined,
|
||||
options?: {
|
||||
documentation?: string
|
||||
prefix?: string
|
||||
}
|
||||
prefix?: string,
|
||||
saveToLocalStorage?: true | boolean
|
||||
},
|
||||
): UIEventSource<string> {
|
||||
const prefix: string = options?.prefix ?? "mapcomplete-"
|
||||
if (key.startsWith(prefix) && prefix !== "") {
|
||||
console.trace(
|
||||
"A preference was requested which has a duplicate prefix in its key. This is probably a bug"
|
||||
)
|
||||
if (options?.prefix) {
|
||||
key = options.prefix + key
|
||||
}
|
||||
key = prefix + key
|
||||
key = key.replace(/[:\\\/"' {}.%]/g, "")
|
||||
if (key.length >= 255) {
|
||||
throw "Preferences: key length to big"
|
||||
key = key.replace(/[:/"' {}.%\\]/g, "")
|
||||
|
||||
|
||||
const localStorage = LocalStorageSource.Get(key)
|
||||
if (localStorage.data === "null" || localStorage.data === "undefined") {
|
||||
localStorage.set(undefined)
|
||||
}
|
||||
const cached = this.preferenceSources.get(key)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
if (this.userDetails.data.loggedIn && this.preferences.data[key] === undefined) {
|
||||
this.UpdatePreferences()
|
||||
let pref: UIEventSource<string> = this.initPreference(key, localStorage.data ?? defaultValue)
|
||||
if (this.localStorageInited.has(key)) {
|
||||
return pref
|
||||
}
|
||||
|
||||
const pref = new UIEventSource<string>(
|
||||
this.preferences.data[key] ?? defaultValue,
|
||||
"osm-preference:" + 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)
|
||||
if (options?.saveToLocalStorage ?? true) {
|
||||
pref.addCallback(v => localStorage.set(v)) // Keep a local copy
|
||||
}
|
||||
this.localStorageInited.add(key)
|
||||
return pref
|
||||
}
|
||||
|
||||
public ClearPreferences() {
|
||||
let isRunning = false
|
||||
const self = this
|
||||
this.preferences.addCallback((prefs) => {
|
||||
console.log("Cleaning preferences...")
|
||||
if (Object.keys(prefs).length == 0) {
|
||||
return
|
||||
console.log("Starting to remove all preferences")
|
||||
this.removeAllWithPrefix("")
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* 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) {
|
||||
return
|
||||
}
|
||||
isRunning = true
|
||||
const prefixes = ["mapcomplete-"]
|
||||
for (const key in prefs) {
|
||||
const matches = prefixes.some((prefix) => key.startsWith(prefix))
|
||||
if (matches) {
|
||||
console.log("Clearing ", key)
|
||||
self.GetPreference(key, "", { prefix: "" }).setData("")
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
return
|
||||
const partKeys = OsmPreferences.keysStartingWith(allKeys, normalKey)
|
||||
const parts = partKeys.map(k => dict[k])
|
||||
newDict[normalKey] = parts.join("")
|
||||
}
|
||||
return newDict
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all items which have a 'combined'-string, the legacy long preferences
|
||||
*
|
||||
* const input = {
|
||||
* "extra-noncombined-key":"xyz",
|
||||
* "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-0":
|
||||
* "{\"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) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
|
||||
console.log("Clearing preference", key)
|
||||
}
|
||||
}
|
||||
this.preferences.ping()
|
||||
/**
|
||||
* Returns all keys matching `k:[number]`
|
||||
* Split separately for test
|
||||
*
|
||||
* const keys = ["abc", "def", "ghi", "ghi:0", "ghi:1"]
|
||||
* OsmPreferences.keysStartingWith(keys, "xyz") // => []
|
||||
* OsmPreferences.keysStartingWith(keys, "abc") // => ["abc"]
|
||||
* 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
|
||||
if (this._fakeUser) {
|
||||
/**
|
||||
* Smart 'upload', which splits the value into `k`, `k:0`, `k:1` if needed.
|
||||
* 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
|
||||
}
|
||||
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`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.preferences.data[k] === v) {
|
||||
return
|
||||
}
|
||||
const self = this
|
||||
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15))
|
||||
if (this._fakeUser) {
|
||||
return
|
||||
}
|
||||
if (v === undefined || v === "") {
|
||||
this.auth.xhr(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
},
|
||||
function (error) {
|
||||
if (error) {
|
||||
console.warn("Could not remove preference", error)
|
||||
return
|
||||
}
|
||||
delete self.preferences.data[k]
|
||||
self.preferences.ping()
|
||||
console.debug("Preference ", k, "removed!")
|
||||
}
|
||||
)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
|
||||
this.auth.xhr(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.warn("Could not remove preference", error)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
this.auth.xhr(
|
||||
{
|
||||
method: "PUT",
|
||||
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
content: v,
|
||||
},
|
||||
function (error) {
|
||||
if (error) {
|
||||
console.warn(`Could not set preference "${k}"'`, error)
|
||||
return
|
||||
}
|
||||
self.preferences.data[k] = v
|
||||
self.preferences.ping()
|
||||
console.debug(`Preference ${k} written!`)
|
||||
}
|
||||
)
|
||||
if (this._fakeUser) {
|
||||
return
|
||||
}
|
||||
if (v === undefined || v === "" || v === null) {
|
||||
await this.deleteKeyDirectly(k)
|
||||
return
|
||||
}
|
||||
|
||||
if (v.length > 255) {
|
||||
console.error("Preference too long, max 255 chars", { k, v })
|
||||
throw "Preference too long, at most 255 characters are supported"
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
|
||||
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]
|
||||
|
||||
}
|
||||
}
|
||||
|
|
55
src/Logic/Search/CombinedSearcher.ts
Normal 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))
|
||||
|
||||
}
|
||||
}
|
98
src/Logic/Search/CoordinateSearch.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
131
src/Logic/Search/FilterSearch.ts
Normal 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)
|
||||
}
|
||||
}
|
45
src/Logic/Search/GeocodingFeatureSource.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
}
|
155
src/Logic/Search/GeocodingProvider.ts
Normal 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",
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
53
src/Logic/Search/LayerSearch.ts
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
124
src/Logic/Search/LocalElementSearch.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
41
src/Logic/Search/NominatimGeocoding.ts
Normal 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)
|
||||
}
|
||||
}
|
96
src/Logic/Search/OpenStreetMapIdSearch.ts
Normal 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))
|
||||
}
|
||||
|
||||
}
|
148
src/Logic/Search/PhotonSearch.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
75
src/Logic/Search/SearchUtils.ts
Normal 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
|
||||
}
|
||||
}
|
150
src/Logic/Search/ThemeSearch.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,6 @@ import { UIEventSource } from "../UIEventSource"
|
|||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { Utils } from "../../Utils"
|
||||
import { Query } from "pg"
|
||||
import { eliCategory } from "../../Models/RasterLayerProperties"
|
||||
import { AvailableRasterLayers } from "../../Models/RasterLayers"
|
||||
import MarkdownUtils from "../../Utils/MarkdownUtils"
|
||||
|
|
|
@ -7,7 +7,14 @@ import { Tag } from "../Tags/Tag"
|
|||
import Translations from "../../UI/i18n/Translations"
|
||||
import { RegexTag } from "../Tags/RegexTag"
|
||||
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:
|
||||
* - 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
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -56,6 +70,52 @@ export default class LayerState {
|
|||
}
|
||||
this.filteredLayers = 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
149
src/Logic/State/SearchState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
|
||||
import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { MangroveIdentity } from "../Web/MangroveReviews"
|
||||
import { Store, Stores, UIEventSource } from "../UIEventSource"
|
||||
|
@ -19,6 +19,101 @@ import { QueryParameters } from "../Web/QueryParameters"
|
|||
import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
|
||||
import { MapProperties } from "../../Models/MapProperties"
|
||||
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,
|
||||
|
@ -62,7 +157,7 @@ export default class UserRelatedState {
|
|||
*/
|
||||
public readonly gpsLocationHistoryRetentionTime = new UIEventSource(
|
||||
7 * 24 * 60 * 60,
|
||||
"gps_location_retention"
|
||||
"gps_location_retention",
|
||||
)
|
||||
|
||||
public readonly addNewFeatureMode = new UIEventSource<
|
||||
|
@ -82,61 +177,62 @@ export default class UserRelatedState {
|
|||
public readonly preferencesAsTags: UIEventSource<Record<string, string>>
|
||||
private readonly _mapProperties: MapProperties
|
||||
|
||||
public readonly recentlyVisitedThemes: OptionallySyncedHistory<string>
|
||||
public readonly recentlyVisitedSearch: OptionallySyncedHistory<GeocodeResult>
|
||||
|
||||
|
||||
constructor(
|
||||
osmConnection: OsmConnection,
|
||||
layout?: LayoutConfig,
|
||||
featureSwitches?: FeatureSwitchState,
|
||||
mapProperties?: MapProperties
|
||||
mapProperties?: MapProperties,
|
||||
) {
|
||||
this.osmConnection = osmConnection
|
||||
this._mapProperties = mapProperties
|
||||
|
||||
this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
|
||||
this.osmConnection.GetPreference("show-all-questions", "false", {
|
||||
documentation:
|
||||
"Either 'true' or 'false'. If set, all questions will be shown all at once",
|
||||
})
|
||||
this.osmConnection.getPreference("show-all-questions", "false"),
|
||||
)
|
||||
this.language = this.osmConnection.GetPreference("language")
|
||||
this.showTags = this.osmConnection.GetPreference("show_tags")
|
||||
this.showCrosshair = this.osmConnection.GetPreference("show_crosshair")
|
||||
this.fixateNorth = this.osmConnection.GetPreference("fixate-north")
|
||||
this.morePrivacy = this.osmConnection.GetPreference("more_privacy", "no")
|
||||
this.language = this.osmConnection.getPreference("language")
|
||||
this.showTags = this.osmConnection.getPreference("show_tags")
|
||||
this.showCrosshair = this.osmConnection.getPreference("show_crosshair")
|
||||
this.fixateNorth = this.osmConnection.getPreference("fixate-north")
|
||||
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.osmConnection.GetLongPreference("identity", "mangrove"),
|
||||
this.osmConnection.GetPreference("identity-creation-date", "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.osmConnection.getPreference("identity", undefined,"mangrove"),
|
||||
this.osmConnection.getPreference("identity-creation-date", undefined,"mangrove"),
|
||||
)
|
||||
this.preferredBackgroundLayer = this.osmConnection.getPreference("preferred-background-layer")
|
||||
|
||||
this.addNewFeatureMode = this.osmConnection.GetPreference(
|
||||
this.addNewFeatureMode = this.osmConnection.getPreference(
|
||||
"preferences-add-new-mode",
|
||||
"button_click_right",
|
||||
{
|
||||
documentation: "How adding a new feature is done",
|
||||
}
|
||||
)
|
||||
this.showScale = UIEventSource.asBoolean(this.osmConnection.GetPreference("preference-show-scale","false"))
|
||||
|
||||
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
|
||||
documentation: "The license under which new images are uploaded",
|
||||
})
|
||||
this.installedUserThemes = this.InitInstalledUserThemes()
|
||||
this.imageLicense = this.osmConnection.getPreference("pictures-license", "CC0")
|
||||
this.installedUserThemes = UserRelatedState.initInstalledUserThemes(osmConnection)
|
||||
this.translationMode = this.initTranslationMode()
|
||||
this.homeLocation = this.initHomeLocation()
|
||||
|
||||
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.recentlyVisitedThemes.addDefferred(layout?.id)
|
||||
}
|
||||
|
||||
private syncLanguage() {
|
||||
|
@ -149,7 +245,7 @@ export default class UserRelatedState {
|
|||
|
||||
private initTranslationMode(): UIEventSource<"false" | "true" | "mobile" | undefined | string> {
|
||||
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
|
||||
this.osmConnection.GetPreference("translation-mode", "false")
|
||||
this.osmConnection.getPreference("translation-mode", "false")
|
||||
translationMode.addCallbackAndRunD((mode) => {
|
||||
mode = mode.toLowerCase()
|
||||
if (mode === "true" || mode === "yes") {
|
||||
|
@ -176,17 +272,7 @@ export default class UserRelatedState {
|
|||
}
|
||||
}
|
||||
|
||||
public GetUnofficialTheme(id: string):
|
||||
| {
|
||||
id: string
|
||||
icon: string
|
||||
title: any
|
||||
shortDescription: any
|
||||
definition?: any
|
||||
isOfficial: boolean
|
||||
}
|
||||
| undefined {
|
||||
console.log("GETTING UNOFFICIAL THEME")
|
||||
public getUnofficialTheme(id: string): (MinimalLayoutInformation & { definition }) | undefined {
|
||||
const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id)
|
||||
const str = pref.data
|
||||
|
||||
|
@ -196,22 +282,13 @@ export default class UserRelatedState {
|
|||
}
|
||||
|
||||
try {
|
||||
const value: {
|
||||
id: string
|
||||
icon: string
|
||||
title: any
|
||||
shortDescription: any
|
||||
definition?: any
|
||||
isOfficial: boolean
|
||||
} = JSON.parse(str)
|
||||
value.isOfficial = false
|
||||
return value
|
||||
return <MinimalLayoutInformation & { definition: string }>JSON.parse(str)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"Removing theme " +
|
||||
id +
|
||||
" as it could not be parsed from the preferences; the content is:",
|
||||
str
|
||||
id +
|
||||
" as it could not be parsed from the preferences; the content is:",
|
||||
str,
|
||||
)
|
||||
pref.setData(null)
|
||||
return undefined
|
||||
|
@ -227,7 +304,7 @@ export default class UserRelatedState {
|
|||
this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
|
||||
if (loggedIn) {
|
||||
this.osmConnection
|
||||
.GetPreference("hidden-theme-" + layout?.id + "-enabled")
|
||||
.getPreference("hidden-theme-" + layout?.id + "-enabled")
|
||||
.setData("true")
|
||||
return true
|
||||
}
|
||||
|
@ -241,18 +318,31 @@ export default class UserRelatedState {
|
|||
title: layout.title.translations,
|
||||
shortDescription: layout.shortDescription.translations,
|
||||
definition: layout["definition"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private InitInstalledUserThemes(): Store<string[]> {
|
||||
public static initInstalledUserThemes(osmConnection: OsmConnection): Store<string[]> {
|
||||
const prefix = "mapcomplete-unofficial-theme-"
|
||||
const postfix = "-combined-length"
|
||||
return this.osmConnection.preferencesHandler.preferences.map((prefs) =>
|
||||
return osmConnection.preferencesHandler.allPreferences.map((prefs) =>
|
||||
Object.keys(prefs)
|
||||
.filter((k) => k.startsWith(prefix) && k.endsWith(postfix))
|
||||
.map((k) => k.substring(prefix.length, k.length - postfix.length))
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.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 [home.lon, home.lat]
|
||||
})
|
||||
}),
|
||||
).map((homeLonLat) => {
|
||||
if (homeLonLat === undefined) {
|
||||
return empty
|
||||
|
@ -298,7 +388,7 @@ export default class UserRelatedState {
|
|||
* */
|
||||
private initAmendedPrefs(
|
||||
layout?: LayoutConfig,
|
||||
featureSwitches?: FeatureSwitchState
|
||||
featureSwitches?: FeatureSwitchState,
|
||||
): UIEventSource<Record<string, string>> {
|
||||
const amendedPrefs = new UIEventSource<Record<string, string>>({
|
||||
_theme: layout?.id,
|
||||
|
@ -319,23 +409,13 @@ export default class UserRelatedState {
|
|||
}
|
||||
|
||||
const osmConnection = this.osmConnection
|
||||
osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => {
|
||||
osmConnection.preferencesHandler.allPreferences.addCallback((newPrefs) => {
|
||||
for (const k in newPrefs) {
|
||||
const v = newPrefs[k]
|
||||
if (v === "undefined" || !v) {
|
||||
if (v === "undefined" || v === "null" || !v) {
|
||||
continue
|
||||
}
|
||||
if (k.endsWith("-combined-length")) {
|
||||
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.data[k] = newPrefs[k] ?? ""
|
||||
}
|
||||
|
||||
amendedPrefs.ping()
|
||||
|
@ -354,19 +434,19 @@ export default class UserRelatedState {
|
|||
const missingLayers = Utils.Dedup(
|
||||
untranslated
|
||||
.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([
|
||||
hasMissingTheme
|
||||
? {
|
||||
id: "theme:" + layout.id,
|
||||
link: LinkToWeblate.hrefToWeblateZen(
|
||||
language,
|
||||
"themes",
|
||||
layout.id
|
||||
),
|
||||
}
|
||||
id: "theme:" + layout.id,
|
||||
link: LinkToWeblate.hrefToWeblateZen(
|
||||
language,
|
||||
"themes",
|
||||
layout.id,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
...missingLayers.map((id) => ({
|
||||
id: "layer:" + id,
|
||||
|
@ -383,7 +463,7 @@ export default class UserRelatedState {
|
|||
}
|
||||
amendedPrefs.ping()
|
||||
},
|
||||
[this.translationMode]
|
||||
[this.translationMode],
|
||||
)
|
||||
|
||||
this.mangroveIdentity.getKeyId().addCallbackAndRun((kid) => {
|
||||
|
@ -402,7 +482,7 @@ export default class UserRelatedState {
|
|||
.makeHtml(userDetails.description)
|
||||
?.replace(/>/g, ">")
|
||||
?.replace(/</g, "<")
|
||||
?.replace(/\n/g, "")
|
||||
?.replace(/\n/g, ""),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -413,7 +493,7 @@ export default class UserRelatedState {
|
|||
(c: { contributor: string; commits: number }) => {
|
||||
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
|
||||
return replaced === simplifiedName
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isTranslator) {
|
||||
amendedPrefs.data["_translation_contributions"] = "" + isTranslator.commits
|
||||
|
@ -422,7 +502,7 @@ export default class UserRelatedState {
|
|||
(c: { contributor: string; commits: number }) => {
|
||||
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
|
||||
return replaced === simplifiedName
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isCodeContributor) {
|
||||
amendedPrefs.data["_code_contributions"] = "" + isCodeContributor.commits
|
||||
|
@ -433,17 +513,15 @@ export default class UserRelatedState {
|
|||
amendedPrefs.addCallbackD((tags) => {
|
||||
for (const key in tags) {
|
||||
if (key.startsWith("_") || key === "mapcomplete-language") {
|
||||
// Language is managed seperately
|
||||
// Language is managed separately
|
||||
continue
|
||||
}
|
||||
if (tags[key + "-combined-0"]) {
|
||||
// A combined value exists
|
||||
this.osmConnection.GetLongPreference(key, "").setData(tags[key])
|
||||
} else {
|
||||
this.osmConnection
|
||||
.GetPreference(key, undefined, { prefix: "" })
|
||||
.setData(tags[key])
|
||||
if(tags[key] === null){
|
||||
continue
|
||||
}
|
||||
let pref = this.osmConnection.GetPreference(key, undefined, {prefix: ""})
|
||||
|
||||
pref.set(tags[key])
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -41,8 +41,25 @@ export class Stores {
|
|||
return src
|
||||
}
|
||||
|
||||
public static flatten<X>(source: Store<Store<X>>, possibleSources?: Store<any>[]): Store<X> {
|
||||
return UIEventSource.flatten(source, possibleSources)
|
||||
public static concat<T>(stores: Store<T[] | undefined>[]): Store<(T[] | undefined)[]> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* 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> {
|
||||
|
@ -104,7 +135,7 @@ export abstract class Store<T> implements Readable<T> {
|
|||
extraStoresToWatch: Store<any>[],
|
||||
callbackDestroyFunction: (f: () => void) => void
|
||||
): Store<J>
|
||||
M
|
||||
|
||||
public mapD<J>(
|
||||
f: (t: Exclude<T, undefined | null>) => J,
|
||||
extraStoresToWatch?: Store<any>[],
|
||||
|
@ -118,7 +149,7 @@ export abstract class Store<T> implements Readable<T> {
|
|||
return null
|
||||
}
|
||||
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)
|
||||
* lastValue // => "def"
|
||||
*/
|
||||
public bind<X>(f: (t: T) => Store<X>): Store<X> {
|
||||
const mapped = this.map(f)
|
||||
public bind<X>(f: (t: T) => Store<X>, extraSources: Store<object>[] = []): Store<X> {
|
||||
const mapped = this.map(f, extraSources)
|
||||
const sink = new UIEventSource<X>(undefined)
|
||||
const seenEventSources = new Set<Store<X>>()
|
||||
mapped.addCallbackAndRun((newEventSource) => {
|
||||
|
@ -229,13 +260,16 @@ export abstract class Store<T> implements Readable<T> {
|
|||
if (mapped.data === newEventSource) {
|
||||
sink.setData(resultData)
|
||||
}
|
||||
if (sink._callbacks.isDestroyed) {
|
||||
return true // unregister
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
if (t === null) {
|
||||
return null
|
||||
|
@ -244,8 +278,9 @@ export abstract class Store<T> implements Readable<T> {
|
|||
return undefined
|
||||
}
|
||||
return f(<Exclude<T, undefined | null>>t)
|
||||
})
|
||||
}, extraSources)
|
||||
}
|
||||
|
||||
public stabilized(millisToStabilize): Store<T> {
|
||||
if (Utils.runningFromConsole) {
|
||||
return this
|
||||
|
@ -305,18 +340,22 @@ export abstract class Store<T> implements Readable<T> {
|
|||
run(v)
|
||||
})
|
||||
}
|
||||
|
||||
public abstract destroy()
|
||||
}
|
||||
|
||||
export class ImmutableStore<T> extends Store<T> {
|
||||
public readonly data: T
|
||||
static FALSE = new ImmutableStore<boolean>(false)
|
||||
static TRUE = new ImmutableStore<boolean>(true)
|
||||
|
||||
constructor(data: T) {
|
||||
super()
|
||||
this.data = data
|
||||
}
|
||||
|
||||
private static readonly pass: () => void = () => {}
|
||||
private static readonly pass: () => void = () => {
|
||||
}
|
||||
|
||||
addCallback(_: (data: T) => void): () => void {
|
||||
// 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> {
|
||||
return f(this.data)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -364,6 +407,7 @@ export class ImmutableStore<T> extends Store<T> {
|
|||
class ListenerTracker<T> {
|
||||
public pingCount = 0
|
||||
private readonly _callbacks: ((t: T) => boolean | void | any)[] = []
|
||||
public isDestroyed = false
|
||||
|
||||
/**
|
||||
* Adds a callback which can be called; a function to unregister is returned
|
||||
|
@ -424,6 +468,11 @@ class ListenerTracker<T> {
|
|||
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._callbacks.ping(this._data)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unregisterFromUpstream()
|
||||
}
|
||||
}
|
||||
|
||||
export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
||||
private static readonly pass: () => {}
|
||||
private static readonly pass: (() => void) = () => {
|
||||
}
|
||||
public data: T
|
||||
_callbacks: ListenerTracker<T> = new ListenerTracker<T>()
|
||||
|
||||
|
@ -591,9 +645,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
this.data = data
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this._callbacks.destroy()
|
||||
}
|
||||
|
||||
public static flatten<X>(
|
||||
source: Store<Store<X>>,
|
||||
possibleSources?: Store<any>[]
|
||||
possibleSources?: Store<object>[]
|
||||
): UIEventSource<X> {
|
||||
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
|
||||
*/
|
||||
public static FromPromise<T>(
|
||||
promise: Promise<T>,
|
||||
onError: (e: any) => void = undefined
|
||||
onError: (e) => void = undefined
|
||||
): UIEventSource<T> {
|
||||
const src = new UIEventSource<T>(undefined)
|
||||
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> {
|
||||
return source.sync(
|
||||
(str) => {
|
||||
let parsed = parseInt(str)
|
||||
const parsed = parseInt(str)
|
||||
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> {
|
||||
return source.sync(
|
||||
(str) => {
|
||||
let parsed = parseFloat(str)
|
||||
const parsed = parseFloat(str)
|
||||
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.
|
||||
* 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 update = function () {
|
||||
const update = function() {
|
||||
newSource.setData(f(self.data))
|
||||
return allowUnregister && newSource._callbacks.length() === 0
|
||||
}
|
||||
|
|
|
@ -22,6 +22,9 @@ export class MangroveIdentity {
|
|||
this.mangroveIdentity = mangroveIdentity
|
||||
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
|
||||
mangroveIdentity.addCallbackAndRunD(async (data) => {
|
||||
if(data === ""){
|
||||
return
|
||||
}
|
||||
await this.setKeypair(data)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Utils } from "../Utils"
|
|||
import { AuthConfig } from "../Logic/Osm/AuthConfig"
|
||||
|
||||
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
|
||||
export type DefaultPinIcon = (typeof Constants._defaultPinIcons)[number]
|
||||
|
||||
export default class Constants {
|
||||
public static vNumber: string = packagefile.version
|
||||
|
@ -25,6 +26,7 @@ export default class Constants {
|
|||
"last_click",
|
||||
"favourite",
|
||||
"summary",
|
||||
"search",
|
||||
"geocoded_image"
|
||||
] as const
|
||||
/**
|
||||
|
@ -126,14 +128,19 @@ export default class Constants {
|
|||
public static countryCoderEndpoint: string = Constants.config.country_coder_host
|
||||
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
|
||||
public static nominatimEndpoint: string = Constants.config.nominatimEndpoint
|
||||
public static photonEndpoint: string = Constants.config.photonEndpoint
|
||||
|
||||
public static linkedDataProxy: string = Constants.config["jsonld-proxy"]
|
||||
/**
|
||||
* 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",
|
||||
"airport",
|
||||
"brick_wall_round",
|
||||
"brick_wall_square",
|
||||
"building_office_2",
|
||||
"building_storefront",
|
||||
"bug",
|
||||
"checkmark",
|
||||
"checkmark",
|
||||
|
@ -148,12 +155,14 @@ export default class Constants {
|
|||
"desktop",
|
||||
"direction",
|
||||
"gear",
|
||||
"globe_alt",
|
||||
"gps_arrow",
|
||||
"heart",
|
||||
"heart_outline",
|
||||
"help",
|
||||
"help",
|
||||
"home",
|
||||
"house",
|
||||
"key",
|
||||
"invalid",
|
||||
"invalid",
|
||||
|
@ -175,7 +184,9 @@ export default class Constants {
|
|||
"square_rounded",
|
||||
"teardrop",
|
||||
"teardrop_with_hole_green",
|
||||
"train",
|
||||
"triangle",
|
||||
"user_circle",
|
||||
"wifi",
|
||||
] as const
|
||||
public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons
|
||||
|
@ -198,6 +209,7 @@ export default class Constants {
|
|||
Constants.countryCoderEndpoint,
|
||||
Constants.osmAuthConfig.url,
|
||||
Constants.nominatimEndpoint,
|
||||
Constants.photonEndpoint,
|
||||
Constants.linkedDataProxy,
|
||||
...Constants.defaultOverpassUrls,
|
||||
]
|
||||
|
|
|
@ -30,6 +30,9 @@ export interface MapProperties {
|
|||
* @param f
|
||||
*/
|
||||
onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean): () => void
|
||||
|
||||
flyTo(lon: number, lat: number, zoom: number): void
|
||||
|
||||
}
|
||||
|
||||
export interface ExportableMap {
|
||||
|
|
|
@ -23,7 +23,7 @@ export class AvailableRasterLayers {
|
|||
const eli = await Utils.downloadJson<{ features: EditorLayerIndex }>(
|
||||
"./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)
|
||||
return this._editorLayerIndex
|
||||
}
|
||||
|
|
|
@ -10,10 +10,7 @@ import {
|
|||
SetDefault,
|
||||
} from "./Conversion"
|
||||
import { LayerConfigJson } from "../Json/LayerConfigJson"
|
||||
import {
|
||||
MinimalTagRenderingConfigJson,
|
||||
TagRenderingConfigJson,
|
||||
} from "../Json/TagRenderingConfigJson"
|
||||
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
|
||||
import { Utils } from "../../../Utils"
|
||||
import RewritableConfigJson from "../Json/RewritableConfigJson"
|
||||
import SpecialVisualizations from "../../../UI/SpecialVisualizations"
|
||||
|
@ -21,7 +18,7 @@ import Translations from "../../../UI/i18n/Translations"
|
|||
import { Translation } from "../../../UI/i18n/Translation"
|
||||
import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json"
|
||||
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 { TagConfigJson } from "../Json/TagConfigJson"
|
||||
import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson"
|
||||
|
@ -34,15 +31,81 @@ import { ConversionContext } from "./ConversionContext"
|
|||
import { ExpandRewrite } from "./ExpandRewrite"
|
||||
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> {
|
||||
private static readonly predefinedFilters = ExpandFilter.load_filters()
|
||||
private _state: DesugaringContext
|
||||
|
||||
constructor(state: DesugaringContext) {
|
||||
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"],
|
||||
"ExpandFilter"
|
||||
"ExpandFilter",
|
||||
)
|
||||
this._state = state
|
||||
}
|
||||
|
@ -55,6 +118,38 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
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 {
|
||||
if (json?.filter === undefined || json?.filter === null) {
|
||||
return json // Nothing to change here
|
||||
|
@ -64,36 +159,14 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
return json // Nothing to change here
|
||||
}
|
||||
|
||||
|
||||
const newFilters: FilterConfigJson[] = []
|
||||
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++) {
|
||||
const filter = filters[i]
|
||||
if (filter === undefined) {
|
||||
|
@ -108,53 +181,34 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
json.tagRenderings.find((tr) => !!tr && tr["id"] === filter)
|
||||
)
|
||||
if (matchingTr) {
|
||||
if (!(matchingTr.mappings?.length >= 1)) {
|
||||
context
|
||||
.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,
|
||||
})
|
||||
const filter = ExpandFilter.buildFilterFromTagRendering(matchingTr, context.enters("filter", i))
|
||||
newFilters.push(filter)
|
||||
continue
|
||||
}
|
||||
|
||||
if (filter.indexOf(".") > 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 {
|
||||
if (!(this._state.sharedLayers?.size > 0)) {
|
||||
// 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
|
||||
}
|
||||
|
@ -164,15 +218,15 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
const suggestions = Utils.sortedByLevenshteinDistance(
|
||||
filter,
|
||||
Array.from(ExpandFilter.predefinedFilters.keys()),
|
||||
(t) => t
|
||||
(t) => t,
|
||||
)
|
||||
context
|
||||
.enter(filter)
|
||||
.err(
|
||||
"While searching for predefined filter " +
|
||||
filter +
|
||||
": this filter is not found. Perhaps you meant one of: " +
|
||||
suggestions
|
||||
filter +
|
||||
": this filter is not found. Perhaps you meant one of: " +
|
||||
suggestions,
|
||||
)
|
||||
}
|
||||
newFilters.push(found)
|
||||
|
@ -185,9 +239,9 @@ class ExpandTagRendering extends Conversion<
|
|||
| string
|
||||
| TagRenderingConfigJson
|
||||
| {
|
||||
builtin: string | string[]
|
||||
override: any
|
||||
},
|
||||
builtin: string | string[]
|
||||
override: any
|
||||
},
|
||||
TagRenderingConfigJson[]
|
||||
> {
|
||||
private readonly _state: DesugaringContext
|
||||
|
@ -209,12 +263,12 @@ class ExpandTagRendering extends Conversion<
|
|||
noHardcodedStrings?: false | boolean
|
||||
// If set, a question will be added to the 'sharedTagRenderings'. Should only be used for 'questions.json'
|
||||
addToContext?: false | boolean
|
||||
}
|
||||
},
|
||||
) {
|
||||
super(
|
||||
"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._self = self
|
||||
|
@ -234,7 +288,7 @@ class ExpandTagRendering extends Conversion<
|
|||
|
||||
public convert(
|
||||
spec: string | any,
|
||||
ctx: ConversionContext
|
||||
ctx: ConversionContext,
|
||||
): QuestionableTagRenderingConfigJson[] {
|
||||
const trs = this.convertOnce(spec, ctx)
|
||||
|
||||
|
@ -347,8 +401,8 @@ class ExpandTagRendering extends Conversion<
|
|||
found,
|
||||
ConversionContext.construct(
|
||||
[layer.id, "tagRenderings", found["id"]],
|
||||
["AddContextToTranslations"]
|
||||
)
|
||||
["AddContextToTranslations"],
|
||||
),
|
||||
)
|
||||
matchingTrs[i] = found
|
||||
}
|
||||
|
@ -376,17 +430,17 @@ class ExpandTagRendering extends Conversion<
|
|||
ctx.warn(
|
||||
`A literal rendering was detected: ${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) {
|
||||
ctx.err(
|
||||
"Detected an invocation to a builtin tagRendering, but this tagrendering was not found: " +
|
||||
tr +
|
||||
" \n Did you perhaps forget to add the layer as prefix, such as `icons." +
|
||||
tr +
|
||||
"`? "
|
||||
tr +
|
||||
" \n Did you perhaps forget to add the layer as prefix, such as `icons." +
|
||||
tr +
|
||||
"`? ",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -421,9 +475,9 @@ class ExpandTagRendering extends Conversion<
|
|||
}
|
||||
ctx.err(
|
||||
"An object calling a builtin can only have keys `builtin` or `override`, but a key with name `" +
|
||||
key +
|
||||
"` was found. This won't be picked up! The full object is: " +
|
||||
JSON.stringify(tr)
|
||||
key +
|
||||
"` was found. This won't be picked up! The full object is: " +
|
||||
JSON.stringify(tr),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -442,39 +496,39 @@ class ExpandTagRendering extends Conversion<
|
|||
const candidates = Utils.sortedByLevenshteinDistance(
|
||||
layerName,
|
||||
Array.from(state.sharedLayers.keys()),
|
||||
(s) => s
|
||||
(s) => s,
|
||||
)
|
||||
if (state.sharedLayers.size === 0) {
|
||||
ctx.warn(
|
||||
"BOOTSTRAPPING. Rerun generate layeroverview. While reusing tagrendering: " +
|
||||
name +
|
||||
": layer " +
|
||||
layerName +
|
||||
" not found for now, but ignoring as this is a bootstrapping run. "
|
||||
name +
|
||||
": layer " +
|
||||
layerName +
|
||||
" not found for now, but ignoring as this is a bootstrapping run. ",
|
||||
)
|
||||
} else {
|
||||
ctx.err(
|
||||
": While reusing tagrendering: " +
|
||||
name +
|
||||
": layer " +
|
||||
layerName +
|
||||
" not found. Maybe you meant one of " +
|
||||
candidates.slice(0, 3).join(", ")
|
||||
name +
|
||||
": layer " +
|
||||
layerName +
|
||||
" not found. Maybe you meant one of " +
|
||||
candidates.slice(0, 3).join(", "),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidates = Utils.NoNull(layer.tagRenderings.map((tr) => tr["id"])).map(
|
||||
(id) => layerName + "." + id
|
||||
(id) => layerName + "." + id,
|
||||
)
|
||||
}
|
||||
candidates = Utils.sortedByLevenshteinDistance(name, candidates, (i) => i)
|
||||
ctx.err(
|
||||
"The tagRendering with identifier " +
|
||||
name +
|
||||
" was not found.\n\tDid you mean one of " +
|
||||
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"
|
||||
name +
|
||||
" was not found.\n\tDid you mean one of " +
|
||||
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",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -499,13 +553,13 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
|
|||
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",
|
||||
["freeform.inline"],
|
||||
"DetectInline"
|
||||
"DetectInline",
|
||||
)
|
||||
}
|
||||
|
||||
convert(
|
||||
json: QuestionableTagRenderingConfigJson,
|
||||
context: ConversionContext
|
||||
context: ConversionContext,
|
||||
): QuestionableTagRenderingConfigJson {
|
||||
if (json.freeform === undefined) {
|
||||
return json
|
||||
|
@ -528,7 +582,7 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
|
|||
if (json.freeform.inline === true) {
|
||||
context.err(
|
||||
"'inline' is set, but the rendering contains a special visualisation...\n " +
|
||||
spec[key]
|
||||
spec[key],
|
||||
)
|
||||
}
|
||||
json = JSON.parse(JSON.stringify(json))
|
||||
|
@ -558,7 +612,7 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
|
|||
super(
|
||||
"Adds a 'questions'-object if no question element is added yet",
|
||||
["tagRenderings"],
|
||||
"AddQuestionBox"
|
||||
"AddQuestionBox",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -582,18 +636,18 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
|
|||
json.tagRenderings = [...json.tagRenderings]
|
||||
const allSpecials: Exclude<RenderingSpecification, string>[] = <any>(
|
||||
ValidationUtils.getAllSpecialVisualisations(
|
||||
<QuestionableTagRenderingConfigJson[]>json.tagRenderings
|
||||
<QuestionableTagRenderingConfigJson[]>json.tagRenderings,
|
||||
).filter((spec) => typeof spec !== "string")
|
||||
)
|
||||
|
||||
const questionSpecials = allSpecials.filter((sp) => sp.func.funcName === "questions")
|
||||
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) {
|
||||
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(
|
||||
[].concat(
|
||||
...json.tagRenderings.map(
|
||||
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? []
|
||||
)
|
||||
)
|
||||
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const seen: Set<string> = new Set()
|
||||
for (const questionSpecial of questionSpecials) {
|
||||
|
@ -621,20 +675,20 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
|
|||
if (blacklisted?.length > 0 && used?.length > 0) {
|
||||
context.err(
|
||||
"The {questions()}-special rendering only supports either a blacklist OR a whitelist, but not both." +
|
||||
"\n Whitelisted: " +
|
||||
used.join(", ") +
|
||||
"\n Blacklisted: " +
|
||||
blacklisted.join(", ")
|
||||
"\n Whitelisted: " +
|
||||
used.join(", ") +
|
||||
"\n Blacklisted: " +
|
||||
blacklisted.join(", "),
|
||||
)
|
||||
}
|
||||
for (const usedLabel of used) {
|
||||
if (!allLabels.has(usedLabel)) {
|
||||
context.err(
|
||||
"This layers specifies a special question element for label `" +
|
||||
usedLabel +
|
||||
"`, but this label doesn't exist.\n" +
|
||||
" Available labels are " +
|
||||
Array.from(allLabels).join(", ")
|
||||
usedLabel +
|
||||
"`, but this label doesn't exist.\n" +
|
||||
" Available labels are " +
|
||||
Array.from(allLabels).join(", "),
|
||||
)
|
||||
}
|
||||
seen.add(usedLabel)
|
||||
|
@ -667,7 +721,7 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
|
|||
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",
|
||||
[],
|
||||
"AddEditingElements"
|
||||
"AddEditingElements",
|
||||
)
|
||||
this._desugaring = desugaring
|
||||
this.builtinQuestions = Array.from(this._desugaring.tagRenderings?.values() ?? [])
|
||||
|
@ -697,13 +751,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
|
|||
json.tagRenderings = [...(json.tagRenderings ?? [])]
|
||||
const allIds = new Set<string>(json.tagRenderings.map((tr) => tr["id"]))
|
||||
const specialVisualisations = ValidationUtils.getAllSpecialVisualisations(
|
||||
<any>json.tagRenderings
|
||||
<any>json.tagRenderings,
|
||||
)
|
||||
|
||||
const usedSpecialFunctions = new Set(
|
||||
specialVisualisations.map((sv) =>
|
||||
typeof sv === "string" ? undefined : sv.func.funcName
|
||||
)
|
||||
typeof sv === "string" ? undefined : sv.func.funcName,
|
||||
),
|
||||
)
|
||||
|
||||
/***** ADD TO TOP ****/
|
||||
|
@ -771,7 +825,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
super(
|
||||
"Converts a 'special' translation into a regular translation which uses parameters",
|
||||
["special"],
|
||||
"RewriteSpecial"
|
||||
"RewriteSpecial",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -862,12 +916,12 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
private static convertIfNeeded(
|
||||
input:
|
||||
| (object & {
|
||||
special: {
|
||||
type: string
|
||||
}
|
||||
})
|
||||
special: {
|
||||
type: string
|
||||
}
|
||||
})
|
||||
| any,
|
||||
context: ConversionContext
|
||||
context: ConversionContext,
|
||||
): any {
|
||||
const special = input["special"]
|
||||
if (special === undefined) {
|
||||
|
@ -877,7 +931,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
const type = special["type"]
|
||||
if (type === undefined) {
|
||||
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
|
||||
}
|
||||
|
@ -887,10 +941,10 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
const options = Utils.sortedByLevenshteinDistance(
|
||||
type,
|
||||
SpecialVisualizations.specialVisualizations,
|
||||
(sp) => sp.funcName
|
||||
(sp) => sp.funcName,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
@ -911,7 +965,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
const byDistance = Utils.sortedByLevenshteinDistance(
|
||||
wrongArg,
|
||||
argNamesList,
|
||||
(x) => x
|
||||
(x) => x,
|
||||
)
|
||||
return `Unexpected argument in special block at ${context} with name '${wrongArg}'. Did you mean ${
|
||||
byDistance[0]
|
||||
|
@ -930,8 +984,8 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
`Obligated parameter '${arg.name}' in special rendering of type ${
|
||||
vis.funcName
|
||||
} not found.\n The full special rendering specification is: '${JSON.stringify(
|
||||
input
|
||||
)}'\n ${arg.name}: ${arg.doc}`
|
||||
input,
|
||||
)}'\n ${arg.name}: ${arg.doc}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1033,7 +1087,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
|
|||
continue
|
||||
}
|
||||
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]
|
||||
const expanded = this._expand.convert(
|
||||
<QuestionableTagRenderingConfigJson>iconBadge.then,
|
||||
context.enters("iconBadges", i)
|
||||
context.enters("iconBadges", i),
|
||||
)
|
||||
if (expanded === undefined) {
|
||||
iconBadges.push(iconBadge)
|
||||
|
@ -1078,7 +1132,7 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
|
|||
...expanded.map((resolved) => ({
|
||||
if: iconBadge.if,
|
||||
then: <MinimalTagRenderingConfigJson>resolved,
|
||||
}))
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1095,11 +1149,11 @@ class PreparePointRendering extends Fuse<PointRenderingConfigJson> {
|
|||
new Each(
|
||||
new On(
|
||||
"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(
|
||||
"sets the fullNodeDatabase-bit if needed",
|
||||
["fullNodeDatabase"],
|
||||
"SetFullNodeDatabase"
|
||||
"SetFullNodeDatabase",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1138,7 +1192,7 @@ class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> {
|
|||
super(
|
||||
"Expands tagRenderings in the icons, if needed",
|
||||
["icon", "color"],
|
||||
"ExpandMarkerRenderings"
|
||||
"ExpandMarkerRenderings",
|
||||
)
|
||||
this._layer = layer
|
||||
this._state = state
|
||||
|
@ -1170,7 +1224,7 @@ class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> {
|
|||
super(
|
||||
"Adds the favourite heart to the title and the rendering badges",
|
||||
[],
|
||||
"AddFavouriteBadges"
|
||||
"AddFavouriteBadges",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1195,7 +1249,7 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
|
|||
super(
|
||||
"Adds the 'rating'-element if a reviews-element is used in the tagRenderings",
|
||||
["titleIcons"],
|
||||
"AddRatingBadge"
|
||||
"AddRatingBadge",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1214,8 +1268,8 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
|
|||
|
||||
const specialVis: Exclude<RenderingSpecification, string>[] = <
|
||||
Exclude<RenderingSpecification, string>[]
|
||||
>ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter(
|
||||
(rs) => typeof rs !== "string"
|
||||
>ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter(
|
||||
(rs) => typeof rs !== "string",
|
||||
)
|
||||
const funcs = new Set<string>(specialVis.map((rs) => rs.func.funcName))
|
||||
|
||||
|
@ -1231,12 +1285,12 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
|
|||
super(
|
||||
"The auto-icon creates a (non-clickable) title icon based on a tagRendering which has icons",
|
||||
["titleIcons"],
|
||||
"AutoTitleIcon"
|
||||
"AutoTitleIcon",
|
||||
)
|
||||
}
|
||||
|
||||
private createTitleIconsBasedOn(
|
||||
tr: QuestionableTagRenderingConfigJson
|
||||
tr: QuestionableTagRenderingConfigJson,
|
||||
): TagRenderingConfigJson | undefined {
|
||||
const mappings: { if: TagConfigJson; then: string }[] = tr.mappings
|
||||
?.filter((m) => m.icon !== undefined)
|
||||
|
@ -1266,7 +1320,7 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
|
|||
return undefined
|
||||
}
|
||||
return this.createTitleIconsBasedOn(<any>tr)
|
||||
})
|
||||
}),
|
||||
)
|
||||
json.titleIcons.splice(allAutoIndex, 1, ...generated)
|
||||
return json
|
||||
|
@ -1295,8 +1349,8 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
|
|||
.enters("titleIcons", i)
|
||||
.warn(
|
||||
"TagRendering with id " +
|
||||
trId +
|
||||
" does not have any icons, not generating an icon for this"
|
||||
trId +
|
||||
" does not have any icons, not generating an icon for this",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -1311,7 +1365,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
|
|||
super(
|
||||
"If no source is given, automatically derives the osmTags by 'or'-ing all the preset tags",
|
||||
["source"],
|
||||
"DeriveSource"
|
||||
"DeriveSource",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1321,7 +1375,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
|
|||
}
|
||||
if (!json.presets) {
|
||||
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
|
||||
}
|
||||
|
@ -1347,7 +1401,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
|
|||
export class PrepareLayer extends Fuse<LayerConfigJson> {
|
||||
constructor(
|
||||
state: DesugaringContext,
|
||||
options?: { addTagRenderingsToContext?: false | boolean }
|
||||
options?: { addTagRenderingsToContext?: false | boolean },
|
||||
) {
|
||||
super(
|
||||
"Fully prepares and expands a layer for the LayerConfig.",
|
||||
|
@ -1360,8 +1414,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
|
|||
new Concat(
|
||||
new ExpandTagRendering(state, layer, {
|
||||
addToContext: options?.addTagRenderingsToContext ?? false,
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
new On("tagRenderings", new Each(new DetectInline())),
|
||||
new AddQuestionBox(),
|
||||
|
@ -1374,11 +1428,11 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
|
|||
new On<PointRenderingConfigJson[], LayerConfigJson>(
|
||||
"pointRendering",
|
||||
(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>(
|
||||
"pointRendering",
|
||||
(layer) => new Each(new PreparePointRendering(state, layer))
|
||||
(layer) => new Each(new PreparePointRendering(state, layer)),
|
||||
),
|
||||
new SetDefault("titleIcons", ["icons.defaults"]),
|
||||
new AddRatingBadge(),
|
||||
|
@ -1387,9 +1441,10 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
|
|||
new On(
|
||||
"titleIcons",
|
||||
(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,6 +103,10 @@ export class DoesImageExist extends DesugaringStep<string> {
|
|||
return image
|
||||
}
|
||||
|
||||
if(Utils.isEmoji(image)){
|
||||
return image
|
||||
}
|
||||
|
||||
if (!this._knownImagePaths.has(image)) {
|
||||
if (this.doesPathExist === undefined) {
|
||||
context.err(
|
||||
|
@ -779,7 +783,8 @@ export class ValidateLayer extends Conversion<
|
|||
try {
|
||||
layerConfig = new LayerConfig(json, "validation", true)
|
||||
} 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
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,9 @@ import Validators, { ValidatorType } from "../../UI/InputElement/Validators"
|
|||
|
||||
export type FilterConfigOption = {
|
||||
question: Translation
|
||||
searchTerms: Record<string, string[]>
|
||||
icon?: string
|
||||
emoji?: string
|
||||
osmTags: TagsFilter | undefined
|
||||
/* Only set if fields are present. Used to create `osmTags` (which are used to _actually_ filter) when the field is written*/
|
||||
readonly originalTagsSpec: TagConfigJson
|
||||
|
@ -107,8 +110,11 @@ export default class FilterConfig {
|
|||
return {
|
||||
question: question,
|
||||
osmTags: osmTags,
|
||||
searchTerms: option.searchTerms,
|
||||
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> {
|
||||
let defaultValue = ""
|
||||
let defaultValue: string
|
||||
if (this.options.length > 1) {
|
||||
defaultValue = "" + (this.defaultSelection ?? 0)
|
||||
} else if (this.options[0].fields?.length > 0) {
|
||||
|
|
|
@ -1,5 +1,20 @@
|
|||
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 {
|
||||
/**
|
||||
* An id/name for this filter, used to set the URL parameters
|
||||
|
@ -33,18 +48,7 @@ export default interface FilterConfigJson {
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
options: {
|
||||
question: string | any
|
||||
osmTags?: TagConfigJson
|
||||
default?: boolean
|
||||
fields?: {
|
||||
/**
|
||||
* If name is `search`, use "_first_comment~.*{search}.*" as osmTags
|
||||
*/
|
||||
name: string
|
||||
type?: string | "string"
|
||||
}[]
|
||||
}[]
|
||||
options: FilterConfigOptionJson[]
|
||||
|
||||
/**
|
||||
* Used for comments or to disable a check
|
||||
|
|
|
@ -41,14 +41,22 @@ export interface LayerConfigJson {
|
|||
name?: Translatable
|
||||
|
||||
/**
|
||||
* question: How would you describe the features that are shown on 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.
|
||||
*
|
||||
* group: Basic
|
||||
* question: How would you describe the features that are shown on this layer?
|
||||
*/
|
||||
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?
|
||||
* title: Data Source
|
||||
|
@ -434,10 +442,15 @@ export interface LayerConfigJson {
|
|||
* 2. search 'filters.json' for the appropriate filter or
|
||||
* 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
|
||||
*/
|
||||
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.
|
||||
|
|
|
@ -321,7 +321,13 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
|
|||
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
|
||||
*
|
||||
* 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[]
|
||||
}
|
||||
|
|
|
@ -226,5 +226,5 @@ export interface TagRenderingConfigJson {
|
|||
/**
|
||||
* This tagRendering can introduce this builtin filter
|
||||
*/
|
||||
filter?: string[]
|
||||
filter?: string[] | true
|
||||
}
|
||||
|
|
|
@ -23,12 +23,14 @@ import Constants from "../Constants"
|
|||
import { QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson"
|
||||
import MarkdownUtils from "../../Utils/MarkdownUtils"
|
||||
import { And } from "../../Logic/Tags/And"
|
||||
import Combine from "../../UI/Base/Combine"
|
||||
|
||||
export default class LayerConfig extends WithContextLoader {
|
||||
public static readonly syncSelectionAllowed = ["no", "local", "theme-only", "global"] as const
|
||||
public readonly id: string
|
||||
public readonly name: Translation
|
||||
public readonly description: Translation
|
||||
public readonly searchTerms: Record<string, string[]>
|
||||
/**
|
||||
* Only 'null' for special, privileged layers
|
||||
*/
|
||||
|
@ -83,9 +85,12 @@ export default class LayerConfig extends WithContextLoader {
|
|||
}
|
||||
|
||||
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({
|
||||
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") {
|
||||
this.maxAgeOfCache = json.source["maxCacheAge"] ?? 24 * 60 * 60 * 30
|
||||
|
@ -112,8 +117,8 @@ export default class LayerConfig extends WithContextLoader {
|
|||
json.description = undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.description = Translations.T(json.description, translationContext + ".description")
|
||||
this.searchTerms = json.searchTerms ?? {}
|
||||
|
||||
this.calculatedTags = undefined
|
||||
if (json.calculatedTags !== undefined) {
|
||||
|
@ -353,11 +358,13 @@ export default class LayerConfig extends WithContextLoader {
|
|||
if (this.mapRendering === undefined || this.mapRendering === null) {
|
||||
return undefined
|
||||
}
|
||||
const mapRendering = this.mapRendering.filter((r) => r.location.has("point"))[0]
|
||||
if (mapRendering === undefined) {
|
||||
const mapRenderings = this.mapRendering.filter((r) => r.location.has("point"))
|
||||
if (mapRenderings.length === 0) {
|
||||
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> {
|
||||
|
@ -634,4 +641,25 @@ export default class LayerConfig extends WithContextLoader {
|
|||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,21 @@ import { RasterLayerProperties } from "../RasterLayerProperties"
|
|||
|
||||
import { ConversionContext } from "./Conversion/ConversionContext"
|
||||
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
|
||||
**/
|
||||
|
@ -27,6 +41,8 @@ export class LayoutInformation {
|
|||
keywords?: (Translatable | Translation)[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default class LayoutConfig implements LayoutInformation {
|
||||
public static readonly defaultSocialImage = "assets/SocialImage.png"
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -338,7 +354,7 @@ export default class LayoutConfig implements LayoutInformation {
|
|||
...json,
|
||||
layers: json.layers.filter((l) => l["id"] !== "favourite"),
|
||||
}
|
||||
const usedImages = json._usedImages
|
||||
const usedImages = jsonNoFavourites._usedImages
|
||||
usedImages.sort()
|
||||
|
||||
this.usedImages = Utils.Dedup(usedImages)
|
||||
|
|
|
@ -73,6 +73,9 @@ import { LayerConfigJson } from "./ThemeConfig/Json/LayerConfigJson"
|
|||
import Hash from "../Logic/Web/Hash"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
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 searchState: SearchState
|
||||
|
||||
constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) {
|
||||
Utils.initDomPurify()
|
||||
this.layout = layout
|
||||
|
@ -380,6 +385,9 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
|
||||
this.featureSummary = this.setupSummaryLayer()
|
||||
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
|
||||
|
||||
this.searchState = new SearchState(this)
|
||||
|
||||
this.initActors()
|
||||
this.drawSpecialLayers()
|
||||
this.initHotkeys()
|
||||
|
@ -557,10 +565,17 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.previewedImage.setData(undefined)
|
||||
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()) {
|
||||
return
|
||||
}
|
||||
this.selectedElement.setData(undefined)
|
||||
Zoomcontrol.resetzoom()
|
||||
this.focusOnMap()
|
||||
})
|
||||
|
@ -569,6 +584,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.guistate.pageStates.favourites.set(true)
|
||||
})
|
||||
|
||||
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: " ",
|
||||
|
@ -582,6 +598,9 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
if (this.guistate.isSomethingOpen() || this.previewedImage.data !== undefined) {
|
||||
return
|
||||
}
|
||||
if (document.activeElement.tagName === "button" || document.activeElement.tagName === "input") {
|
||||
return
|
||||
}
|
||||
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) => {
|
||||
if (!enable) {
|
||||
return
|
||||
|
@ -770,7 +795,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
current_view: this.currentView,
|
||||
favourite: this.favourites,
|
||||
summary: this.featureSummary,
|
||||
last_click: this.lastClickObject
|
||||
last_click: this.lastClickObject,
|
||||
search: this.searchState.locationResults
|
||||
}
|
||||
|
||||
this.closestFeatures.registerSource(specialLayers.favourite, "favourite")
|
||||
|
@ -820,13 +846,21 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
}
|
||||
|
||||
this.featureProperties.trackFeatureSource(features)
|
||||
new ShowDataLayer(this.map, {
|
||||
const options: ShowDataLayerOptions & { layer: LayerConfig } = {
|
||||
features,
|
||||
doShowLayer: flayer.isDisplayed,
|
||||
layer: flayer.layerDef,
|
||||
metaTags: this.userRelatedState.preferencesAsTags,
|
||||
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")
|
||||
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.mapProperties.showScale.set(showScale)
|
||||
})
|
||||
|
@ -912,7 +969,38 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
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 = "") {
|
||||
if (Utils.runningFromConsole) {
|
||||
console.error("Got (in themeViewSTate.reportError):", message, extramessage)
|
||||
return
|
||||
}
|
||||
const isTesting = this.featureSwitchIsTesting.data
|
||||
console.log(
|
||||
isTesting
|
||||
|
|
|
@ -7,20 +7,14 @@
|
|||
import Translations from "./i18n/Translations"
|
||||
import Logo from "../assets/svg/Logo.svelte"
|
||||
import Tr from "./Base/Tr.svelte"
|
||||
import MoreScreen from "./BigComponents/MoreScreen"
|
||||
import LoginToggle from "./Base/LoginToggle.svelte"
|
||||
import Pencil from "../assets/svg/Pencil.svelte"
|
||||
import Constants from "../Models/Constants"
|
||||
import { Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import { placeholder } from "../Utils/placeholder"
|
||||
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"
|
||||
import ThemesList from "./BigComponents/ThemesList.svelte"
|
||||
import { LayoutInformation } from "../Models/ThemeConfig/LayoutConfig"
|
||||
import * as themeOverview from "../assets/generated/theme_overview.json"
|
||||
import UnofficialThemeList from "./BigComponents/UnofficialThemeList.svelte"
|
||||
import { MinimalLayoutInformation } from "../Models/ThemeConfig/LayoutConfig"
|
||||
import Eye from "../assets/svg/Eye.svelte"
|
||||
import LoginButton from "./Base/LoginButton.svelte"
|
||||
import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight"
|
||||
import Mastodon from "../assets/svg/Mastodon.svelte"
|
||||
import Liberapay from "../assets/svg/Liberapay.svelte"
|
||||
import Bug from "../assets/svg/Bug.svelte"
|
||||
|
@ -28,6 +22,9 @@
|
|||
import { Utils } from "../Utils"
|
||||
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
|
||||
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 osmConnection = new OsmConnection({
|
||||
|
@ -36,41 +33,75 @@
|
|||
"oauth_token",
|
||||
undefined,
|
||||
"Used to complete the login"
|
||||
),
|
||||
)
|
||||
})
|
||||
const state = new UserRelatedState(osmConnection)
|
||||
const t = Translations.t.index
|
||||
const tu = Translations.t.general
|
||||
const tr = Translations.t.general.morescreen
|
||||
|
||||
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") {
|
||||
document.getElementById("theme-search")?.focus()
|
||||
searchIsFocussed.set(true)
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
let visitedHiddenThemes: Store<LayoutInformation[]>
|
||||
const hiddenThemes: LayoutInformation[] =
|
||||
(themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? []
|
||||
{
|
||||
const prefix = "mapcomplete-hidden-theme-"
|
||||
const userPreferences = state.osmConnection.preferencesHandler.preferences
|
||||
visitedHiddenThemes = userPreferences.map((preferences) => {
|
||||
const knownIds = new Set<string>(
|
||||
Object.keys(preferences)
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
|
||||
)
|
||||
return hiddenThemes.filter(
|
||||
(theme) =>
|
||||
knownIds.has(theme.id) ||
|
||||
state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
|
||||
)
|
||||
})
|
||||
function applySearch() {
|
||||
const didRedirect = SearchUtils.applySpecialSearch(search.data)
|
||||
console.log("Did redirect?", didRedirect)
|
||||
if (didRedirect) {
|
||||
// Just for style and readability; won't _actually_ reach this
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = officialSearched.data[0] ?? hiddenSearched.data[0] ?? customSearched.data[0]
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = ThemeSearch.createUrlFor(candidate, undefined)
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<main>
|
||||
|
@ -102,24 +133,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Searchbar
|
||||
value={themeSearchText}
|
||||
placeholder={tr.searchForATheme}
|
||||
on:search={() => MoreScreen.applySearch(themeSearchText.data)}
|
||||
/>
|
||||
<Searchbar value={search} placeholder={tr.searchForATheme} on:search={() => applySearch()} isFocused={searchIsFocussed} />
|
||||
|
||||
<ThemesList search={themeSearchText} {state} themes={MoreScreen.officialThemes} />
|
||||
<ThemesList {search} {state} themes={$officialSearched} />
|
||||
|
||||
<LoginToggle {state}>
|
||||
<LoginButton clss="primary" {osmConnection} slot="not-logged-in">
|
||||
<Tr t={t.logIn} />
|
||||
</LoginButton>
|
||||
<ThemesList
|
||||
hideThemes={false}
|
||||
isCustom={false}
|
||||
search={themeSearchText}
|
||||
{search}
|
||||
{state}
|
||||
themes={$visitedHiddenThemes}
|
||||
themes={$hiddenSearched}
|
||||
hasSelection={$officialSearched.length === 0}
|
||||
>
|
||||
<svelte:fragment slot="title">
|
||||
<h3>
|
||||
|
@ -136,7 +162,19 @@
|
|||
</svelte:fragment>
|
||||
</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>
|
||||
|
||||
<a
|
||||
|
|
63
src/UI/Base/DotMenu.svelte
Normal 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>
|
43
src/UI/Base/DrawerRight.svelte
Normal 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>
|
40
src/UI/Base/ModalRight.svelte
Normal 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>
|
|
@ -6,33 +6,62 @@
|
|||
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { ariaLabel } from "../../Utils/ariaLabel"
|
||||
import { Translation } from "../i18n/Translation"
|
||||
import Backspace from "@babeard/svelte-heroicons/outline/Backspace"
|
||||
|
||||
export let value: UIEventSource<string>
|
||||
let _value = value.data ?? ""
|
||||
value.addCallbackD((v) => {
|
||||
value.addCallbackD(v => {
|
||||
_value = v
|
||||
})
|
||||
$: value.set(_value)
|
||||
|
||||
const dispatch = createEventDispatcher<{ 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>
|
||||
|
||||
<form class="flex justify-center" on:submit|preventDefault={() => dispatch("search")}>
|
||||
|
||||
<form
|
||||
class="w-full"
|
||||
on:submit|preventDefault={() => dispatch("search")}
|
||||
>
|
||||
<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
|
||||
bind:this={inputElement}
|
||||
on:focus={() => {isFocused?.setData(true)}}
|
||||
on:blur={() => {isFocused?.setData(false)}}
|
||||
type="search"
|
||||
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) => {
|
||||
return keypr.key === "Enter" ? dispatch("search") : undefined
|
||||
}}
|
||||
return keypr.key === "Enter" ? dispatch("search") : undefined
|
||||
}}
|
||||
bind:value={_value}
|
||||
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>
|
||||
</form>
|
||||
|
|
|
@ -7,23 +7,17 @@
|
|||
import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource"
|
||||
import Loading from "./Loading.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 selected: Feature
|
||||
let tags = state.featureProperties.getStore(selected.properties.id)
|
||||
|
||||
export let absolute = true
|
||||
function getLayer(properties: Record<string, string>) {
|
||||
if (properties.id === "settings") {
|
||||
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)
|
||||
function getLayer(properties: Record<string, string>): LayerConfig {
|
||||
return state.getMatchingLayer(properties)
|
||||
}
|
||||
|
||||
let layer = getLayer(selected.properties)
|
||||
|
|