forked from MapComplete/MapComplete
		
	Merge pull request #1731 from pietervdvn/feature/favourites
Feature/favourites
This commit is contained in:
		
						commit
						4197ec0055
					
				
					 80 changed files with 2715 additions and 1059 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -6,6 +6,7 @@ scratch | |||
| assets/editor-layer-index.json | ||||
| assets/generated/* | ||||
| src/assets/generated/ | ||||
| assets/layers/favourite/favourite.json | ||||
| public/*.webmanifest | ||||
| /*.html | ||||
| !/index.html | ||||
|  |  | |||
							
								
								
									
										29
									
								
								Docs/UserTests/2023-12-4 User Test Favourites.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								Docs/UserTests/2023-12-4 User Test Favourites.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| 
 | ||||
| ## Task | ||||
| 
 | ||||
| Add a (specified) feature as favourite | ||||
| Find and use the list of favourites | ||||
| Determine information from this list | ||||
| Open the popup from this list | ||||
| 
 | ||||
| ## Background info | ||||
| 
 | ||||
| User has used mapcomplete before | ||||
| 
 | ||||
| ## Results | ||||
| 
 | ||||
| The user is asked to mark a specified bicycle shop as favourite. They find the big button to mark as favourite at the bottom. | ||||
| 
 | ||||
| When asked to select another feature, they choose a bicycle pump. When hinted that 'they can add this in a different way', they immediately select the heart title icon. | ||||
| 
 | ||||
| When asked to open the list of favourites, they open the 'hamburger'-menu. After a bit of looking, they spot the 'Your favourites'-button. | ||||
| 
 | ||||
| They are a bit confused. The specified bicycle shop is advertised as `building or wall`. | ||||
| 
 | ||||
| The bicycle pump is shown correctly, the icons are clear. When asked to open the popup for one of them, they click directly on the link. | ||||
| 
 | ||||
| ## Surfaced issues | ||||
| 
 | ||||
| Due to the way the title is generated, wrong titles appeared: all titles from all layers are mixed and used as title, if the tags match. As such, the title `building or wall` appeared, as it happened to be on top and the bicycle shop had a `building~*` tag. | ||||
| 
 | ||||
| This was resolved by sorting those titles by popularity. The least occuring tags/titles are placed first, so that the most specific title is shown. This might, in some cases, still result in differing titles (e.g. if something is e.g. both a shop and a café), but this should be exceptional. | ||||
|  | @ -29,7 +29,8 @@ | |||
|             "natural=stone" | ||||
|           ] | ||||
|         }, | ||||
|         "climbing=" | ||||
|         "climbing=", | ||||
|         "sport!=climbing" | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|  |  | |||
							
								
								
									
										47
									
								
								assets/layers/favourite/favourite.proto.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								assets/layers/favourite/favourite.proto.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | |||
| { | ||||
|   "#":"no-translations", | ||||
|   "#dont-translate": "*", | ||||
|   "pointRendering": [ | ||||
|     { | ||||
|       "location": [ | ||||
|         "point", | ||||
|         "centroid" | ||||
|       ], | ||||
|       "marker": [ | ||||
|         { | ||||
|           "icon": { | ||||
|             "render": "heart", | ||||
|             "mappings": [ | ||||
|               { | ||||
|                 "if": "_favourite=no", | ||||
|                 "then": "heart_outline" | ||||
|               } | ||||
|             ] | ||||
|           }, | ||||
|           "color": "red" | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "description": { | ||||
|     "en": "A generic map layer which shows locations that a contributor marked as favourite", | ||||
|     "nl": "Een laag met persoonlijke favourieten" | ||||
|   }, | ||||
|   "name": { | ||||
|     "en": "Favourites", | ||||
|     "nl": "Favorieten" | ||||
|   }, | ||||
|   "id": "favourite", | ||||
|   "source": "special", | ||||
|   "isShown": "_favourite=yes", | ||||
|   "minzoom": 0, | ||||
|   "title": { | ||||
|     "render": { | ||||
|       "en": "Favourite location", | ||||
|       "nl": "Favoriete locatie" | ||||
|     } | ||||
|   }, | ||||
|   "tagRenderings": [ | ||||
|      | ||||
|   ] | ||||
| } | ||||
|  | @ -14,7 +14,8 @@ | |||
|     { | ||||
|       "id": "wikipedialink", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank' rel='noopener'><img src='./assets/svg/wikipedia.svg' textmode='📖' alt='Wikipedia'/></a>", | ||||
|       "condition": { | ||||
|  | @ -66,10 +67,23 @@ | |||
|       ], | ||||
|       "metacondition": "__showTimeSensitiveIcons!=no" | ||||
|     }, | ||||
|     { | ||||
|       "id": "open_until", | ||||
|       "labels": [ | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "#": "Titleicon showing 'open until 17:00'", | ||||
|       "icon": { | ||||
|         "class": "w-20 mx-1 flex items-center" | ||||
|       }, | ||||
|       "render": "{opening_hours_state()}" | ||||
|     }, | ||||
|     { | ||||
|       "id": "phonelink", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "render": "<a href='tel:{phone}'><img textmode='📞' alt='phone' src='./assets/layers/questions/phone.svg'/></a>", | ||||
|       "mappings": [ | ||||
|  | @ -89,7 +103,8 @@ | |||
|     { | ||||
|       "id": "emaillink", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "render": "<a href='mailto:{email}'><img textmode='✉️' alt='email' src='./assets/layers/questions/send_email.svg'/></a>", | ||||
|       "mappings": [ | ||||
|  | @ -109,7 +124,8 @@ | |||
|     { | ||||
|       "id": "websitelink", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "render": "<a href='{website}' target='_blank' rel='noopener'><img textmode='🌐' alt='website' src='./assets/layers/icons/website.svg'/></a>", | ||||
|       "condition": "website~*" | ||||
|  | @ -117,7 +133,8 @@ | |||
|     { | ||||
|       "id": "smokingicon", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "mappings": [ | ||||
|         { | ||||
|  | @ -140,6 +157,15 @@ | |||
|       "render": "{share_link()}", | ||||
|       "metacondition": "_supports_sharing=yes" | ||||
|     }, | ||||
|     { | ||||
|       "id": "favourite_title_icon", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|       ], | ||||
|       "render": { | ||||
|         "*": "{favourite_icon()}" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "id": "osmlink", | ||||
|       "labels": [ | ||||
|  | @ -162,7 +188,8 @@ | |||
|     { | ||||
|       "id": "dogicon", | ||||
|       "labels": [ | ||||
|         "defaults" | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "mappings": [ | ||||
|         { | ||||
|  | @ -193,6 +220,13 @@ | |||
|         "class": "w-20 mx-1 flex items-center" | ||||
|       }, | ||||
|       "render": "{rating()}" | ||||
|     }, | ||||
|     { | ||||
|       "id": "favourite_icon", | ||||
|       "description": "Only for rendering", | ||||
|       "condition": "_favourite=yes", | ||||
|       "icon": "circle:white;heart:red", | ||||
|       "metacondition": "__showTimeSensitiveIcons!=no" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  |  | |||
							
								
								
									
										62
									
								
								assets/svg/center.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								assets/svg/center.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | |||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    width="544.02838" | ||||
|    height="544.02838" | ||||
|    viewBox="0 0 544.02838 544.02838" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    sodipodi:docname="center.svg" | ||||
|    inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <sodipodi:namedview | ||||
|      id="namedview1" | ||||
|      pagecolor="#505050" | ||||
|      bordercolor="#eeeeee" | ||||
|      borderopacity="1" | ||||
|      inkscape:showpageshadow="0" | ||||
|      inkscape:pageopacity="0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      showguides="true" | ||||
|      inkscape:zoom="0.90326851" | ||||
|      inkscape:cx="393.57068" | ||||
|      inkscape:cy="250.756" | ||||
|      inkscape:window-width="1920" | ||||
|      inkscape:window-height="995" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="1" | ||||
|      inkscape:current-layer="svg1"> | ||||
|     <sodipodi:guide | ||||
|        position="171.95879,103.32864" | ||||
|        orientation="0,-1" | ||||
|        id="guide4" | ||||
|        inkscape:locked="false" /> | ||||
|     <sodipodi:guide | ||||
|        position="271.68286,132.35281" | ||||
|        orientation="1,0" | ||||
|        id="guide5" | ||||
|        inkscape:locked="false" /> | ||||
|   </sodipodi:namedview> | ||||
|   <path | ||||
|      d="m 365.63918,111.75001 h -62.375 V 15.9375 c 0,-8.75 -7,-15.9375 -15.625,-15.9375 h -31.1875 c -8.5625,0 -15.625,7.1875 -15.625,15.9375 v 95.81251 h -62.375 l 93.5625,127.75 z" | ||||
|      id="path1" | ||||
|      sodipodi:nodetypes="ccsssscccc" /> | ||||
|   <path | ||||
|      d="m 432.27837,365.63919 v -62.375 h 95.8125 c 8.75,0 15.9375,-7 15.9375,-15.625 v -31.1875 c 0,-8.5625 -7.1875,-15.625 -15.9375,-15.625 h -95.8125 v -62.375 l -127.75,93.5625 z" | ||||
|      id="path1-5" | ||||
|      sodipodi:nodetypes="ccsssscccc" /> | ||||
|   <path | ||||
|      d="m 178.38918,432.27838 h 62.375 v 95.8125 c 0,8.75 7,15.9375 15.625,15.9375 h 31.1875 c 8.5625,0 15.625,-7.1875 15.625,-15.9375 v -95.8125 h 62.375 l -93.5625,-127.75 z" | ||||
|      id="path2" | ||||
|      sodipodi:nodetypes="ccsssscccc" /> | ||||
|   <path | ||||
|      d="m 111.75,178.38919 v 62.375 H 15.9375 c -8.75,0 -15.9375,7 -15.9375,15.625 v 31.1875 c 0,8.5625 7.1875,15.625 15.9375,15.625 H 111.75 v 62.375 l 127.74999,-93.5625 z" | ||||
|      id="path3" | ||||
|      sodipodi:nodetypes="ccsssscccc" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 2.3 KiB | 
							
								
								
									
										2
									
								
								assets/svg/center.svg.license
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								assets/svg/center.svg.license
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,2 @@ | |||
| SPDX-FileCopyrightText: Pieter Vander Vennet | ||||
| SPDX-License-Identifier: CC0-1.0 | ||||
|  | @ -153,6 +153,14 @@ | |||
|       "https://commons.wikimedia.org/wiki/File:Camera_font_awesome.svg" | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "path": "center.svg", | ||||
|     "license": "CC0-1.0", | ||||
|     "authors": [ | ||||
|       "Pieter Vander Vennet" | ||||
|     ], | ||||
|     "sources": [] | ||||
|   }, | ||||
|   { | ||||
|     "path": "checkmark.svg", | ||||
|     "license": "CC0-1.0", | ||||
|  |  | |||
|  | @ -69,10 +69,12 @@ | |||
|         }, | ||||
|         "+titleIcons": [ | ||||
|           { | ||||
|             "id": "climbing_length", | ||||
|             "render": "<div class='flex' style='word-wrap: normal; padding-right: 0.25rem;'><img src='./assets/themes/climbing/height.svg' style='height: 1.75rem;'/>{climbing:length}m</div>", | ||||
|             "condition": "climbing:length~*" | ||||
|           }, | ||||
|           { | ||||
|             "id": "climbing_bolts", | ||||
|             "mappings": [ | ||||
|               { | ||||
|                 "if": "__bolts_max~*", | ||||
|  | @ -95,6 +97,7 @@ | |||
|             "render": "<div class='w-8 flex justify-center rounded-right-full climbing-{__difficulty_max:char}'> {__difficulty_max}</div>" | ||||
|           }, | ||||
|           { | ||||
|             "id": "difficulty", | ||||
|             "render": "<div class='flex justify-center rounded-full pl-1 pr-1 climbing-{__difficulty:char}'> {climbing:grade:french}</div>", | ||||
|             "condition": "__difficulty:char~*" | ||||
|           } | ||||
|  |  | |||
|  | @ -166,31 +166,31 @@ | |||
|                 { | ||||
|                   "if": "sidewalk:left|right=yes", | ||||
|                   "then": { | ||||
|                     "en": "Yes, there is a sidewalk on this side of the road", | ||||
|                     "de": "Ja, es gibt einen Bürgersteig auf dieser Straßenseite", | ||||
|                     "da": "Ja, der er et fortov på denne side af vejen", | ||||
|                     "nl": "Ja, er is een stoep aan deze kant van de weg", | ||||
|                     "fr": "Oui, il y a un trottoir de ce côté de la route", | ||||
|                     "ca": "Sí, hi ha una vorera a aquest costat del carrer", | ||||
|                     "es": "Sí, hay una acera en este lado de la calle", | ||||
|                     "cs": "Ano, na této straně silnice je chodník", | ||||
|                     "it": "Sì, c'è un marciapiede su questo lato della strada", | ||||
|                     "pl": "Tak, jest chodnik z boku drogi" | ||||
|                     "en": "There is a sidewalk on this side of the road", | ||||
|                     "de": "Es gibt einen Bürgersteig auf dieser Straßenseite", | ||||
|                     "da": "Der er et fortov på denne side af vejen", | ||||
|                     "nl": "Er is een stoep aan deze kant van de weg", | ||||
|                     "fr": "Il y a un trottoir de ce côté de la route", | ||||
|                     "ca": "Hi ha una vorera a aquest costat del carrer", | ||||
|                     "es": "Hay una acera en este lado de la calle", | ||||
|                     "cs": "Na této straně silnice je chodník", | ||||
|                     "it": "C'è un marciapiede su questo lato della strada", | ||||
|                     "pl": "Jest chodnik z boku drogi" | ||||
|                   } | ||||
|                 }, | ||||
|                 { | ||||
|                   "if": "sidewalk:left|right=no", | ||||
|                   "then": { | ||||
|                     "en": "No, there is no sidewalk to walk on", | ||||
|                     "de": "Nein, es gibt keinen Bürgersteig für Fußgänger", | ||||
|                     "da": "Nej, der er ikke noget fortov at gå på", | ||||
|                     "nl": "Nee, er is geen stoep om op te lopen", | ||||
|                     "fr": "Non, il n'y a pas de trottoir où marcher", | ||||
|                     "ca": "No, no hi ha vorera per la que caminar", | ||||
|                     "es": "No, no hay acera por la que caminar", | ||||
|                     "cs": "Ne, není tu žádný chodník", | ||||
|                     "it": "No, non c'è un marciapiede su cui camminare", | ||||
|                     "pl": "Nie, nie ma chodnika, którym można chodzić" | ||||
|                     "en": "There is no sidewalk to walk on", | ||||
|                     "de": "Es gibt keinen Bürgersteig für Fußgänger", | ||||
|                     "da": "Der er ikke noget fortov at gå på", | ||||
|                     "nl": "Er is geen stoep om op te lopen", | ||||
|                     "fr": "Il n'y a pas de trottoir où marcher", | ||||
|                     "ca": "No hi ha vorera per la que caminar", | ||||
|                     "es": "No hay acera por la que caminar", | ||||
|                     "cs": "Není tu žádný chodník", | ||||
|                     "it": "Non c'è un marciapiede su cui camminare", | ||||
|                     "pl": "Nie ma chodnika, którym można chodzić" | ||||
|                   } | ||||
|                 }, | ||||
|                 { | ||||
|  |  | |||
|  | @ -50,6 +50,22 @@ | |||
|         "panelIntro": "<h3>Your personal theme</h3>Activate your favourite layers from all the official themes", | ||||
|         "reload": "Reload the data" | ||||
|     }, | ||||
|     "favouritePoi": { | ||||
|         "button": { | ||||
|             "isFavourite": "This location is currently marked as favourite and will show up on all thematic maps of MapComplete you visit.", | ||||
|             "markAsFavouriteTitle": "Mark this location as favourite location", | ||||
|             "markDescription": "Add this location to a personal list of your favourites", | ||||
|             "unmark": "Remove from your personal list of favourites", | ||||
|             "unmarkNotDeleted": "This point will not be deleted and still be visible on the appropriate map for you and others" | ||||
|         }, | ||||
|         "downloadGeojson": "Download your favourites as geojson", | ||||
|         "downloadGpx": "Download your favourites as GPX", | ||||
|         "intro": "You marked {length} locations as a favourite location.", | ||||
|         "introPrivacy": "This list is only visible to you", | ||||
|         "loginToSeeList": "Login to see the list of locations you marked as favourite", | ||||
|         "tab": "Your favourites", | ||||
|         "title": "Your favourite locations" | ||||
|     }, | ||||
|     "flyer": { | ||||
|         "aerial": "This map uses a different background, namely aerial imagery by Agentschap Informatie Vlaanderen", | ||||
|         "callToAction": "Test it on mapcomplete.org", | ||||
|  | @ -404,6 +420,7 @@ | |||
|         "key": "Key combination", | ||||
|         "openLayersPanel": "Opens the layers and filters panel", | ||||
|         "selectAerial": "Set the background to aerial or satellite imagery. Toggles between the two best, available layers", | ||||
|         "selectFavourites": "Open the favourites page", | ||||
|         "selectItem": "Select the POI which is closest to the map center (crosshair). Only when in keyboard navigation is used", | ||||
|         "selectMap": "Set the background to a map from external sources. Toggles between the two best, available layers", | ||||
|         "selectMapnik": "Set the background layer to OpenStreetMap-carto", | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| { | ||||
|   "name": "mapcomplete", | ||||
|   "version": "0.35.2", | ||||
|   "version": "0.36.0", | ||||
|   "repository": "https://github.com/pietervdvn/MapComplete", | ||||
|   "description": "A small website to edit OSM easily", | ||||
|   "bugs": "https://github.com/pietervdvn/MapComplete/issues", | ||||
|  | @ -65,7 +65,7 @@ | |||
|     "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", | ||||
|     "optimize-images": "cd assets/generated/ &&  find -name '*.png' -exec optipng '{}' \\; && echo 'PNGs are optimized'", | ||||
|     "generate:stats": "vite-node scripts/GenerateSeries.ts", | ||||
|     "reset: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 && npm run generate:layeroverview && vite-node scripts/generateLayerOverview.ts -- --force", | ||||
|     "reset: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 && npm run generate:layeroverview && vite-node scripts/generateLayerOverview.ts -- --force", | ||||
|     "generate": "mkdir -p ./assets/generated; npm run generate:licenses; npm run generate:images; npm run generate:charging-stations; npm run generate:translations; npm run reset:layeroverview; npm run generate:service-worker", | ||||
|     "generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -", | ||||
|     "prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh", | ||||
|  |  | |||
|  | @ -745,6 +745,10 @@ video { | |||
|   top: 2.5rem; | ||||
| } | ||||
| 
 | ||||
| .left-1\/4 { | ||||
|   left: 25%; | ||||
| } | ||||
| 
 | ||||
| .isolate { | ||||
|   isolation: isolate; | ||||
| } | ||||
|  | @ -765,10 +769,6 @@ video { | |||
|   float: left; | ||||
| } | ||||
| 
 | ||||
| .m-8 { | ||||
|   margin: 2rem; | ||||
| } | ||||
| 
 | ||||
| .m-4 { | ||||
|   margin: 1rem; | ||||
| } | ||||
|  | @ -781,6 +781,10 @@ video { | |||
|   margin: 0px; | ||||
| } | ||||
| 
 | ||||
| .m-8 { | ||||
|   margin: 2rem; | ||||
| } | ||||
| 
 | ||||
| .m-2 { | ||||
|   margin: 0.5rem; | ||||
| } | ||||
|  | @ -841,10 +845,6 @@ video { | |||
|   margin-right: 3rem; | ||||
| } | ||||
| 
 | ||||
| .mb-4 { | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| .mt-4 { | ||||
|   margin-top: 1rem; | ||||
| } | ||||
|  | @ -881,6 +881,10 @@ video { | |||
|   margin-right: 0.25rem; | ||||
| } | ||||
| 
 | ||||
| .mb-4 { | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| .ml-1 { | ||||
|   margin-left: 0.25rem; | ||||
| } | ||||
|  | @ -1088,6 +1092,10 @@ video { | |||
|   height: 2.75rem; | ||||
| } | ||||
| 
 | ||||
| .h-5 { | ||||
|   height: 1.25rem; | ||||
| } | ||||
| 
 | ||||
| .h-48 { | ||||
|   height: 12rem; | ||||
| } | ||||
|  | @ -1198,6 +1206,14 @@ video { | |||
|   width: 50%; | ||||
| } | ||||
| 
 | ||||
| .w-14 { | ||||
|   width: 3.5rem; | ||||
| } | ||||
| 
 | ||||
| .w-5 { | ||||
|   width: 1.25rem; | ||||
| } | ||||
| 
 | ||||
| .w-10 { | ||||
|   width: 2.5rem; | ||||
| } | ||||
|  | @ -1289,6 +1305,10 @@ video { | |||
|           appearance: none; | ||||
| } | ||||
| 
 | ||||
| .grid-cols-2 { | ||||
|   grid-template-columns: repeat(2, minmax(0, 1fr)); | ||||
| } | ||||
| 
 | ||||
| .grid-cols-1 { | ||||
|   grid-template-columns: repeat(1, minmax(0, 1fr)); | ||||
| } | ||||
|  | @ -1441,6 +1461,14 @@ video { | |||
|   align-self: center; | ||||
| } | ||||
| 
 | ||||
| .justify-self-start { | ||||
|   justify-self: start; | ||||
| } | ||||
| 
 | ||||
| .justify-self-end { | ||||
|   justify-self: end; | ||||
| } | ||||
| 
 | ||||
| .overflow-auto { | ||||
|   overflow: auto; | ||||
| } | ||||
|  | @ -2335,6 +2363,16 @@ button.disabled:hover, .button.disabled:hover { | |||
|   color: unset; | ||||
| } | ||||
| 
 | ||||
| button.link { | ||||
|   border: none; | ||||
|   text-decoration: underline; | ||||
|   background-color: unset; | ||||
| } | ||||
| 
 | ||||
| button.link:hover { | ||||
|   color:unset; | ||||
| } | ||||
| 
 | ||||
| .interactive button.disabled svg path, .interactive .button.disabled svg path { | ||||
|   fill: var(--interactive-foreground) !important; | ||||
| } | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ mkdir dist 2> /dev/null | |||
| mkdir dist/assets 2> /dev/null | ||||
| 
 | ||||
| 
 | ||||
| export NODE_OPTIONS="--max-old-space-size=8192" | ||||
| export NODE_OPTIONS="--max-old-space-size=16384" | ||||
| 
 | ||||
| # This script ends every line with '&&' to chain everything. A failure will thus stop the build | ||||
| npm run generate:editor-layer-index && | ||||
|  | @ -48,7 +48,7 @@ else | |||
|   exit 1 | ||||
| fi | ||||
| 
 | ||||
| export NODE_OPTIONS=--max-old-space-size=7000 | ||||
| export NODE_OPTIONS=--max-old-space-size=16000 | ||||
| which vite | ||||
| vite build --sourcemap  | ||||
| # Copy the layer files, as these might contain assets (e.g. svgs) | ||||
|  |  | |||
							
								
								
									
										304
									
								
								scripts/generateFavouritesLayer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								scripts/generateFavouritesLayer.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,304 @@ | |||
| import Script from "./Script" | ||||
| import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson" | ||||
| import { existsSync, readFileSync, writeFileSync } from "fs" | ||||
| import { AllSharedLayers } from "../src/Customizations/AllSharedLayers" | ||||
| import { AllKnownLayoutsLazy } from "../src/Customizations/AllKnownLayouts" | ||||
| import { Utils } from "../src/Utils" | ||||
| import { AddEditingElements } from "../src/Models/ThemeConfig/Conversion/PrepareLayer" | ||||
| import { | ||||
|     MappingConfigJson, | ||||
|     QuestionableTagRenderingConfigJson, | ||||
| } from "../src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson" | ||||
| import { TagConfigJson } from "../src/Models/ThemeConfig/Json/TagConfigJson" | ||||
| import { TagUtils } from "../src/Logic/Tags/TagUtils" | ||||
| import { TagRenderingConfigJson } from "../src/Models/ThemeConfig/Json/TagRenderingConfigJson" | ||||
| import { Translatable } from "../src/Models/ThemeConfig/Json/Translatable" | ||||
| 
 | ||||
| export class GenerateFavouritesLayer extends Script { | ||||
|     private readonly layers: LayerConfigJson[] = [] | ||||
| 
 | ||||
|     constructor() { | ||||
|         super("Prepares the 'favourites'-layer") | ||||
|         const allThemes = new AllKnownLayoutsLazy(false).values() | ||||
|         for (const theme of allThemes) { | ||||
|             if (theme.hideFromOverview) { | ||||
|                 continue | ||||
|             } | ||||
|             for (const layer of theme.layers) { | ||||
|                 if (!layer.source) { | ||||
|                     continue | ||||
|                 } | ||||
|                 if (layer.source.geojsonSource) { | ||||
|                     continue | ||||
|                 } | ||||
|                 const layerConfig = AllSharedLayers.getSharedLayersConfigs().get(layer.id) | ||||
|                 if (!layerConfig) { | ||||
|                     continue | ||||
|                 } | ||||
|                 this.layers.push(layerConfig) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private sortMappings(mappings: MappingConfigJson[]): MappingConfigJson[] { | ||||
|         const sortedMappings: MappingConfigJson[] = [...mappings] | ||||
|         sortedMappings.sort((a, b) => { | ||||
|             const aTag = TagUtils.Tag(a.if) | ||||
|             const bTag = TagUtils.Tag(b.if) | ||||
|             const aPop = TagUtils.GetPopularity(aTag) | ||||
|             const bPop = TagUtils.GetPopularity(bTag) | ||||
|             return aPop - bPop | ||||
|         }) | ||||
| 
 | ||||
|         return sortedMappings | ||||
|     } | ||||
|     private addTagRenderings(proto: LayerConfigJson) { | ||||
|         const blacklistedIds = new Set([ | ||||
|             "images", | ||||
|             "questions", | ||||
|             "mapillary", | ||||
|             "leftover-questions", | ||||
|             "last_edit", | ||||
|             "minimap", | ||||
|             "move-button", | ||||
|             "delete-button", | ||||
|             "all-tags", | ||||
|             "all_tags", | ||||
|             ...AddEditingElements.addedElements, | ||||
|         ]) | ||||
| 
 | ||||
|         const generatedTagRenderings: (string | QuestionableTagRenderingConfigJson)[] = [] | ||||
|         const trPerId = new Map< | ||||
|             string, | ||||
|             { conditions: TagConfigJson[]; tr: QuestionableTagRenderingConfigJson } | ||||
|         >() | ||||
|         for (const layerConfig of this.layers) { | ||||
|             if (!layerConfig.tagRenderings) { | ||||
|                 continue | ||||
|             } | ||||
|             for (const tagRendering of layerConfig.tagRenderings) { | ||||
|                 if (typeof tagRendering === "string") { | ||||
|                     if (blacklistedIds.has(tagRendering)) { | ||||
|                         continue | ||||
|                     } | ||||
|                     generatedTagRenderings.push(tagRendering) | ||||
|                     blacklistedIds.add(tagRendering) | ||||
|                     continue | ||||
|                 } | ||||
|                 if (tagRendering["builtin"]) { | ||||
|                     continue | ||||
|                 } | ||||
|                 const id = tagRendering.id | ||||
|                 if (blacklistedIds.has(id)) { | ||||
|                     continue | ||||
|                 } | ||||
|                 if (trPerId.has(id)) { | ||||
|                     const old = trPerId.get(id).tr | ||||
| 
 | ||||
|                     // We need to figure out if this was a 'recycled' tag rendering or just happens to have the same id
 | ||||
|                     function isSame(fieldName: string) { | ||||
|                         return old[fieldName]?.["en"] === tagRendering[fieldName]?.["en"] | ||||
|                     } | ||||
| 
 | ||||
|                     const sameQuestion = isSame("question") && isSame("render") | ||||
|                     if (!sameQuestion) { | ||||
|                         const newTr = <QuestionableTagRenderingConfigJson>Utils.Clone(tagRendering) | ||||
|                         newTr.id = layerConfig.id + "_" + newTr.id | ||||
|                         if (blacklistedIds.has(newTr.id)) { | ||||
|                             continue | ||||
|                         } | ||||
|                         newTr.condition = { | ||||
|                             and: Utils.NoNull([newTr.condition, layerConfig.source["osmTags"]]), | ||||
|                         } | ||||
|                         generatedTagRenderings.push(newTr) | ||||
|                         blacklistedIds.add(newTr.id) | ||||
|                         continue | ||||
|                     } | ||||
|                 } | ||||
|                 if (!trPerId.has(id)) { | ||||
|                     const newTr = <QuestionableTagRenderingConfigJson>Utils.Clone(tagRendering) | ||||
|                     generatedTagRenderings.push(newTr) | ||||
|                     trPerId.set(newTr.id, { tr: newTr, conditions: [] }) | ||||
|                 } | ||||
|                 const conditions = trPerId.get(id).conditions | ||||
|                 if (tagRendering["condition"]) { | ||||
|                     conditions.push({ | ||||
|                         and: [tagRendering["condition"], layerConfig.source["osmTags"]], | ||||
|                     }) | ||||
|                 } else { | ||||
|                     conditions.push(layerConfig.source["osmTags"]) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         for (const { tr, conditions } of Array.from(trPerId.values())) { | ||||
|             const optimized = TagUtils.optimzeJson({ or: conditions }) | ||||
|             if (optimized === true) { | ||||
|                 continue | ||||
|             } | ||||
|             if (optimized === false) { | ||||
|                 throw "Optimized into 'false', this is weird..." | ||||
|             } | ||||
|             tr.condition = optimized | ||||
|         } | ||||
| 
 | ||||
|         const allTags: QuestionableTagRenderingConfigJson = { | ||||
|             id: "all-tags", | ||||
|             render: { "*": "{all_tags()}" }, | ||||
| 
 | ||||
|             metacondition: { | ||||
|                 or: [ | ||||
|                     "__featureSwitchIsDebugging=true", | ||||
|                     "mapcomplete-show_tags=full", | ||||
|                     "mapcomplete-show_debug=yes", | ||||
|                 ], | ||||
|             }, | ||||
|         } | ||||
|         proto.tagRenderings = [ | ||||
|             "images", | ||||
|             ...generatedTagRenderings, | ||||
|             ...proto.tagRenderings, | ||||
|             "questions", | ||||
|             allTags, | ||||
|         ] | ||||
|     } | ||||
| 
 | ||||
|     private addTitleIcons(proto: LayerConfigJson) { | ||||
|         proto.titleIcons = [] | ||||
|         const seenTitleIcons = new Set<string>() | ||||
|         for (const layer of this.layers) { | ||||
|             for (const titleIcon of layer.titleIcons) { | ||||
|                 if (typeof titleIcon === "string") { | ||||
|                     continue | ||||
|                 } | ||||
|                 if (titleIcon["labels"]?.indexOf("defaults") >= 0) { | ||||
|                     continue | ||||
|                 } | ||||
|                 if (titleIcon.id === "rating") { | ||||
|                     if (!seenTitleIcons.has("rating")) { | ||||
|                         proto.titleIcons.unshift("icons.rating") | ||||
|                         seenTitleIcons.add("rating") | ||||
|                     } | ||||
|                     continue | ||||
|                 } | ||||
|                 if (seenTitleIcons.has(titleIcon.id)) { | ||||
|                     continue | ||||
|                 } | ||||
|                 seenTitleIcons.add(titleIcon.id) | ||||
|                 console.log("Adding ", titleIcon.id) | ||||
|                 proto.titleIcons.push(titleIcon) | ||||
|             } | ||||
|         } | ||||
|         proto.titleIcons.push("icons.defaults") | ||||
|     } | ||||
| 
 | ||||
|     private addTitle(proto: LayerConfigJson) { | ||||
|         let mappings: MappingConfigJson[] = [] | ||||
|         for (const layer of this.layers) { | ||||
|             const t = layer.title | ||||
|             const tags: TagConfigJson = layer.source["osmTags"] | ||||
|             if (!t) { | ||||
|                 continue | ||||
|             } | ||||
|             if (typeof t === "string") { | ||||
|                 mappings.push({ if: tags, then: t }) | ||||
|             } else if (t["render"] !== undefined || t["mappings"] !== undefined) { | ||||
|                 const tr = <TagRenderingConfigJson>t | ||||
|                 for (let i = 0; i < (tr.mappings ?? []).length; i++) { | ||||
|                     const mapping = tr.mappings[i] | ||||
|                     const optimized = TagUtils.optimzeJson({ | ||||
|                         and: [mapping.if, tags], | ||||
|                     }) | ||||
|                     if (optimized === false) { | ||||
|                         console.warn( | ||||
|                             "The following tags yielded 'false':", | ||||
|                             JSON.stringify(mapping.if), | ||||
|                             JSON.stringify(tags) | ||||
|                         ) | ||||
|                         continue | ||||
|                     } | ||||
|                     if (optimized === true) { | ||||
|                         console.error( | ||||
|                             "The following tags yielded 'false':", | ||||
|                             JSON.stringify(mapping.if), | ||||
|                             JSON.stringify(tags) | ||||
|                         ) | ||||
|                         throw "Tags for title optimized to true" | ||||
|                     } | ||||
| 
 | ||||
|                     if (!mapping.then) { | ||||
|                         throw ( | ||||
|                             "The title has a missing 'then' for mapping " + | ||||
|                             i + | ||||
|                             " in layer " + | ||||
|                             layer.id | ||||
|                         ) | ||||
|                     } | ||||
|                     mappings.push({ | ||||
|                         if: optimized, | ||||
|                         then: mapping.then, | ||||
|                     }) | ||||
|                 } | ||||
|                 if (tr.render) { | ||||
|                     mappings.push({ | ||||
|                         if: tags, | ||||
|                         then: <Translatable>tr.render, | ||||
|                     }) | ||||
|                 } | ||||
|             } else { | ||||
|                 mappings.push({ if: tags, then: <Record<string, string>>t }) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         mappings = this.sortMappings(mappings) | ||||
| 
 | ||||
|         if (proto.title["mappings"]) { | ||||
|             mappings.unshift(...proto.title["mappings"]) | ||||
|         } | ||||
|         if (proto.title["render"]) { | ||||
|             mappings.push({ | ||||
|                 if: "id~*", | ||||
|                 then: proto.title["render"], | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         for (const mapping of mappings) { | ||||
|             const opt = TagUtils.optimzeJson(mapping.if) | ||||
|             if (typeof opt === "boolean") { | ||||
|                 continue | ||||
|             } | ||||
|             mapping.if = opt | ||||
|         } | ||||
| 
 | ||||
|         proto.title = { | ||||
|             mappings, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async main(args: string[]): Promise<void> { | ||||
|         console.log("Generating the favourite layer: stealing _all_ tagRenderings") | ||||
|         const proto = this.readLayer("favourite/favourite.proto.json") | ||||
|         this.addTagRenderings(proto) | ||||
|         this.addTitle(proto) | ||||
|         this.addTitleIcons(proto) | ||||
|         const targetContent = JSON.stringify(proto, null, "  ") | ||||
|         const path = "./assets/layers/favourite/favourite.json" | ||||
|         if (existsSync(path)) { | ||||
|             if (readFileSync(path, "utf8") === targetContent) { | ||||
|                 return // No need to actually write the file, it is identical
 | ||||
|             } | ||||
|         } | ||||
|         writeFileSync(path, targetContent) | ||||
|     } | ||||
| 
 | ||||
|     private readLayer(path: string): LayerConfigJson { | ||||
|         try { | ||||
|             return JSON.parse(readFileSync("./assets/layers/" + path, "utf8")) | ||||
|         } catch (e) { | ||||
|             console.error("Could not read ./assets/layers/" + path) | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| new GenerateFavouritesLayer().run() | ||||
|  | @ -27,7 +27,8 @@ function genImages(dryrun = false) { | |||
|         "star_outline", | ||||
|         "star", | ||||
|         "osm_logo_us", | ||||
| 
 | ||||
|         "triangle", | ||||
|         "teardrop_with_hole_green", | ||||
|         "SocialImageForeground", | ||||
|         "wikipedia", | ||||
|         "Upload", | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ import { QuestionableTagRenderingConfigJson } from "../src/Models/ThemeConfig/Js | |||
| 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" | ||||
| 
 | ||||
| // 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
 | ||||
|  | @ -381,16 +382,11 @@ class LayerOverviewUtils extends Script { | |||
|             forceReload | ||||
|         ) | ||||
| 
 | ||||
|         writeFileSync( | ||||
|             "./src/assets/generated/known_themes.json", | ||||
|             JSON.stringify({ | ||||
|                 themes: Array.from(sharedThemes.values()), | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         writeFileSync( | ||||
|             "./src/assets/generated/known_layers.json", | ||||
|             JSON.stringify({ layers: Array.from(sharedLayers.values()) }) | ||||
|             JSON.stringify({ | ||||
|                 layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"), | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         const mcChangesPath = "./assets/themes/mapcomplete-changes/mapcomplete-changes.json" | ||||
|  | @ -428,6 +424,19 @@ class LayerOverviewUtils extends Script { | |||
|             ConversionContext.construct([], []) | ||||
|         ) | ||||
| 
 | ||||
|         for (const [_, theme] of sharedThemes) { | ||||
|             theme.layers = theme.layers.filter( | ||||
|                 (l) => Constants.added_by_default.indexOf(l["id"]) < 0 | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         writeFileSync( | ||||
|             "./src/assets/generated/known_themes.json", | ||||
|             JSON.stringify({ | ||||
|                 themes: Array.from(sharedThemes.values()), | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         const end = new Date() | ||||
|         const millisNeeded = end.getTime() - start.getTime() | ||||
|         if (AllSharedLayers.getSharedLayersConfigs().size == 0) { | ||||
|  | @ -791,4 +800,5 @@ class LayerOverviewUtils extends Script { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| new GenerateFavouritesLayer().run() | ||||
| new LayerOverviewUtils().run() | ||||
|  |  | |||
|  | @ -4,6 +4,8 @@ import { TagUtils } from "../src/Logic/Tags/TagUtils" | |||
| import { Utils } from "../src/Utils" | ||||
| import { writeFileSync } from "fs" | ||||
| import ScriptUtils from "./ScriptUtils" | ||||
| import TagRenderingConfig from "../src/Models/ThemeConfig/TagRenderingConfig" | ||||
| import { And } from "../src/Logic/Tags/And" | ||||
| 
 | ||||
| /* Downloads stats on osmSource-tags and keys from tagInfo */ | ||||
| 
 | ||||
|  | @ -21,7 +23,12 @@ async function main(includeTags = true) { | |||
|             continue | ||||
|         } | ||||
| 
 | ||||
|         const sources = TagUtils.Tag(layer.source["osmTags"]) | ||||
|         const sourcesList = [TagUtils.Tag(layer.source["osmTags"])] | ||||
|         if (layer?.title) { | ||||
|             sourcesList.push(...new TagRenderingConfig(layer.title).usedTags()) | ||||
|         } | ||||
| 
 | ||||
|         const sources = new And(sourcesList) | ||||
|         const allKeys = sources.usedKeys() | ||||
|         for (const key of allKeys) { | ||||
|             if (!keysAndTags.has(key)) { | ||||
|  | @ -68,6 +75,8 @@ async function main(includeTags = true) { | |||
|         "./src/assets/key_totals.json", | ||||
|         JSON.stringify( | ||||
|             { | ||||
|                 "#": "Generated with generateStats.ts", | ||||
|                 date: new Date().toISOString(), | ||||
|                 keys: Utils.MapToObj(keyTotal, (t) => t), | ||||
|                 tags: Utils.MapToObj(tagTotal, (v) => Utils.MapToObj(v, (t) => t)), | ||||
|             }, | ||||
|  |  | |||
|  | @ -1,45 +1,54 @@ | |||
| import known_themes from "../assets/generated/known_themes.json" | ||||
| import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" | ||||
| import favourite from "../assets/generated/layers/favourite.json" | ||||
| import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson" | ||||
| import { AllSharedLayers } from "./AllSharedLayers" | ||||
| import Constants from "../Models/Constants" | ||||
| 
 | ||||
| /** | ||||
|  * Somewhat of a dictionary, which lazily parses needed themes | ||||
|  */ | ||||
| export class AllKnownLayoutsLazy { | ||||
|     private readonly dict: Map<string, { data: LayoutConfig } | { func: () => LayoutConfig }> = | ||||
|         new Map() | ||||
|     constructor() { | ||||
|     private readonly raw: Map<string, LayoutConfigJson> = new Map() | ||||
|     private readonly dict: Map<string, LayoutConfig> = new Map() | ||||
| 
 | ||||
|     constructor(includeFavouriteLayer = true) { | ||||
|         for (const layoutConfigJson of known_themes["themes"]) { | ||||
|             this.dict.set(layoutConfigJson.id, { | ||||
|                 func: () => { | ||||
|                     const layout = new LayoutConfig(<LayoutConfigJson>layoutConfigJson, true) | ||||
|                     for (let i = 0; i < layout.layers.length; i++) { | ||||
|                         let layer = layout.layers[i] | ||||
|                         if (typeof layer === "string") { | ||||
|                             throw "Layer " + layer + " was not expanded in " + layout.id | ||||
|             for (const layerId of Constants.added_by_default) { | ||||
|                 if (layerId === "favourite" && favourite.id) { | ||||
|                     if (includeFavouriteLayer) { | ||||
|                         layoutConfigJson.layers.push(favourite) | ||||
|                     } | ||||
|                     continue | ||||
|                 } | ||||
|                     return layout | ||||
|                 }, | ||||
|             }) | ||||
|                 const defaultLayer = AllSharedLayers.getSharedLayersConfigs().get(layerId) | ||||
|                 if (defaultLayer === undefined) { | ||||
|                     console.error("Could not find builtin layer", layerId) | ||||
|                     continue | ||||
|                 } | ||||
|                 layoutConfigJson.layers.push(defaultLayer) | ||||
|             } | ||||
|             this.raw.set(layoutConfigJson.id, layoutConfigJson) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public getConfig(key: string): LayoutConfigJson { | ||||
|         return this.raw.get(key) | ||||
|     } | ||||
| 
 | ||||
|     public get(key: string): LayoutConfig { | ||||
|         const thunk = this.dict.get(key) | ||||
|         if (thunk === undefined) { | ||||
|             return undefined | ||||
|         const cached = this.dict.get(key) | ||||
|         if (cached !== undefined) { | ||||
|             return cached | ||||
|         } | ||||
|         if (thunk["data"]) { | ||||
|             return thunk["data"] | ||||
|         } | ||||
|         const layout = thunk["func"]() | ||||
|         this.dict.set(key, { data: layout }) | ||||
| 
 | ||||
|         const layout = new LayoutConfig(this.getConfig(key)) | ||||
|         this.dict.set(key, layout) | ||||
|         return layout | ||||
|     } | ||||
| 
 | ||||
|     public keys() { | ||||
|         return this.dict.keys() | ||||
|         return this.raw.keys() | ||||
|     } | ||||
| 
 | ||||
|     public values() { | ||||
|  |  | |||
|  | @ -6,13 +6,21 @@ import { Changes } from "../Osm/Changes" | |||
| import { OsmConnection } from "../Osm/OsmConnection" | ||||
| import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import SimpleMetaTagger from "../SimpleMetaTagger" | ||||
| import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" | ||||
| import { Feature } from "geojson" | ||||
| import { OsmTags } from "../../Models/OsmFeature" | ||||
| import OsmObjectDownloader from "../Osm/OsmObjectDownloader" | ||||
| import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" | ||||
| import { Utils } from "../../Utils" | ||||
| 
 | ||||
| interface TagsUpdaterState { | ||||
|     selectedElement: UIEventSource<Feature> | ||||
|     featureProperties: { getStore: (id: string) => UIEventSource<Record<string, string>> } | ||||
|     changes: Changes | ||||
|     osmConnection: OsmConnection | ||||
|     layout: LayoutConfig | ||||
|     osmObjectDownloader: OsmObjectDownloader | ||||
|     indexedFeatures: IndexedFeatureSource | ||||
| } | ||||
| export default class SelectedElementTagsUpdater { | ||||
|     private static readonly metatags = new Set([ | ||||
|         "timestamp", | ||||
|  | @ -23,38 +31,18 @@ export default class SelectedElementTagsUpdater { | |||
|         "id", | ||||
|     ]) | ||||
| 
 | ||||
|     private readonly state: { | ||||
|         selectedElement: UIEventSource<Feature> | ||||
|         featureProperties: FeaturePropertiesStore | ||||
|         changes: Changes | ||||
|         osmConnection: OsmConnection | ||||
|         layout: LayoutConfig | ||||
|         osmObjectDownloader: OsmObjectDownloader | ||||
|         indexedFeatures: IndexedFeatureSource | ||||
|     } | ||||
| 
 | ||||
|     constructor(state: { | ||||
|         selectedElement: UIEventSource<Feature> | ||||
|         featureProperties: FeaturePropertiesStore | ||||
|         indexedFeatures: IndexedFeatureSource | ||||
|         changes: Changes | ||||
|         osmConnection: OsmConnection | ||||
|         layout: LayoutConfig | ||||
|         osmObjectDownloader: OsmObjectDownloader | ||||
|     }) { | ||||
|         this.state = state | ||||
|     constructor(state: TagsUpdaterState) { | ||||
|         state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => { | ||||
|             if (!isLoggedIn && !Utils.runningFromConsole) { | ||||
|                 return | ||||
|             } | ||||
|             this.installCallback() | ||||
|             this.installCallback(state) | ||||
|             // We only have to do this once...
 | ||||
|             return true | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private installCallback() { | ||||
|         const state = this.state | ||||
|     private installCallback(state: TagsUpdaterState) { | ||||
|         state.selectedElement.addCallbackAndRunD(async (s) => { | ||||
|             let id = s.properties?.id | ||||
|             if (!id) { | ||||
|  | @ -94,7 +82,7 @@ export default class SelectedElementTagsUpdater { | |||
|                     oldFeature.geometry = newGeometry | ||||
|                     state.featureProperties.getStore(id)?.ping() | ||||
|                 } | ||||
|                 this.applyUpdate(latestTags, id) | ||||
|                 SelectedElementTagsUpdater.applyUpdate(latestTags, id, state) | ||||
| 
 | ||||
|                 console.log("Updated", id) | ||||
|             } catch (e) { | ||||
|  | @ -102,8 +90,7 @@ export default class SelectedElementTagsUpdater { | |||
|             } | ||||
|         }) | ||||
|     } | ||||
|     private applyUpdate(latestTags: OsmTags, id: string) { | ||||
|         const state = this.state | ||||
|     public static applyUpdate(latestTags: OsmTags, id: string, state: TagsUpdaterState) { | ||||
|         try { | ||||
|             const leftRightSensitive = state.layout.isLeftRightSensitive() | ||||
| 
 | ||||
|  | @ -162,11 +149,16 @@ export default class SelectedElementTagsUpdater { | |||
|             } | ||||
| 
 | ||||
|             if (somethingChanged) { | ||||
|                 console.log("Detected upstream changes to the object when opening it, updating...") | ||||
|                 console.log( | ||||
|                     "Detected upstream changes to the object " + | ||||
|                         id + | ||||
|                         " when opening it, updating..." | ||||
|                 ) | ||||
|                 currentTagsSource.ping() | ||||
|             } else { | ||||
|                 console.debug("Fetched latest tags for ", id, "but detected no changes") | ||||
|             } | ||||
|             return currentTags | ||||
|         } catch (e) { | ||||
|             console.error("Updating the tags of selected element ", id, "failed due to", e) | ||||
|         } | ||||
|  |  | |||
							
								
								
									
										220
									
								
								src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,220 @@ | |||
| import StaticFeatureSource from "./StaticFeatureSource" | ||||
| import { Feature } from "geojson" | ||||
| import { Store, Stores, UIEventSource } from "../../UIEventSource" | ||||
| import { OsmConnection } from "../../Osm/OsmConnection" | ||||
| import { OsmId } from "../../../Models/OsmFeature" | ||||
| import { GeoOperations } from "../../GeoOperations" | ||||
| import { IndexedFeatureSource } from "../FeatureSource" | ||||
| import OsmObjectDownloader from "../../Osm/OsmObjectDownloader" | ||||
| import { SpecialVisualizationState } from "../../../UI/SpecialVisualization" | ||||
| import SelectedElementTagsUpdater from "../../Actors/SelectedElementTagsUpdater" | ||||
| 
 | ||||
| /** | ||||
|  * Generates the favourites from the preferences and marks them as favourite | ||||
|  */ | ||||
| export default class FavouritesFeatureSource extends StaticFeatureSource { | ||||
|     public static readonly prefix = "mapcomplete-favourite-" | ||||
|     private readonly _osmConnection: OsmConnection | ||||
|     private readonly _detectedIds: Store<string[]> | ||||
| 
 | ||||
|     /** | ||||
|      * All favourites, including the ones which are filtered away because they are already displayed | ||||
|      */ | ||||
|     public readonly allFavourites: Store<Feature[]> | ||||
| 
 | ||||
|     constructor(state: SpecialVisualizationState) { | ||||
|         const features: Store<Feature[]> = Stores.ListStabilized( | ||||
|             state.osmConnection.preferencesHandler.preferences.map((prefs) => { | ||||
|                 const feats: Feature[] = [] | ||||
|                 const allIds = new Set<string>() | ||||
|                 for (const key in prefs) { | ||||
|                     if (!key.startsWith(FavouritesFeatureSource.prefix)) { | ||||
|                         continue | ||||
|                     } | ||||
| 
 | ||||
|                     try { | ||||
|                         const feat = FavouritesFeatureSource.ExtractFavourite(key, prefs) | ||||
|                         if (!feat) { | ||||
|                             continue | ||||
|                         } | ||||
|                         feats.push(feat) | ||||
|                         allIds.add(feat.properties.id) | ||||
|                     } catch (e) { | ||||
|                         console.error("Could not create favourite from", key, "due to", e) | ||||
|                     } | ||||
|                 } | ||||
|                 return feats | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         const featuresWithoutAlreadyPresent = features.map((features) => | ||||
|             features.filter( | ||||
|                 (feat) => !state.layout.layers.some((l) => l.id === feat.properties._orig_layer) | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         super(featuresWithoutAlreadyPresent) | ||||
|         this.allFavourites = features | ||||
| 
 | ||||
|         this._osmConnection = state.osmConnection | ||||
|         this._detectedIds = Stores.ListStabilized( | ||||
|             features.map((feats) => feats.map((f) => f.properties.id)) | ||||
|         ) | ||||
|         let allFeatures = state.indexedFeatures | ||||
|         this._detectedIds.addCallbackAndRunD((detected) => | ||||
|             this.markFeatures(detected, state.featureProperties, allFeatures) | ||||
|         ) | ||||
|         // We use the indexedFeatureSource as signal to update
 | ||||
|         allFeatures.features.map((_) => | ||||
|             this.markFeatures(this._detectedIds.data, state.featureProperties, allFeatures) | ||||
|         ) | ||||
| 
 | ||||
|         this.allFavourites.addCallbackD((features) => { | ||||
|             for (const feature of features) { | ||||
|                 this.updateFeature(feature, state.osmObjectDownloader, state) | ||||
|             } | ||||
| 
 | ||||
|             return true | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private async updateFeature( | ||||
|         feature: Feature, | ||||
|         osmObjectDownloader: OsmObjectDownloader, | ||||
|         state: SpecialVisualizationState | ||||
|     ) { | ||||
|         const id = feature.properties.id | ||||
|         const upstream = await osmObjectDownloader.DownloadObjectAsync(id) | ||||
|         if (upstream === "deleted") { | ||||
|             this.removeFavourite(feature) | ||||
|             return | ||||
|         } | ||||
|         console.log("Updating metadata due to favourite of", id) | ||||
|         const latestTags = SelectedElementTagsUpdater.applyUpdate(upstream.tags, id, state) | ||||
|         this.updatePropertiesOfFavourite(latestTags) | ||||
|     } | ||||
| 
 | ||||
|     private static ExtractFavourite(key: string, prefs: Record<string, string>): Feature { | ||||
|         const id = key.substring(FavouritesFeatureSource.prefix.length) | ||||
|         const osmId = id.replace("-", "/") | ||||
|         if (id.indexOf("-property-") > 0 || id.endsWith("-layer") || id.endsWith("-theme")) { | ||||
|             return undefined | ||||
|         } | ||||
|         const geometry = <[number, number]>JSON.parse(prefs[key]) | ||||
|         const properties = FavouritesFeatureSource.getPropertiesFor(prefs, id) | ||||
|         properties._orig_layer = prefs[FavouritesFeatureSource.prefix + id + "-layer"] | ||||
|         properties._orig_theme = prefs[FavouritesFeatureSource.prefix + id + "-theme"] | ||||
| 
 | ||||
|         properties.id = osmId | ||||
|         properties._favourite = "yes" | ||||
|         return { | ||||
|             type: "Feature", | ||||
|             properties, | ||||
|             geometry: { | ||||
|                 type: "Point", | ||||
|                 coordinates: geometry, | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static getPropertiesFor( | ||||
|         prefs: Record<string, string>, | ||||
|         id: string | ||||
|     ): Record<string, string> { | ||||
|         const properties: Record<string, string> = {} | ||||
|         const minLength = FavouritesFeatureSource.prefix.length + id.length + "-property-".length | ||||
|         for (const key in prefs) { | ||||
|             if (key.length < minLength) { | ||||
|                 continue | ||||
|             } | ||||
|             if (!key.startsWith(FavouritesFeatureSource.prefix + id)) { | ||||
|                 continue | ||||
|             } | ||||
|             const propertyName = key.substring(minLength).replaceAll("__", ":") | ||||
|             properties[propertyName] = prefs[key] | ||||
|         } | ||||
|         return properties | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sets all the (normal) properties as the feature is updated | ||||
|      */ | ||||
|     private updatePropertiesOfFavourite(properties: Record<string, string>) { | ||||
|         const id = properties?.id?.replace("/", "-") | ||||
|         if (!id) { | ||||
|             return | ||||
|         } | ||||
|         console.log("Updating store for", id) | ||||
|         for (const key in properties) { | ||||
|             const pref = this._osmConnection.GetPreference( | ||||
|                 "favourite-" + id + "-property-" + key.replaceAll(":", "__") | ||||
|             ) | ||||
|             const v = properties[key] | ||||
|             if (v === "" || !v) { | ||||
|                 continue | ||||
|             } | ||||
|             pref.setData("" + v) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public removeFavourite(feature: Feature, tags?: UIEventSource<Record<string, string>>) { | ||||
|         const id = feature.properties.id.replace("/", "-") | ||||
|         const pref = this._osmConnection.GetPreference("favourite-" + id) | ||||
|         this._osmConnection.preferencesHandler.removeAllWithPrefix("mapcomplete-favourite-" + id) | ||||
|         if (tags) { | ||||
|             delete tags.data._favourite | ||||
|             tags.ping() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public markAsFavourite( | ||||
|         feature: Feature, | ||||
|         layer: string, | ||||
|         theme: string, | ||||
|         tags: UIEventSource<Record<string, string> & { id: OsmId }>, | ||||
|         isFavourite: boolean = true | ||||
|     ) { | ||||
|         { | ||||
|             if (!isFavourite) { | ||||
|                 this.removeFavourite(feature, tags) | ||||
|                 return | ||||
|             } | ||||
|             const id = tags.data.id.replace("/", "-") | ||||
|             const pref = this._osmConnection.GetPreference("favourite-" + id) | ||||
|             const center = GeoOperations.centerpointCoordinates(feature) | ||||
|             pref.setData(JSON.stringify(center)) | ||||
|             this._osmConnection.GetPreference("favourite-" + id + "-layer").setData(layer) | ||||
|             this._osmConnection.GetPreference("favourite-" + id + "-theme").setData(theme) | ||||
|             this.updatePropertiesOfFavourite(tags.data) | ||||
|         } | ||||
|         tags.data._favourite = "yes" | ||||
|         tags.ping() | ||||
|     } | ||||
| 
 | ||||
|     private markFeatures( | ||||
|         detected: string[], | ||||
|         featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> }, | ||||
|         allFeatures: IndexedFeatureSource | ||||
|     ) { | ||||
|         const feature = allFeatures.features.data | ||||
|         for (const f of feature) { | ||||
|             const id = f.properties.id | ||||
|             if (!id) { | ||||
|                 continue | ||||
|             } | ||||
|             const store = featureProperties.getStore(id) | ||||
|             const origValue = store.data._favourite | ||||
|             if (detected.indexOf(id) >= 0) { | ||||
|                 if (origValue !== "yes") { | ||||
|                     store.data._favourite = "yes" | ||||
|                     store.ping() | ||||
|                 } | ||||
|             } else { | ||||
|                 if (origValue) { | ||||
|                     store.data._favourite = "" | ||||
|                     store.ping() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -6,10 +6,14 @@ import FilteringFeatureSource from "./FilteringFeatureSource" | |||
| import LayerState from "../../State/LayerState" | ||||
| 
 | ||||
| export default class NearbyFeatureSource implements FeatureSource { | ||||
|     private readonly _result = new UIEventSource<Feature[]>(undefined) | ||||
| 
 | ||||
|     public readonly features: Store<Feature[]> | ||||
|     private readonly _targetPoint: Store<{ lon: number; lat: number }> | ||||
|     private readonly _numberOfNeededFeatures: number | ||||
|     private readonly _layerState?: LayerState | ||||
|     private readonly _currentZoom: Store<number> | ||||
|     private readonly _allSources: Store<{ feat: Feature; d: number }[]>[] = [] | ||||
| 
 | ||||
|     constructor( | ||||
|         targetPoint: Store<{ lon: number; lat: number }>, | ||||
|  | @ -18,41 +22,44 @@ export default class NearbyFeatureSource implements FeatureSource { | |||
|         layerState?: LayerState, | ||||
|         currentZoom?: Store<number> | ||||
|     ) { | ||||
|         this._layerState = layerState | ||||
|         this._targetPoint = targetPoint.stabilized(100) | ||||
|         this._numberOfNeededFeatures = numberOfNeededFeatures | ||||
|         this._currentZoom = currentZoom.stabilized(500) | ||||
| 
 | ||||
|         const allSources: Store<{ feat: Feature; d: number }[]>[] = [] | ||||
|         let minzoom = 999 | ||||
| 
 | ||||
|         const result = new UIEventSource<Feature[]>(undefined) | ||||
|         this.features = Stores.ListStabilized(result) | ||||
| 
 | ||||
|         function update() { | ||||
|             let features: { feat: Feature; d: number }[] = [] | ||||
|             for (const src of allSources) { | ||||
|                 features.push(...src.data) | ||||
|             } | ||||
|             features.sort((a, b) => a.d - b.d) | ||||
|             if (numberOfNeededFeatures !== undefined) { | ||||
|                 features = features.slice(0, numberOfNeededFeatures) | ||||
|             } | ||||
|             result.setData(features.map((f) => f.feat)) | ||||
|         } | ||||
|         this.features = Stores.ListStabilized(this._result) | ||||
| 
 | ||||
|         sources.forEach((source, layer) => { | ||||
|             const flayer = layerState?.filteredLayers.get(layer) | ||||
|             minzoom = Math.min(minzoom, flayer.layerDef.minzoom) | ||||
|             this.registerSource(source, layer) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     public registerSource(source: FeatureSource, layerId: string) { | ||||
|         const flayer = this._layerState?.filteredLayers.get(layerId) | ||||
|         if (!flayer) { | ||||
|             return | ||||
|         } | ||||
|         const calcSource = this.createSource( | ||||
|             source.features, | ||||
|             flayer.layerDef.minzoom, | ||||
|             flayer.isDisplayed | ||||
|         ) | ||||
|         calcSource.addCallbackAndRunD((features) => { | ||||
|                 update() | ||||
|             }) | ||||
|             allSources.push(calcSource) | ||||
|             this.update() | ||||
|         }) | ||||
|         this._allSources.push(calcSource) | ||||
|     } | ||||
| 
 | ||||
|     private update() { | ||||
|         let features: { feat: Feature; d: number }[] = [] | ||||
|         for (const src of this._allSources) { | ||||
|             features.push(...src.data) | ||||
|         } | ||||
|         features.sort((a, b) => a.d - b.d) | ||||
|         if (this._numberOfNeededFeatures !== undefined) { | ||||
|             features = features.slice(0, this._numberOfNeededFeatures) | ||||
|         } | ||||
|         this._result.setData(features.map((f) => f.feat)) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -501,147 +501,43 @@ export class GeoOperations { | |||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     public static IdentifieCommonSegments(coordinatess: [number, number][][]): { | ||||
|         originalIndex: number | ||||
|         segmentShardWith: number[] | ||||
|         coordinates: [] | ||||
|     }[] { | ||||
|         // An edge. Note that the edge might be reversed to fix the sorting condition:  start[0] < end[0] && (start[0] != end[0] || start[0] < end[1])
 | ||||
|         type edge = { | ||||
|             start: [number, number] | ||||
|             end: [number, number] | ||||
|             intermediate: [number, number][] | ||||
|             members: { index: number; isReversed: boolean }[] | ||||
|     /** | ||||
|      * Given a list of points, convert into a GPX-list, e.g. for favourites | ||||
|      * @param locations | ||||
|      * @param title | ||||
|      */ | ||||
|     public static toGpxPoints( | ||||
|         locations: Feature<Point, { date?: string; altitude?: number | string }>[], | ||||
|         title?: string | ||||
|     ) { | ||||
|         title = title?.trim() | ||||
|         if (title === undefined || title === "") { | ||||
|             title = "Created with MapComplete" | ||||
|         } | ||||
| 
 | ||||
|         // The strategy:
 | ||||
|         // 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them
 | ||||
|         // 2. Join these edges back together - as long as their membership groups are the same
 | ||||
|         // 3. Convert to results
 | ||||
| 
 | ||||
|         const allEdgesByKey = new Map<string, edge>() | ||||
| 
 | ||||
|         for (let index = 0; index < coordinatess.length; index++) { | ||||
|             const coordinates = coordinatess[index] | ||||
|             for (let i = 0; i < coordinates.length - 1; i++) { | ||||
|                 const c0 = coordinates[i] | ||||
|                 const c1 = coordinates[i + 1] | ||||
|                 const isReversed = c0[0] > c1[0] || (c0[0] == c1[0] && c0[1] > c1[1]) | ||||
| 
 | ||||
|                 let key: string | ||||
|                 if (isReversed) { | ||||
|                     key = "" + c1 + ";" + c0 | ||||
|                 } else { | ||||
|                     key = "" + c0 + ";" + c1 | ||||
|                 } | ||||
|                 const member = { index, isReversed } | ||||
|                 if (allEdgesByKey.has(key)) { | ||||
|                     allEdgesByKey.get(key).members.push(member) | ||||
|                     continue | ||||
|                 } | ||||
| 
 | ||||
|                 let edge: edge | ||||
|                 if (!isReversed) { | ||||
|                     edge = { | ||||
|                         start: c0, | ||||
|                         end: c1, | ||||
|                         members: [member], | ||||
|                         intermediate: [], | ||||
|                     } | ||||
|                 } else { | ||||
|                     edge = { | ||||
|                         start: c1, | ||||
|                         end: c0, | ||||
|                         members: [member], | ||||
|                         intermediate: [], | ||||
|         title = Utils.EncodeXmlValue(title) | ||||
|         const trackPoints: string[] = [] | ||||
|         for (const l of locations) { | ||||
|             let trkpt = `    <wpt lat="${l.geometry.coordinates[1]}" lon="${l.geometry.coordinates[0]}">` | ||||
|             for (const key in l.properties) { | ||||
|                 const keyCleaned = key.replaceAll(":", "__") | ||||
|                 trkpt += `        <${keyCleaned}>${l.properties[key]}</${keyCleaned}>\n` | ||||
|                 if (key === "website") { | ||||
|                     trkpt += `        <link>${l.properties[key]}</link>\n` | ||||
|                 } | ||||
|             } | ||||
|                 allEdgesByKey.set(key, edge) | ||||
|             trkpt += "    </wpt>\n" | ||||
|             trackPoints.push(trkpt) | ||||
|         } | ||||
|         } | ||||
| 
 | ||||
|         // Lets merge them back together!
 | ||||
| 
 | ||||
|         let didMergeSomething = false | ||||
|         let allMergedEdges = Array.from(allEdgesByKey.values()) | ||||
|         const allEdgesByStartPoint = new Map<string, edge[]>() | ||||
|         for (const edge of allMergedEdges) { | ||||
|             edge.members.sort((m0, m1) => m0.index - m1.index) | ||||
| 
 | ||||
|             const kstart = edge.start + "" | ||||
|             if (!allEdgesByStartPoint.has(kstart)) { | ||||
|                 allEdgesByStartPoint.set(kstart, []) | ||||
|             } | ||||
|             allEdgesByStartPoint.get(kstart).push(edge) | ||||
|         } | ||||
| 
 | ||||
|         function membersAreCompatible(first: edge, second: edge): boolean { | ||||
|             // There must be an exact match between the members
 | ||||
|             if (first.members === second.members) { | ||||
|                 return true | ||||
|             } | ||||
| 
 | ||||
|             if (first.members.length !== second.members.length) { | ||||
|                 return false | ||||
|             } | ||||
| 
 | ||||
|             // Members are sorted and have the same length, so we can check quickly
 | ||||
|             for (let i = 0; i < first.members.length; i++) { | ||||
|                 const m0 = first.members[i] | ||||
|                 const m1 = second.members[i] | ||||
|                 if (m0.index !== m1.index || m0.isReversed !== m1.isReversed) { | ||||
|                     return false | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // Allrigth, they are the same, lets mark this permanently
 | ||||
|             second.members = first.members | ||||
|             return true | ||||
|         } | ||||
| 
 | ||||
|         do { | ||||
|             didMergeSomething = false | ||||
|             // We use 'allMergedEdges' as our running list
 | ||||
|             const consumed = new Set<edge>() | ||||
|             for (const edge of allMergedEdges) { | ||||
|                 // Can we make this edge longer at the end?
 | ||||
|                 if (consumed.has(edge)) { | ||||
|                     continue | ||||
|                 } | ||||
| 
 | ||||
|                 console.log("Considering edge", edge) | ||||
|                 const matchingEndEdges = allEdgesByStartPoint.get(edge.end + "") | ||||
|                 console.log("Matchign endpoints:", matchingEndEdges) | ||||
|                 if (matchingEndEdges === undefined) { | ||||
|                     continue | ||||
|                 } | ||||
| 
 | ||||
|                 for (let i = 0; i < matchingEndEdges.length; i++) { | ||||
|                     const endEdge = matchingEndEdges[i] | ||||
| 
 | ||||
|                     if (consumed.has(endEdge)) { | ||||
|                         continue | ||||
|                     } | ||||
| 
 | ||||
|                     if (!membersAreCompatible(edge, endEdge)) { | ||||
|                         continue | ||||
|                     } | ||||
| 
 | ||||
|                     // We can make the segment longer!
 | ||||
|                     didMergeSomething = true | ||||
|                     console.log("Merging ", edge, "with ", endEdge) | ||||
|                     edge.intermediate.push(edge.end) | ||||
|                     edge.end = endEdge.end | ||||
|                     consumed.add(endEdge) | ||||
|                     matchingEndEdges.splice(i, 1) | ||||
|                     break | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge)) | ||||
|         } while (didMergeSomething) | ||||
| 
 | ||||
|         return [] | ||||
|         const header = | ||||
|             '<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">' | ||||
|         return ( | ||||
|             header + | ||||
|             "\n<name>" + | ||||
|             title + | ||||
|             "</name>\n<trk><trkseg>\n" + | ||||
|             trackPoints.join("\n") + | ||||
|             "\n</trkseg></trk></gpx>" | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -107,7 +107,8 @@ export class ImageUploadManager { | |||
|             title, | ||||
|             description, | ||||
|             file, | ||||
|             targetKey | ||||
|             targetKey, | ||||
|             tags.data["_orig_theme"] | ||||
|         ) | ||||
|         if (!isNaN(Number(featureId))) { | ||||
|             // This is a map note
 | ||||
|  | @ -126,7 +127,8 @@ export class ImageUploadManager { | |||
|         title: string, | ||||
|         description: string, | ||||
|         blob: File, | ||||
|         targetKey: string | undefined | ||||
|         targetKey: string | undefined, | ||||
|         theme?: string | ||||
|     ): Promise<LinkImageAction> { | ||||
|         this.increaseCountFor(this._uploadStarted, featureId) | ||||
|         const properties = this._featureProperties.getStore(featureId) | ||||
|  | @ -148,7 +150,7 @@ export class ImageUploadManager { | |||
|         console.log("Uploading done, creating action for", featureId) | ||||
|         key = targetKey ?? key | ||||
|         const action = new LinkImageAction(featureId, key, value, properties, { | ||||
|             theme: this._layout.id, | ||||
|             theme: theme ?? this._layout.id, | ||||
|             changeType: "add-image", | ||||
|         }) | ||||
|         this.increaseCountFor(this._uploadFinished, featureId) | ||||
|  |  | |||
|  | @ -12,6 +12,10 @@ export class OsmPreferences { | |||
|         "all-osm-preferences", | ||||
|         {} | ||||
|     ) | ||||
|     /** | ||||
|      * A map containing the individual preference sources | ||||
|      * @private | ||||
|      */ | ||||
|     private readonly preferenceSources = new Map<string, UIEventSource<string>>() | ||||
|     private auth: any | ||||
|     private userDetails: UIEventSource<UserDetails> | ||||
|  | @ -21,7 +25,10 @@ export class OsmPreferences { | |||
|         this.auth = auth | ||||
|         this.userDetails = osmConnection.userDetails | ||||
|         const self = this | ||||
|         osmConnection.OnLoggedIn(() => self.UpdatePreferences()) | ||||
|         osmConnection.OnLoggedIn(() => { | ||||
|             self.UpdatePreferences(true) | ||||
|             return true | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -72,11 +79,19 @@ export class OsmPreferences { | |||
|             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... " | ||||
|                 } | ||||
|  | @ -197,7 +212,7 @@ export class OsmPreferences { | |||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private UpdatePreferences() { | ||||
|     private UpdatePreferences(forceUpdate?: boolean) { | ||||
|         const self = this | ||||
|         this.auth.xhr( | ||||
|             { | ||||
|  | @ -210,11 +225,22 @@ export class OsmPreferences { | |||
|                     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
 | ||||
|  | @ -285,4 +311,14 @@ export class OsmPreferences { | |||
|             } | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     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() | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -294,6 +294,9 @@ export default class UserRelatedState { | |||
|         osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => { | ||||
|             for (const k in newPrefs) { | ||||
|                 const v = newPrefs[k] | ||||
|                 if (v === "undefined" || !v) { | ||||
|                     continue | ||||
|                 } | ||||
|                 if (k.endsWith("-combined-length")) { | ||||
|                     const l = Number(v) | ||||
|                     const key = k.substring(0, k.length - "length".length) | ||||
|  | @ -308,7 +311,6 @@ export default class UserRelatedState { | |||
|             } | ||||
| 
 | ||||
|             amendedPrefs.ping() | ||||
|             console.log("Amended prefs are:", amendedPrefs.data) | ||||
|         }) | ||||
|         const translationMode = osmConnection.GetPreference("translation-mode") | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ import { Or } from "./Or" | |||
| import { TagUtils } from "./TagUtils" | ||||
| import { Tag } from "./Tag" | ||||
| import { RegexTag } from "./RegexTag" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| export class And extends TagsFilter { | ||||
|     public and: TagsFilter[] | ||||
|  | @ -72,6 +73,10 @@ export class And extends TagsFilter { | |||
|         return allChoices | ||||
|     } | ||||
| 
 | ||||
|     asJson(): TagConfigJson { | ||||
|         return { and: this.and.map((a) => a.asJson()) } | ||||
|     } | ||||
| 
 | ||||
|     asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) { | ||||
|         return this.and | ||||
|             .map((t) => { | ||||
|  | @ -228,6 +233,15 @@ export class And extends TagsFilter { | |||
|         return And.construct(newAnds) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * const raw = {"and": [{"or":["leisure=playground","playground!=forest"]},{"or":["leisure=playground","playground!=forest"]}]} | ||||
|      * const parsed = TagUtils.Tag(raw) | ||||
|      * parsed.optimize().asJson() // => {"or":["leisure=playground","playground!=forest"]}
 | ||||
|      * | ||||
|      * const raw = {"and": [{"and":["advertising=screen"]}, {"and":["advertising~*"]}]}] | ||||
|      * const parsed = TagUtils.Tag(raw) | ||||
|      * parsed.optimize().asJson() // => "advertising=screen"
 | ||||
|      */ | ||||
|     optimize(): TagsFilter | boolean { | ||||
|         if (this.and.length === 0) { | ||||
|             return true | ||||
|  | @ -289,9 +303,17 @@ export class And extends TagsFilter { | |||
|                             optimized.splice(i, 1) | ||||
|                             i-- | ||||
|                         } | ||||
|                     } else if (v !== opt.value) { | ||||
|                         // detected an internal conflict
 | ||||
|                     } else { | ||||
|                         if (!v.match(opt.value)) { | ||||
|                             // We _know_ that for the key of the RegexTag `opt`, the value will be `v`.
 | ||||
|                             // As such, if `opt.value` cannot match `v`, we detected an internal conflict and can fail
 | ||||
| 
 | ||||
|                             return false | ||||
|                         } else { | ||||
|                             // Another tag already provided a _stricter_ value then this regex, so we can remove this one!
 | ||||
|                             optimized.splice(i, 1) | ||||
|                             i-- | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | @ -369,10 +391,13 @@ export class And extends TagsFilter { | |||
|                     const elements = containedOr.or.filter( | ||||
|                         (candidate) => !commonValues.some((cv) => cv.shadows(candidate)) | ||||
|                     ) | ||||
|                     if (elements.length > 0) { | ||||
|                         newOrs.push(Or.construct(elements)) | ||||
|                     } | ||||
| 
 | ||||
|                 } | ||||
|                 if (newOrs.length > 0) { | ||||
|                     commonValues.push(And.construct(newOrs)) | ||||
|                 } | ||||
|                 const result = new Or(commonValues).optimize() | ||||
|                 if (result === false) { | ||||
|                     return false | ||||
|  |  | |||
|  | @ -1,18 +1,23 @@ | |||
| import { TagsFilter } from "./TagsFilter" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| import { Tag } from "./Tag" | ||||
| 
 | ||||
| export default class ComparingTag implements TagsFilter { | ||||
|     private readonly _key: string | ||||
|     private readonly _predicate: (value: string) => boolean | ||||
|     private readonly _representation: string | ||||
|     private readonly _representation: "<" | ">" | "<=" | ">=" | ||||
|     private readonly _boundary: string | ||||
| 
 | ||||
|     constructor( | ||||
|         key: string, | ||||
|         predicate: (value: string | undefined) => boolean, | ||||
|         representation: string = "" | ||||
|         representation: "<" | ">" | "<=" | ">=", | ||||
|         boundary: string | ||||
|     ) { | ||||
|         this._key = key | ||||
|         this._predicate = predicate | ||||
|         this._representation = representation | ||||
|         this._boundary = boundary | ||||
|     } | ||||
| 
 | ||||
|     asChange(properties: Record<string, string>): { k: string; v: string }[] { | ||||
|  | @ -20,15 +25,64 @@ export default class ComparingTag implements TagsFilter { | |||
|     } | ||||
| 
 | ||||
|     asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) { | ||||
|         return this._key + this._representation | ||||
|         return this._key + this._representation + this._boundary | ||||
|     } | ||||
| 
 | ||||
|     asOverpass(): string[] { | ||||
|         throw "A comparable tag can not be used as overpass filter" | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * const tg = new ComparingTag("key", value => (Number(value) < 42), "<", "42") | ||||
|      * const tg0 = new ComparingTag("key", value => (Number(value) < 42), "<", "42") | ||||
|      * const tg1 = new ComparingTag("key", value => (Number(value) <= 42), "<=", "42") | ||||
|      * const against = new ComparingTag("key", value => (Number(value) > 0), ">", "0") | ||||
|      * tg.shadows(new Tag("key", "41")) // => true
 | ||||
|      * tg.shadows(new Tag("key", "0")) // => true
 | ||||
|      * tg.shadows(new Tag("key", "43")) // => false
 | ||||
|      * tg.shadows(new Tag("key", "0")) // => true
 | ||||
|      * tg.shadows(tg) // => true
 | ||||
|      * tg.shadows(tg0) // => true
 | ||||
|      * tg.shadows(against) // => false
 | ||||
|      * tg1.shadows(tg0) // => true
 | ||||
|      * tg0.shadows(tg1) // => false
 | ||||
|      * | ||||
|      */ | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         return other === this | ||||
|         if (other === this) { | ||||
|             return true | ||||
|         } | ||||
|         if (other instanceof ComparingTag) { | ||||
|             if (other._key !== this._key) { | ||||
|                 return false | ||||
|             } | ||||
|             const selfDesc = this._representation === "<" || this._representation === "<=" | ||||
|             const otherDesc = other._representation === "<" || other._representation === "<=" | ||||
|             if (selfDesc !== otherDesc) { | ||||
|                 return false | ||||
|             } | ||||
|             if ( | ||||
|                 this._boundary === other._boundary && | ||||
|                 this._representation === other._representation | ||||
|             ) { | ||||
|                 return true | ||||
|             } | ||||
|             if (this._predicate(other._boundary)) { | ||||
|                 return true | ||||
|             } | ||||
|             return false | ||||
|         } | ||||
| 
 | ||||
|         if (other instanceof Tag) { | ||||
|             if (other.key !== this._key) { | ||||
|                 return false | ||||
|             } | ||||
|             if (this.matchesProperties({ [other.key]: other.value })) { | ||||
|                 return true | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return false | ||||
|     } | ||||
| 
 | ||||
|     isUsableAsAnswer(): boolean { | ||||
|  | @ -38,7 +92,7 @@ export default class ComparingTag implements TagsFilter { | |||
|     /** | ||||
|      * Checks if the properties match | ||||
|      * | ||||
|      * const t = new ComparingTag("key", (x => Number(x) < 42)) | ||||
|      * const t = new ComparingTag("key", (x => Number(x) < 42), "<", "42") | ||||
|      * t.matchesProperties({key: 42}) // => false
 | ||||
|      * t.matchesProperties({key: 41}) // => true
 | ||||
|      * t.matchesProperties({key: 0}) // => true
 | ||||
|  | @ -56,6 +110,10 @@ export default class ComparingTag implements TagsFilter { | |||
|         return [] | ||||
|     } | ||||
| 
 | ||||
|     asJson(): TagConfigJson { | ||||
|         return this._key + this._representation | ||||
|     } | ||||
| 
 | ||||
|     optimize(): TagsFilter | boolean { | ||||
|         return this | ||||
|     } | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import { TagsFilter } from "./TagsFilter" | ||||
| import { TagUtils } from "./TagUtils" | ||||
| import { And } from "./And" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| export class Or extends TagsFilter { | ||||
|     public or: TagsFilter[] | ||||
|  | @ -27,6 +28,10 @@ export class Or extends TagsFilter { | |||
|         return false | ||||
|     } | ||||
| 
 | ||||
|     asJson(): TagConfigJson { | ||||
|         return { or: this.or.map((o) => o.asJson()) } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * | ||||
|      * import {Tag} from "./Tag"; | ||||
|  | @ -157,6 +162,12 @@ export class Or extends TagsFilter { | |||
|         return Or.construct(newOrs) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * const raw = {"or": [{"and":["leisure=playground","playground!=forest"]},{"and":["leisure=playground","playground!=forest"]}]} | ||||
|      * const parsed = TagUtils.Tag(raw) | ||||
|      * parsed.optimize().asJson() // => {"and":["leisure=playground","playground!=forest"]}
 | ||||
|      * | ||||
|      */ | ||||
|     optimize(): TagsFilter | boolean { | ||||
|         if (this.or.length === 0) { | ||||
|             return false | ||||
|  | @ -174,9 +185,9 @@ export class Or extends TagsFilter { | |||
|         const newOrs: TagsFilter[] = [] | ||||
|         let containedAnds: And[] = [] | ||||
|         for (const tf of optimized) { | ||||
|             if (tf instanceof Or) { | ||||
|             if (tf["or"]) { | ||||
|                 // expand all the nested ors...
 | ||||
|                 newOrs.push(...tf.or) | ||||
|                 newOrs.push(...tf["or"]) | ||||
|             } else if (tf instanceof And) { | ||||
|                 // partition of all the ands
 | ||||
|                 containedAnds.push(tf) | ||||
|  | @ -191,7 +202,7 @@ export class Or extends TagsFilter { | |||
|                 const cleanedContainedANds: And[] = [] | ||||
|                 outer: for (let containedAnd of containedAnds) { | ||||
|                     for (const known of newOrs) { | ||||
|                         // input for optimazation: (K=V | (X=Y & K=V))
 | ||||
|                         // input for optimization: (K=V | (X=Y & K=V))
 | ||||
|                         // containedAnd: (X=Y & K=V)
 | ||||
|                         // newOrs (and thus known): (K=V) --> false
 | ||||
|                         const cleaned = containedAnd.removePhraseConsideredKnown(known, false) | ||||
|  | @ -236,16 +247,21 @@ export class Or extends TagsFilter { | |||
|                     const elements = containedAnd.and.filter( | ||||
|                         (candidate) => !commonValues.some((cv) => cv.shadows(candidate)) | ||||
|                     ) | ||||
|                     if (elements.length == 0) { | ||||
|                         continue | ||||
|                     } | ||||
|                     newAnds.push(And.construct(elements)) | ||||
|                 } | ||||
| 
 | ||||
|                 if (newAnds.length > 0) { | ||||
|                     commonValues.push(Or.construct(newAnds)) | ||||
|                 } | ||||
| 
 | ||||
|                 const result = new And(commonValues).optimize() | ||||
|                 if (result === true) { | ||||
|                     return true | ||||
|                 } else if (result === false) { | ||||
|                     // neutral element: skip
 | ||||
|                 } else { | ||||
|                 } else if (commonValues.length > 0) { | ||||
|                     newOrs.push(And.construct(commonValues)) | ||||
|                 } | ||||
|             } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { Tag } from "./Tag" | ||||
| import { TagsFilter } from "./TagsFilter" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| export class RegexTag extends TagsFilter { | ||||
|     public readonly key: RegExp | string | ||||
|  | @ -11,6 +12,9 @@ export class RegexTag extends TagsFilter { | |||
|         super() | ||||
|         this.key = key | ||||
|         this.value = value | ||||
|         if (this.value instanceof RegExp && ("" + this.value).startsWith("^(^(")) { | ||||
|             throw "Detected a duplicate start marker ^(^( in a regextag:" + this.value | ||||
|         } | ||||
|         this.invert = invert | ||||
|         this.matchesEmpty = RegexTag.doesMatch("", this.value) | ||||
|     } | ||||
|  | @ -41,11 +45,21 @@ export class RegexTag extends TagsFilter { | |||
|         return possibleRegex.test(fromTag) | ||||
|     } | ||||
| 
 | ||||
|     private static source(r: string | RegExp) { | ||||
|     private static source(r: string | RegExp, includeStartMarker: boolean = true) { | ||||
|         if (typeof r === "string") { | ||||
|             return r | ||||
|         } | ||||
|         return r.source | ||||
|         if (r === undefined) { | ||||
|             return undefined | ||||
|         } | ||||
|         const src = r.source | ||||
|         if (includeStartMarker) { | ||||
|             return src | ||||
|         } | ||||
|         if (src.startsWith("^(") && src.endsWith(")$")) { | ||||
|             return src.substring(2, src.length - 2) | ||||
|         } | ||||
|         return src | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -82,6 +96,24 @@ export class RegexTag extends TagsFilter { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * import { TagUtils } from "./TagUtils"; | ||||
|      * | ||||
|      * const t = TagUtils.Tag("a~b") | ||||
|      * t.asJson() // => "a~b"
 | ||||
|      * | ||||
|      * const t = TagUtils.Tag("a=") | ||||
|      * t.asJson() // => "a="
 | ||||
|      */ | ||||
|     asJson(): TagConfigJson { | ||||
|         const v = RegexTag.source(this.value, false) | ||||
|         if (typeof this.key === "string") { | ||||
|             const oper = typeof this.value === "string" ? "=" : "~" | ||||
|             return `${this.key}${this.invert ? "!" : ""}${oper}${v}` | ||||
|         } | ||||
|         return `${this.key.source}${this.invert ? "!" : ""}~~${v}` | ||||
|     } | ||||
| 
 | ||||
|     isUsableAsAnswer(): boolean { | ||||
|         return false | ||||
|     } | ||||
|  | @ -293,7 +325,7 @@ export class RegexTag extends TagsFilter { | |||
|         if (typeof this.key === "string") { | ||||
|             return [this.key] | ||||
|         } | ||||
|         throw "Key cannot be determined as it is a regex" | ||||
|         return [] | ||||
|     } | ||||
| 
 | ||||
|     usedTags(): { key: string; value: string }[] { | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import { TagsFilter } from "./TagsFilter" | ||||
| import { Tag } from "./Tag" | ||||
| import { Utils } from "../../Utils" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| /** | ||||
|  * The substituting-tag uses the tags of a feature a variables and replaces them. | ||||
|  | @ -45,6 +46,10 @@ export default class SubstitutingTag implements TagsFilter { | |||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     asJson(): TagConfigJson { | ||||
|         return this._key + (this._invert ? "!" : "") + ":=" + this._value | ||||
|     } | ||||
| 
 | ||||
|     asOverpass(): string[] { | ||||
|         throw "A variable with substitution can not be used to query overpass" | ||||
|     } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { Utils } from "../../Utils" | ||||
| import { TagsFilter } from "./TagsFilter" | ||||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| export class Tag extends TagsFilter { | ||||
|     public key: string | ||||
|  | @ -67,6 +68,10 @@ export class Tag extends TagsFilter { | |||
|         return [`["${this.key}"="${this.value}"]`] | ||||
|     } | ||||
| 
 | ||||
|     asJson(): TagConfigJson { | ||||
|         return this.key + "=" + this.value | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| 
 | ||||
|      const t = new Tag("key", "value") | ||||
|  |  | |||
|  | @ -15,8 +15,9 @@ type Tags = Record<string, string> | |||
| export type UploadableTag = Tag | SubstitutingTag | And | ||||
| 
 | ||||
| export class TagUtils { | ||||
|     public static readonly comparators: ReadonlyArray<[string, (a: number, b: number) => boolean]> = | ||||
|         [ | ||||
|     public static readonly comparators: ReadonlyArray< | ||||
|         ["<" | ">" | "<=" | ">=", (a: number, b: number) => boolean] | ||||
|     > = [ | ||||
|         ["<=", (a, b) => a <= b], | ||||
|         [">=", (a, b) => a >= b], | ||||
|         ["<", (a, b) => a < b], | ||||
|  | @ -324,6 +325,14 @@ export class TagUtils { | |||
|         return tags | ||||
|     } | ||||
| 
 | ||||
|     static optimzeJson(json: TagConfigJson): TagConfigJson | boolean { | ||||
|         const optimized = TagUtils.Tag(json).optimize() | ||||
|         if (optimized === true || optimized === false) { | ||||
|             return optimized | ||||
|         } | ||||
|         return optimized.asJson() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set. | ||||
|      * | ||||
|  | @ -735,11 +744,10 @@ export class TagUtils { | |||
|         const tag = json as string | ||||
|         for (const [operator, comparator] of TagUtils.comparators) { | ||||
|             if (tag.indexOf(operator) >= 0) { | ||||
|                 const split = Utils.SplitFirst(tag, operator) | ||||
| 
 | ||||
|                 let val = Number(split[1].trim()) | ||||
|                 const split = Utils.SplitFirst(tag, operator).map((v) => v.trim()) | ||||
|                 let val = Number(split[1]) | ||||
|                 if (isNaN(val)) { | ||||
|                     val = new Date(split[1].trim()).getTime() | ||||
|                     val = new Date(split[1]).getTime() | ||||
|                 } | ||||
| 
 | ||||
|                 const f = (value: string | number | undefined) => { | ||||
|  | @ -762,7 +770,7 @@ export class TagUtils { | |||
|                     } | ||||
|                     return comparator(b, val) | ||||
|                 } | ||||
|                 return new ComparingTag(split[0], f, operator + val) | ||||
|                 return new ComparingTag(split[0], f, operator, "" + val) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|  | @ -861,6 +869,27 @@ export class TagUtils { | |||
|         return TagUtils.keyCounts.keys[key] | ||||
|     } | ||||
| 
 | ||||
|     public static GetPopularity(tag: TagsFilter): number | undefined { | ||||
|         if (tag instanceof And) { | ||||
|             return Math.min(...Utils.NoNull(tag.and.map((t) => TagUtils.GetPopularity(t)))) - 1 | ||||
|         } | ||||
|         if (tag instanceof Or) { | ||||
|             return Math.max(...Utils.NoNull(tag.or.map((t) => TagUtils.GetPopularity(t)))) + 1 | ||||
|         } | ||||
|         if (tag instanceof Tag) { | ||||
|             return TagUtils.GetCount(tag.key, tag.value) | ||||
|         } | ||||
|         if (tag instanceof RegexTag) { | ||||
|             const key = tag.key | ||||
|             if (key instanceof RegExp || tag.invert || tag.isNegative()) { | ||||
|                 return undefined | ||||
|             } | ||||
|             return TagUtils.GetCount(key) | ||||
|         } | ||||
| 
 | ||||
|         return undefined | ||||
|     } | ||||
| 
 | ||||
|     private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number { | ||||
|         const rta = a instanceof RegexTag | ||||
|         const rtb = b instanceof RegexTag | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" | ||||
| 
 | ||||
| export abstract class TagsFilter { | ||||
|     abstract asOverpass(): string[] | ||||
| 
 | ||||
|  | @ -17,6 +19,8 @@ export abstract class TagsFilter { | |||
|         properties: Record<string, string> | ||||
|     ): string | ||||
| 
 | ||||
|     abstract asJson(): TagConfigJson | ||||
| 
 | ||||
|     abstract usedKeys(): string[] | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export class MangroveIdentity { | |||
|         const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined) | ||||
|         this.keypair = keypairEventSource | ||||
|         mangroveIdentity.addCallbackAndRunD(async (data) => { | ||||
|             if (data === "") { | ||||
|             if (!data) { | ||||
|                 return | ||||
|             } | ||||
|             const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data)) | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ export default class Constants { | |||
|         "gps_track", | ||||
|         "range", | ||||
|         "last_click", | ||||
|         "favourite", | ||||
|     ] as const | ||||
|     /** | ||||
|      * Special layers which are not included in a theme by default | ||||
|  | @ -131,6 +132,8 @@ export default class Constants { | |||
|         "clock", | ||||
|         "invalid", | ||||
|         "close", | ||||
|         "heart", | ||||
|         "heart_outline", | ||||
|     ] as const | ||||
|     public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ export class MenuState { | |||
|     public static readonly _menuviewTabs = [ | ||||
|         "about", | ||||
|         "settings", | ||||
|         "favourites", | ||||
|         "community", | ||||
|         "privacy", | ||||
|         "advanced", | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import { LayerConfigJson } from "../Json/LayerConfigJson" | |||
| import { Utils } from "../../../Utils" | ||||
| import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" | ||||
| import { ConversionContext } from "./ConversionContext" | ||||
| import { T } from "vitest/dist/types-aac763a5" | ||||
| 
 | ||||
| export interface DesugaringContext { | ||||
|     tagRenderings: Map<string, QuestionableTagRenderingConfigJson> | ||||
|  | @ -81,18 +82,36 @@ export class Pure<TIn, TOut> extends Conversion<TIn, TOut> { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| export class Bypass<T> extends DesugaringStep<T> { | ||||
|     private readonly _applyIf: (t: T) => boolean | ||||
|     private readonly _step: DesugaringStep<T> | ||||
|     constructor(applyIf: (t: T) => boolean, step: DesugaringStep<T>) { | ||||
|         super("Applies the step on the object, if the object satisfies the predicate", [], "Bypass") | ||||
|         this._applyIf = applyIf | ||||
|         this._step = step | ||||
|     } | ||||
| 
 | ||||
|     convert(json: T, context: ConversionContext): T { | ||||
|         if (!this._applyIf(json)) { | ||||
|             return json | ||||
|         } | ||||
|         return this._step.convert(json, context) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export class Each<X, Y> extends Conversion<X[], Y[]> { | ||||
|     private readonly _step: Conversion<X, Y> | ||||
|     private readonly _msg: string | ||||
|     private readonly _filter: (x: X) => boolean | ||||
| 
 | ||||
|     constructor(step: Conversion<X, Y>, msg?: string) { | ||||
|     constructor(step: Conversion<X, Y>, options?: { msg?: string }) { | ||||
|         super( | ||||
|             "Applies the given step on every element of the list", | ||||
|             [], | ||||
|             "OnEach(" + step.name + ")" | ||||
|         ) | ||||
|         this._step = step | ||||
|         this._msg = msg | ||||
|         this._msg = options?.msg | ||||
|     } | ||||
| 
 | ||||
|     convert(values: X[], context: ConversionContext): Y[] { | ||||
|  |  | |||
|  | @ -85,7 +85,7 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfigJson, L | |||
|             description: trs(t.description, { title: layer.title.render }), | ||||
|             source: { | ||||
|                 osmTags: { | ||||
|                     and: ["id~*"], | ||||
|                     and: ["id~[0-9]+", "comment_url~.*notes/[0-9]*g.json"], | ||||
|                 }, | ||||
|                 geoJson: | ||||
|                     "https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed=" + | ||||
|  |  | |||
|  | @ -10,7 +10,10 @@ 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" | ||||
|  | @ -563,6 +566,16 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> { | |||
| } | ||||
| 
 | ||||
| export class AddEditingElements extends DesugaringStep<LayerConfigJson> { | ||||
|     static addedElements: string[] = [ | ||||
|         "minimap", | ||||
|         "just_created", | ||||
|         "split_button", | ||||
|         "move_button", | ||||
|         "delete_button", | ||||
|         "last_edit", | ||||
|         "favourite_state", | ||||
|         "all_tags", | ||||
|     ] | ||||
|     private readonly _desugaring: DesugaringContext | ||||
| 
 | ||||
|     constructor(desugaring: DesugaringContext) { | ||||
|  | @ -636,6 +649,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> { | |||
|             json.tagRenderings.push(this._desugaring.tagRenderings.get("last_edit")) | ||||
|         } | ||||
| 
 | ||||
|         if (!usedSpecialFunctions.has("favourite_status")) { | ||||
|             json.tagRenderings.push({ | ||||
|                 id: "favourite_status", | ||||
|                 render: { "*": "{favourite_status()}" }, | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         if (!usedSpecialFunctions.has("all_tags")) { | ||||
|             const trc: QuestionableTagRenderingConfigJson = { | ||||
|                 id: "all-tags", | ||||
|  | @ -1190,6 +1210,31 @@ class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> { | ||||
|     constructor() { | ||||
|         super( | ||||
|             "Adds the favourite heart to the title and the rendering badges", | ||||
|             [], | ||||
|             "AddFavouriteBadges" | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson { | ||||
|         if (json.source === "special" || json.source === "special:library") { | ||||
|             return json | ||||
|         } | ||||
|         const pr = json.pointRendering?.[0] | ||||
|         if (pr) { | ||||
|             pr.iconBadges ??= [] | ||||
|             if (!pr.iconBadges.some((ti) => ti.if === "_favourite=yes")) { | ||||
|                 pr.iconBadges.push({ if: "_favourite=yes", then: "circle:white;heart:red" }) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return json | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export class AddRatingBadge extends DesugaringStep<LayerConfigJson> { | ||||
|     constructor() { | ||||
|         super( | ||||
|  | @ -1203,6 +1248,10 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> { | |||
|         if (!json.tagRenderings) { | ||||
|             return json | ||||
|         } | ||||
|         if (json.titleIcons.some((ti) => ti === "icons.rating" || ti["id"] === "rating")) { | ||||
|             // already added
 | ||||
|             return json | ||||
|         } | ||||
| 
 | ||||
|         const specialVis: Exclude<RenderingSpecification, string>[] = < | ||||
|             Exclude<RenderingSpecification, string>[] | ||||
|  | @ -1238,23 +1287,28 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> { | |||
|                 continue | ||||
|             } | ||||
|             const trId = titleIcon.substring("auto:".length) | ||||
|             const tr = <QuestionableTagRenderingConfigJson>json.tagRenderings.find((tr) => tr["id"] === trId) | ||||
|             const tr = <QuestionableTagRenderingConfigJson>( | ||||
|                 json.tagRenderings.find((tr) => tr["id"] === trId) | ||||
|             ) | ||||
|             if (tr === undefined) { | ||||
|                 context | ||||
|                     .enters("titleIcons", i) | ||||
|                     .err("TagRendering with id " + trId + " not found") | ||||
|                 context.enters("titleIcons", i).err("TagRendering with id " + trId + " not found") | ||||
|                 continue | ||||
|             } | ||||
|             const mappings: { if: TagConfigJson, then: string }[] = tr.mappings?.filter(m => m.icon !== undefined) | ||||
|                 .map(m => { | ||||
|             const mappings: { if: TagConfigJson; then: string }[] = tr.mappings | ||||
|                 ?.filter((m) => m.icon !== undefined) | ||||
|                 .map((m) => { | ||||
|                     const path: string = typeof m.icon === "string" ? m.icon : m.icon.path | ||||
|                     const img = `<img class="m-1 h-6 w-6 low-interaction rounded" src='${path}'/>` | ||||
|                     return ({ if: m.if, then: img }) | ||||
|                     return { if: m.if, then: img } | ||||
|                 }) | ||||
|             if (mappings.length === 0) { | ||||
|                 context | ||||
|                     .enters("titleIcons", i) | ||||
|                     .warn("TagRendering with id " + trId + " does not have any icons, not generating an icon for this") | ||||
|                     .warn( | ||||
|                         "TagRendering with id " + | ||||
|                             trId + | ||||
|                             " does not have any icons, not generating an icon for this" | ||||
|                     ) | ||||
|                 continue | ||||
|             } | ||||
|             json.titleIcons[i] = <TagRenderingConfigJson>{ | ||||
|  | @ -1292,6 +1346,7 @@ export class PrepareLayer extends Fuse<LayerConfigJson> { | |||
|             ), | ||||
|             new SetDefault("titleIcons", ["icons.defaults"]), | ||||
|             new AddRatingBadge(), | ||||
|             new AddFavouriteBadges(), | ||||
|             new AutoTitleIcon(), | ||||
|             new On( | ||||
|                 "titleIcons", | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Conversion, DesugaringStep, Each, Fuse, On, Pipe, Pure } from "./Conversion" | ||||
| import { Bypass, Conversion, DesugaringStep, Each, Fuse, On } from "./Conversion" | ||||
| import { LayerConfigJson } from "../Json/LayerConfigJson" | ||||
| import LayerConfig from "../LayerConfig" | ||||
| import { Utils } from "../../../Utils" | ||||
|  | @ -11,7 +11,6 @@ import { TagUtils } from "../../../Logic/Tags/TagUtils" | |||
| import { ExtractImages } from "./FixImages" | ||||
| import { And } from "../../../Logic/Tags/And" | ||||
| import Translations from "../../../UI/i18n/Translations" | ||||
| import Svg from "../../../Svg" | ||||
| import FilterConfigJson from "../Json/FilterConfigJson" | ||||
| import DeleteConfig from "../DeleteConfig" | ||||
| import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" | ||||
|  | @ -23,7 +22,7 @@ import { TagsFilter } from "../../../Logic/Tags/TagsFilter" | |||
| import { Translatable } from "../Json/Translatable" | ||||
| import { ConversionContext } from "./ConversionContext" | ||||
| 
 | ||||
| class ValidateLanguageCompleteness extends DesugaringStep<any> { | ||||
| class ValidateLanguageCompleteness extends DesugaringStep<LayoutConfig> { | ||||
|     private readonly _languages: string[] | ||||
| 
 | ||||
|     constructor(...languages: string[]) { | ||||
|  | @ -35,7 +34,9 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> { | |||
|         this._languages = languages ?? ["en"] | ||||
|     } | ||||
| 
 | ||||
|     convert(obj: any, context: ConversionContext): LayerConfig { | ||||
|     convert(obj: LayoutConfig, context: ConversionContext): LayoutConfig { | ||||
|         const origLayers = obj.layers | ||||
|         obj.layers = [...obj.layers].filter((l) => l["id"] !== "favourite") | ||||
|         const translations = Translation.ExtractAllTranslationsFrom(obj) | ||||
|         for (const neededLanguage of this._languages) { | ||||
|             translations | ||||
|  | @ -57,7 +58,7 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> { | |||
|                         ) | ||||
|                 }) | ||||
|         } | ||||
| 
 | ||||
|         obj.layers = origLayers | ||||
|         return obj | ||||
|     } | ||||
| } | ||||
|  | @ -276,9 +277,9 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> { | |||
|             new On( | ||||
|                 "layers", | ||||
|                 new Each( | ||||
|                     new Pipe( | ||||
|                         new ValidateLayer(undefined, isBuiltin, doesImageExist, false, true), | ||||
|                         new Pure((x) => x?.raw) | ||||
|                     new Bypass( | ||||
|                         (layer) => Constants.added_by_default.indexOf(<any>layer.id) < 0, | ||||
|                         new ValidateLayerConfig(undefined, isBuiltin, doesImageExist, false, true) | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|  | @ -974,7 +975,7 @@ export class ValidateTagRenderings extends Fuse<TagRenderingConfigJson> { | |||
|             "Various validation on tagRenderingConfigs", | ||||
|             new DetectShadowedMappings(layerConfig), | ||||
|             new DetectConflictingAddExtraTags(), | ||||
|             new DetectNonErasedKeysInMappings(), | ||||
|             // TODO enable   new DetectNonErasedKeysInMappings(),
 | ||||
|             new DetectMappingsWithImages(doesImageExist), | ||||
|             new On("render", new ValidatePossibleLinks()), | ||||
|             new On("question", new ValidatePossibleLinks()), | ||||
|  | @ -1356,6 +1357,34 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| export class ValidateLayerConfig extends DesugaringStep<LayerConfigJson> { | ||||
|     private readonly validator: ValidateLayer | ||||
|     constructor( | ||||
|         path: string, | ||||
|         isBuiltin: boolean, | ||||
|         doesImageExist: DoesImageExist, | ||||
|         studioValidations: boolean = false, | ||||
|         skipDefaultLayers: boolean = false | ||||
|     ) { | ||||
|         super("Thin wrapper around 'ValidateLayer", [], "ValidateLayerConfig") | ||||
|         this.validator = new ValidateLayer( | ||||
|             path, | ||||
|             isBuiltin, | ||||
|             doesImageExist, | ||||
|             studioValidations, | ||||
|             skipDefaultLayers | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson { | ||||
|         const prepared = this.validator.convert(json, context) | ||||
|         if (!prepared) { | ||||
|             context.err("Preparing layer failed") | ||||
|             return undefined | ||||
|         } | ||||
|         return prepared?.raw | ||||
|     } | ||||
| } | ||||
| export class ValidateLayer extends Conversion< | ||||
|     LayerConfigJson, | ||||
|     { parsed: LayerConfig; raw: LayerConfigJson } | ||||
|  |  | |||
|  | @ -245,7 +245,7 @@ export interface LayerConfigJson { | |||
|      * Type: icon[] | ||||
|      * group: infobox | ||||
|      */ | ||||
|     titleIcons?: (string | TagRenderingConfigJson)[] | ["defaults"] | ||||
|     titleIcons?: (string | (TagRenderingConfigJson & { id?: string }))[] | ["defaults"] | ||||
| 
 | ||||
|     /** | ||||
|      * Creates points to render on the map. | ||||
|  |  | |||
|  | @ -305,6 +305,9 @@ export default class LayoutConfig implements LayoutInformation { | |||
|         } | ||||
|         for (const layer of this.layers) { | ||||
|             if (!layer.source) { | ||||
|                 if (layer.isShown?.matchesProperties(tags)) { | ||||
|                     return layer | ||||
|                 } | ||||
|                 continue | ||||
|             } | ||||
|             if (layer.source.osmTags.matchesProperties(tags)) { | ||||
|  |  | |||
|  | @ -16,10 +16,10 @@ import { | |||
| } from "./Json/QuestionableTagRenderingConfigJson" | ||||
| import { FixedUiElement } from "../../UI/Base/FixedUiElement" | ||||
| import { Paragraph } from "../../UI/Base/Paragraph" | ||||
| import Svg from "../../Svg" | ||||
| import Validators, { ValidatorType } from "../../UI/InputElement/Validators" | ||||
| import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" | ||||
| import Constants from "../Constants" | ||||
| import { RegexTag } from "../../Logic/Tags/RegexTag" | ||||
| 
 | ||||
| export interface Icon {} | ||||
| 
 | ||||
|  | @ -800,4 +800,25 @@ export default class TagRenderingConfig { | |||
|             labels, | ||||
|         ]).SetClass("flex flex-col") | ||||
|     } | ||||
| 
 | ||||
|     public usedTags(): TagsFilter[] { | ||||
|         const tags: TagsFilter[] = [] | ||||
|         tags.push( | ||||
|             this.metacondition, | ||||
|             this.condition, | ||||
|             this.freeform?.key ? new RegexTag(this.freeform?.key, /.*/) : undefined, | ||||
|             this.invalidValues | ||||
|         ) | ||||
|         for (const m of this.mappings ?? []) { | ||||
|             tags.push(m.if) | ||||
|             tags.push(m.priorityIf) | ||||
|             tags.push(...(m.addExtraTags ?? [])) | ||||
|             if (typeof m.hideInAnswer !== "boolean") { | ||||
|                 tags.push(m.hideInAnswer) | ||||
|             } | ||||
|             tags.push(m.ifnot) | ||||
|         } | ||||
| 
 | ||||
|         return Utils.NoNull(tags) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -58,6 +58,7 @@ import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLay | |||
| import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" | ||||
| import { Imgur } from "../Logic/ImageProviders/Imgur" | ||||
| import NearbyFeatureSource from "../Logic/FeatureSource/Sources/NearbyFeatureSource" | ||||
| import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource" | ||||
| 
 | ||||
| /** | ||||
|  * | ||||
|  | @ -96,10 +97,11 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|     readonly indexedFeatures: IndexedFeatureSource & LayoutSource | ||||
|     readonly currentView: FeatureSource<Feature<Polygon>> | ||||
|     readonly featuresInView: FeatureSource | ||||
|     readonly favourites: FavouritesFeatureSource | ||||
|     /** | ||||
|      * Contains a few (<10) >features that are near the center of the map. | ||||
|      */ | ||||
|     readonly closestFeatures: FeatureSource | ||||
|     readonly closestFeatures: NearbyFeatureSource | ||||
|     readonly newFeatures: WritableFeatureSource | ||||
|     readonly layerState: LayerState | ||||
|     readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | ||||
|  | @ -220,8 +222,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|                 this.fullNodeDatabase | ||||
|             ) | ||||
| 
 | ||||
|             this.indexedFeatures = layoutSource | ||||
| 
 | ||||
|             let currentViewIndex = 0 | ||||
|             const empty = [] | ||||
|             this.currentView = new StaticFeatureSource( | ||||
|  | @ -242,13 +242,13 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds) | ||||
| 
 | ||||
|             this.dataIsLoading = layoutSource.isLoading | ||||
|             this.indexedFeatures = layoutSource | ||||
|             this.featureProperties = new FeaturePropertiesStore(layoutSource) | ||||
| 
 | ||||
|             const indexedElements = this.indexedFeatures | ||||
|             this.featureProperties = new FeaturePropertiesStore(indexedElements) | ||||
|             this.changes = new Changes( | ||||
|                 { | ||||
|                     dryRun: this.featureSwitches.featureSwitchIsTesting, | ||||
|                     allElements: indexedElements, | ||||
|                     allElements: layoutSource, | ||||
|                     featurePropertiesStore: this.featureProperties, | ||||
|                     osmConnection: this.osmConnection, | ||||
|                     historicalUserLocations: this.geolocation.historicalUserLocations, | ||||
|  | @ -258,7 +258,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             this.historicalUserLocations = this.geolocation.historicalUserLocations | ||||
|             this.newFeatures = new NewGeometryFromChangesFeatureSource( | ||||
|                 this.changes, | ||||
|                 indexedElements, | ||||
|                 layoutSource, | ||||
|                 this.featureProperties | ||||
|             ) | ||||
|             layoutSource.addSource(this.newFeatures) | ||||
|  | @ -327,10 +327,10 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             return sorted | ||||
|         }) | ||||
| 
 | ||||
|         const lastClick = (this.lastClickObject = new LastClickFeatureSource( | ||||
|         this.lastClickObject = new LastClickFeatureSource( | ||||
|             this.mapProperties.lastClickLocation, | ||||
|             this.layout | ||||
|         )) | ||||
|         ) | ||||
| 
 | ||||
|         this.osmObjectDownloader = new OsmObjectDownloader( | ||||
|             this.osmConnection.Backend(), | ||||
|  | @ -353,6 +353,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             this.osmConnection, | ||||
|             this.changes | ||||
|         ) | ||||
|         this.favourites = new FavouritesFeatureSource(this) | ||||
| 
 | ||||
|         this.initActors() | ||||
|         this.drawSpecialLayers() | ||||
|  | @ -456,6 +457,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|      * @private | ||||
|      */ | ||||
|     private selectClosestAtCenter(i: number = 0) { | ||||
|         this.mapProperties.lastKeyNavigation.setData(Date.now() / 1000) | ||||
|         const toSelect = this.closestFeatures.features.data[i] | ||||
|         if (!toSelect) { | ||||
|             return | ||||
|  | @ -465,6 +467,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|         this.selectedLayer.setData(layer) | ||||
|         this.selectedElement.setData(toSelect) | ||||
|     } | ||||
| 
 | ||||
|     private initHotkeys() { | ||||
|         Hotkeys.RegisterHotkey( | ||||
|             { nomod: "Escape", onUp: true }, | ||||
|  | @ -476,6 +479,15 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             } | ||||
|         ) | ||||
| 
 | ||||
|         Hotkeys.RegisterHotkey( | ||||
|             { nomod: "f" }, | ||||
|             Translations.t.hotkeyDocumentation.selectFavourites, | ||||
|             () => { | ||||
|                 this.guistate.menuViewTab.setData("favourites") | ||||
|                 this.guistate.menuIsOpened.setData(true) | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|         this.mapProperties.lastKeyNavigation.addCallbackAndRunD((_) => { | ||||
|             Hotkeys.RegisterHotkey( | ||||
|                 { | ||||
|  | @ -561,46 +573,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private addLastClick(last_click: LastClickFeatureSource) { | ||||
|         // The last_click gets a _very_ special treatment as it interacts with various parts
 | ||||
| 
 | ||||
|         this.featureProperties.trackFeatureSource(last_click) | ||||
|         this.indexedFeatures.addSource(last_click) | ||||
| 
 | ||||
|         last_click.features.addCallbackAndRunD((features) => { | ||||
|             if (this.selectedLayer.data?.id === "last_click") { | ||||
|                 // The last-click location moved, but we have selected the last click of the previous location
 | ||||
|                 // So, we update _after_ clearing the selection to make sure no stray data is sticking around
 | ||||
|                 this.selectedElement.setData(undefined) | ||||
|                 this.selectedElement.setData(features[0]) | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
|         new ShowDataLayer(this.map, { | ||||
|             features: new FilteringFeatureSource(this.newPointDialog, last_click), | ||||
|             doShowLayer: this.featureSwitches.featureSwitchEnableLogin, | ||||
|             layer: this.newPointDialog.layerDef, | ||||
|             selectedElement: this.selectedElement, | ||||
|             selectedLayer: this.selectedLayer, | ||||
|             metaTags: this.userRelatedState.preferencesAsTags, | ||||
|             onClick: (feature: Feature) => { | ||||
|                 if (this.mapProperties.zoom.data < Constants.minZoomLevelToAddNewPoint) { | ||||
|                     this.map.data.flyTo({ | ||||
|                         zoom: Constants.minZoomLevelToAddNewPoint, | ||||
|                         center: this.mapProperties.lastClickLocation.data, | ||||
|                     }) | ||||
|                     return | ||||
|                 } | ||||
|                 // We first clear the selection to make sure no weird state is around
 | ||||
|                 this.selectedLayer.setData(undefined) | ||||
|                 this.selectedElement.setData(undefined) | ||||
| 
 | ||||
|                 this.selectedElement.setData(feature) | ||||
|                 this.selectedLayer.setData(this.newPointDialog.layerDef) | ||||
|             }, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add the special layers to the map | ||||
|      */ | ||||
|  | @ -627,7 +599,10 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|                 ) | ||||
|             ), | ||||
|             current_view: this.currentView, | ||||
|             favourite: this.favourites, | ||||
|         } | ||||
| 
 | ||||
|         this.closestFeatures.registerSource(specialLayers.favourite, "favourite") | ||||
|         if (this.layout?.lockLocation) { | ||||
|             const bbox = new BBox(this.layout.lockLocation) | ||||
|             this.mapProperties.maxbounds.setData(bbox) | ||||
|  | @ -654,21 +629,23 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|         } | ||||
| 
 | ||||
|         const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range") | ||||
| 
 | ||||
|         const rangeIsDisplayed = rangeFLayer?.isDisplayed | ||||
| 
 | ||||
|         if ( | ||||
|             !QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef)) | ||||
|         ) { | ||||
|             rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true) | ||||
|         } | ||||
| 
 | ||||
|         // enumarate all 'normal' layers and match them with the appropriate 'special' layer - if applicable
 | ||||
|         this.layerState.filteredLayers.forEach((flayer) => { | ||||
|             const id = flayer.layerDef.id | ||||
|             const features: FeatureSource = specialLayers[id] | ||||
|             if (features === undefined) { | ||||
|                 return | ||||
|             } | ||||
|             if (id === "favourite") { | ||||
|                 console.log("Matching special layer", id, flayer) | ||||
|             } | ||||
| 
 | ||||
|             this.featureProperties.trackFeatureSource(features) | ||||
|             new ShowDataLayer(this.map, { | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ | |||
| 
 | ||||
| <button class={clss} on:click={() => osmConnection.AttemptLogin()}> | ||||
|   <ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} /> | ||||
|   <slot name="message"> | ||||
|   <slot> | ||||
|     <Tr t={Translations.t.general.loginWithOpenStreetMap} /> | ||||
|   </slot> | ||||
| </button> | ||||
|  |  | |||
|  | @ -4,12 +4,12 @@ | |||
|   import Translations from "../i18n/Translations" | ||||
|   import Tr from "./Tr.svelte" | ||||
| 
 | ||||
|   export let osmConnection: OsmConnection | ||||
|   export let osmConnection: OsmConnection; | ||||
| </script> | ||||
| 
 | ||||
| <button | ||||
|   on:click={() => { | ||||
|     state.osmConnection.LogOut() | ||||
|     osmConnection.LogOut() | ||||
|   }} | ||||
| > | ||||
|   <Logout class="h-6 w-6" /> | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ | |||
|     const uiElem = typeof construct === "function" ? construct() : construct | ||||
|     html = uiElem?.ConstructElement() | ||||
|     if (html !== undefined) { | ||||
|       elem.replaceWith(html) | ||||
|       elem?.replaceWith(html) | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|  |  | |||
|  | @ -121,9 +121,9 @@ export default class UploadTraceToOsmUI extends LoginToggle { | |||
|                     ]).SetClass("flex p-2 rounded-xl border-2 subtle-border items-center"), | ||||
|                     new Toggle( | ||||
|                         confirmPanel, | ||||
|                         new SubtleButton(new SvelteUIElement(Upload), t.title).onClick(() => | ||||
|                             clicked.setData(true) | ||||
|                         ), | ||||
|                         new SubtleButton(new SvelteUIElement(Upload), t.title) | ||||
|                             .onClick(() => clicked.setData(true)) | ||||
|                             .SetClass("w-full"), | ||||
|                         clicked | ||||
|                     ), | ||||
|                     uploadFinished | ||||
|  |  | |||
							
								
								
									
										83
									
								
								src/UI/Favourites/FavouriteSummary.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/UI/Favourites/FavouriteSummary.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,83 @@ | |||
| <script lang="ts"> | ||||
| 
 | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization"; | ||||
|   import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import { ImmutableStore } from "../../Logic/UIEventSource"; | ||||
|   import { GeoOperations } from "../../Logic/GeoOperations"; | ||||
|   import Center from "../../assets/svg/Center.svelte"; | ||||
| 
 | ||||
|   export let feature: Feature; | ||||
|   let properties: Record<string, string> = feature.properties; | ||||
|   export let state: SpecialVisualizationState; | ||||
|   let tags = state.featureProperties.getStore(properties.id) ?? new ImmutableStore(properties); | ||||
| 
 | ||||
|   const favLayer = state.layerState.filteredLayers.get("favourite"); | ||||
|   const favConfig = favLayer.layerDef; | ||||
|   const titleConfig = favConfig.title; | ||||
| 
 | ||||
|   function center() { | ||||
|     const [lon, lat] = GeoOperations.centerpointCoordinates(feature); | ||||
|     state.mapProperties.location.setData( | ||||
|       { lon, lat } | ||||
|     ); | ||||
|     const z = state.mapProperties.zoom.data | ||||
|     state.mapProperties.zoom.setData( Math.min(17, Math.max(12, z )) ) | ||||
|     state.guistate.menuIsOpened.setData(false); | ||||
|   } | ||||
| 
 | ||||
|   function select() { | ||||
|     state.selectedLayer.setData(favConfig); | ||||
|     state.selectedElement.setData(feature); | ||||
|     center(); | ||||
|   } | ||||
| 
 | ||||
|   const coord = GeoOperations.centerpointCoordinates(feature); | ||||
|   const distance = state.mapProperties.location.stabilized(500).mapD(({ lon, lat }) => { | ||||
|     let meters = Math.round(GeoOperations.distanceBetween(coord, [lon, lat])); | ||||
| 
 | ||||
|     if (meters < 1000) { | ||||
|       return meters + "m"; | ||||
|     } | ||||
| 
 | ||||
|     meters = Math.round(meters / 100); | ||||
|     const kmStr = "" + meters; | ||||
| 
 | ||||
| 
 | ||||
|     return kmStr.substring(0, kmStr.length - 1) + "." + kmStr.substring(kmStr.length - 1) + "km"; | ||||
|   }); | ||||
|   const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]; | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <div class="px-1 my-1 border-2 border-dashed border-gray-300 rounded grid grid-cols-2 items-center no-weblate"> | ||||
|   <button class="cursor-pointer ml-1 m-0 link justify-self-start" on:click={() => select()}> | ||||
|     <TagRenderingAnswer config={titleConfig} extraClasses="underline" layer={favConfig} selectedElement={feature} | ||||
|                         {tags} /> | ||||
|   </button> | ||||
| 
 | ||||
| 
 | ||||
|   <div class="flex items-center justify-self-end title-icons links-as-button gap-x-0.5 p-1 pt-0.5 sm:pt-1"> | ||||
|     {#each favConfig.titleIcons as titleIconConfig} | ||||
|       {#if (titleIconBlacklist.indexOf(titleIconConfig.id) < 0) && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties({ ...properties, ...state.userRelatedState.preferencesAsTags.data }) ?? true) && titleIconConfig.IsKnown(properties)} | ||||
|         <div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}> | ||||
|           <TagRenderingAnswer | ||||
|             config={titleIconConfig} | ||||
|             {tags} | ||||
|             selectedElement={feature} | ||||
|             {state} | ||||
|             layer={favLayer} | ||||
|             extraClasses="h-full justify-center" | ||||
|           /> | ||||
|         </div> | ||||
|       {/if} | ||||
|     {/each} | ||||
| 
 | ||||
|     <button class="p-1" on:click={() => center()}> | ||||
|       <Center class="w-6 h-6" /> | ||||
|     </button> | ||||
|     <div class="w-14"> | ||||
|       {$distance} | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										68
									
								
								src/UI/Favourites/Favourites.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/UI/Favourites/Favourites.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| <script lang="ts"> | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization"; | ||||
|   import FavouriteSummary from "./FavouriteSummary.svelte"; | ||||
|   import Translations from "../i18n/Translations"; | ||||
|   import Tr from "../Base/Tr.svelte"; | ||||
|   import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid"; | ||||
|   import { Utils } from "../../Utils"; | ||||
|   import { GeoOperations } from "../../Logic/GeoOperations"; | ||||
|   import type { Feature, LineString, Point } from "geojson"; | ||||
|   import LoginToggle from "../Base/LoginToggle.svelte"; | ||||
|   import LoginButton from "../Base/LoginButton.svelte"; | ||||
| 
 | ||||
|   /** | ||||
|    * A panel showing all your favourites | ||||
|    */ | ||||
|   export let state: SpecialVisualizationState; | ||||
|   let favourites = state.favourites.allFavourites; | ||||
| 
 | ||||
|   function downloadGeojson() { | ||||
|     const contents = { features: favourites.data, type: "FeatureCollection" }; | ||||
|     Utils.offerContentsAsDownloadableFile( | ||||
|       JSON.stringify(contents), | ||||
|       "mapcomplete-favourites-" + (new Date().toISOString()) + ".geojson", | ||||
|       { | ||||
|         mimetype: "application/vnd.geo+json" | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   function downloadGPX() { | ||||
|     const gpx = GeoOperations.toGpxPoints(<Feature<Point>>favourites.data, "MapComplete favourites"); | ||||
|     Utils.offerContentsAsDownloadableFile(gpx, | ||||
|       "mapcomplete-favourites-" + (new Date().toISOString()) + ".gpx", | ||||
|       { | ||||
|         mimetype: "{gpx=application/gpx+xml}" | ||||
|       }); | ||||
| 
 | ||||
|   } | ||||
|    | ||||
| </script> | ||||
| 
 | ||||
| <LoginToggle {state}> | ||||
|   <div slot="not-logged-in"> | ||||
|     <LoginButton osmConnection={state.osmConnection}> | ||||
|       <Tr t={Translations.t.favouritePoi.loginToSeeList}/> | ||||
|     </LoginButton> | ||||
|   </div> | ||||
|    | ||||
| <div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}> | ||||
|   <Tr t={Translations.t.favouritePoi.intro.Subs({length: $favourites?.length ?? 0})} /> | ||||
|   <Tr t={Translations.t.favouritePoi.privacy} /> | ||||
| 
 | ||||
|   {#each $favourites as feature (feature.properties.id)} | ||||
|     <FavouriteSummary {feature} {state} /> | ||||
|   {/each} | ||||
| 
 | ||||
|   <div class="mt-8"> | ||||
|     <button class="flex p-2" on:click={() => downloadGeojson()}> | ||||
|       <DownloadIcon class="h-6 w-6" /> | ||||
|       <Tr t={Translations.t.favouritePoi.downloadGeojson} /> | ||||
|     </button> | ||||
|     <button class="flex p-2" on:click={() => downloadGPX()}> | ||||
|       <DownloadIcon class="h-6 w-6" /> | ||||
|       <Tr t={Translations.t.favouritePoi.downloadGpx} /> | ||||
|     </button> | ||||
|   </div> | ||||
| </div> | ||||
| </LoginToggle> | ||||
|  | @ -1,27 +1,7 @@ | |||
| <script lang="ts"> | ||||
|   import { IconConfig } from "../../Models/ThemeConfig/PointRenderingConfig" | ||||
|   import { Store } from "../../Logic/UIEventSource" | ||||
|   import Pin from "../../assets/svg/Pin.svelte" | ||||
|   import Square from "../../assets/svg/Square.svelte" | ||||
|   import Circle from "../../assets/svg/Circle.svelte" | ||||
|   import Checkmark from "../../assets/svg/Checkmark.svelte" | ||||
|   import Clock from "../../assets/svg/Clock.svelte" | ||||
|   import Close from "../../assets/svg/Close.svelte" | ||||
|   import Crosshair from "../../assets/svg/Crosshair.svelte" | ||||
|   import Help from "../../assets/svg/Help.svelte" | ||||
|   import Home from "../../assets/svg/Home.svelte" | ||||
|   import Invalid from "../../assets/svg/Invalid.svelte" | ||||
|   import Location from "../../assets/svg/Location.svelte" | ||||
|   import Location_empty from "../../assets/svg/Location_empty.svelte" | ||||
|   import Location_locked from "../../assets/svg/Location_locked.svelte" | ||||
|   import Note from "../../assets/svg/Note.svelte" | ||||
|   import Resolved from "../../assets/svg/Resolved.svelte" | ||||
|   import Ring from "../../assets/svg/Ring.svelte" | ||||
|   import Scissors from "../../assets/svg/Scissors.svelte" | ||||
|   import Teardrop from "../../assets/svg/Teardrop.svelte" | ||||
|   import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte" | ||||
|   import Triangle from "../../assets/svg/Triangle.svelte" | ||||
|   import Icon from "./Icon.svelte" | ||||
|   import { IconConfig } from "../../Models/ThemeConfig/PointRenderingConfig"; | ||||
|   import { Store } from "../../Logic/UIEventSource"; | ||||
|   import Icon from "./Icon.svelte"; | ||||
| 
 | ||||
|   /** | ||||
|    * Renders a single icon. | ||||
|  |  | |||
|  | @ -7,9 +7,9 @@ | |||
|   /** | ||||
|    * Renders a 'marker', which consists of multiple 'icons' | ||||
|    */ | ||||
|   export let marker: IconConfig[] = config?.marker | ||||
|   export let marker: IconConfig[] = config?.marker; | ||||
|   export let tags: Store<Record<string, string>> | ||||
|   export let rotation: TagRenderingConfig | ||||
|   export let rotation: TagRenderingConfig = undefined; | ||||
|   let _rotation = rotation | ||||
|     ? tags.map((tags) => rotation.GetRenderValue(tags).Subs(tags).txt) | ||||
|     : new ImmutableStore(0) | ||||
|  | @ -18,7 +18,9 @@ | |||
| {#if marker && marker} | ||||
|   <div class="relative h-full w-full" style={`transform: rotate(${$_rotation})`}> | ||||
|     {#each marker as icon} | ||||
|       <div class="absolute top-0 left-0 h-full w-full"> | ||||
|         <DynamicIcon {icon} {tags} /> | ||||
|       </div> | ||||
|     {/each} | ||||
|   </div> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -1,27 +1,29 @@ | |||
| <script lang="ts"> | ||||
|   import Pin from "../../assets/svg/Pin.svelte" | ||||
|   import Square from "../../assets/svg/Square.svelte" | ||||
|   import Circle from "../../assets/svg/Circle.svelte" | ||||
|   import Checkmark from "../../assets/svg/Checkmark.svelte" | ||||
|   import Clock from "../../assets/svg/Clock.svelte" | ||||
|   import Close from "../../assets/svg/Close.svelte" | ||||
|   import Crosshair from "../../assets/svg/Crosshair.svelte" | ||||
|   import Help from "../../assets/svg/Help.svelte" | ||||
|   import Home from "../../assets/svg/Home.svelte" | ||||
|   import Invalid from "../../assets/svg/Invalid.svelte" | ||||
|   import Location from "../../assets/svg/Location.svelte" | ||||
|   import Location_empty from "../../assets/svg/Location_empty.svelte" | ||||
|   import Location_locked from "../../assets/svg/Location_locked.svelte" | ||||
|   import Note from "../../assets/svg/Note.svelte" | ||||
|   import Resolved from "../../assets/svg/Resolved.svelte" | ||||
|   import Ring from "../../assets/svg/Ring.svelte" | ||||
|   import Scissors from "../../assets/svg/Scissors.svelte" | ||||
|   import Teardrop from "../../assets/svg/Teardrop.svelte" | ||||
|   import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte" | ||||
|   import Triangle from "../../assets/svg/Triangle.svelte" | ||||
|   import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte" | ||||
|   import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte" | ||||
|   import Gps_arrow from "../../assets/svg/Gps_arrow.svelte" | ||||
|   import Pin from "../../assets/svg/Pin.svelte"; | ||||
|   import Square from "../../assets/svg/Square.svelte"; | ||||
|   import Circle from "../../assets/svg/Circle.svelte"; | ||||
|   import Checkmark from "../../assets/svg/Checkmark.svelte"; | ||||
|   import Clock from "../../assets/svg/Clock.svelte"; | ||||
|   import Close from "../../assets/svg/Close.svelte"; | ||||
|   import Crosshair from "../../assets/svg/Crosshair.svelte"; | ||||
|   import Help from "../../assets/svg/Help.svelte"; | ||||
|   import Home from "../../assets/svg/Home.svelte"; | ||||
|   import Invalid from "../../assets/svg/Invalid.svelte"; | ||||
|   import Location from "../../assets/svg/Location.svelte"; | ||||
|   import Location_empty from "../../assets/svg/Location_empty.svelte"; | ||||
|   import Location_locked from "../../assets/svg/Location_locked.svelte"; | ||||
|   import Note from "../../assets/svg/Note.svelte"; | ||||
|   import Resolved from "../../assets/svg/Resolved.svelte"; | ||||
|   import Ring from "../../assets/svg/Ring.svelte"; | ||||
|   import Scissors from "../../assets/svg/Scissors.svelte"; | ||||
|   import Teardrop from "../../assets/svg/Teardrop.svelte"; | ||||
|   import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte"; | ||||
|   import Triangle from "../../assets/svg/Triangle.svelte"; | ||||
|   import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte"; | ||||
|   import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"; | ||||
|   import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"; | ||||
|   import { HeartIcon } from "@babeard/svelte-heroicons/solid"; | ||||
|   import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"; | ||||
| 
 | ||||
|   /** | ||||
|    * Renders a single icon. | ||||
|  | @ -29,68 +31,72 @@ | |||
|    * Icons -placed on top of each other- form a 'Marker' together | ||||
|    */ | ||||
| 
 | ||||
|   export let icon: string | undefined | ||||
|   export let color: string | undefined | ||||
|   export let icon: string | undefined; | ||||
|   export let color: string | undefined = undefined | ||||
|   export let clss: string | undefined = undefined | ||||
| </script> | ||||
| 
 | ||||
| {#if icon} | ||||
|   <div class="absolute top-0 left-0 h-full w-full"> | ||||
|     {#if icon === "pin"} | ||||
|       <Pin {color} /> | ||||
|       <Pin {color} class={clss}/> | ||||
|     {:else if icon === "square"} | ||||
|       <Square {color} /> | ||||
|       <Square {color} class={clss}/> | ||||
|     {:else if icon === "circle"} | ||||
|       <Circle {color} /> | ||||
|       <Circle {color} class={clss}/> | ||||
|     {:else if icon === "checkmark"} | ||||
|       <Checkmark {color} /> | ||||
|       <Checkmark {color} class={clss}/> | ||||
|     {:else if icon === "clock"} | ||||
|       <Clock {color} /> | ||||
|       <Clock {color} class={clss}/> | ||||
|     {:else if icon === "close"} | ||||
|       <Close {color} /> | ||||
|       <Close {color} class={clss}/> | ||||
|     {:else if icon === "crosshair"} | ||||
|       <Crosshair {color} /> | ||||
|       <Crosshair {color} class={clss}/> | ||||
|     {:else if icon === "help"} | ||||
|       <Help {color} /> | ||||
|       <Help {color} class={clss}/> | ||||
|     {:else if icon === "home"} | ||||
|       <Home {color} /> | ||||
|       <Home {color} class={clss}/> | ||||
|     {:else if icon === "invalid"} | ||||
|       <Invalid {color} /> | ||||
|       <Invalid {color} class={clss}/> | ||||
|     {:else if icon === "location"} | ||||
|       <Location {color} /> | ||||
|       <Location {color} class={clss}/> | ||||
|     {:else if icon === "location_empty"} | ||||
|       <Location_empty {color} /> | ||||
|       <Location_empty {color} class={clss}/> | ||||
|     {:else if icon === "location_locked"} | ||||
|       <Location_locked {color} /> | ||||
|       <Location_locked {color} class={clss}/> | ||||
|     {:else if icon === "note"} | ||||
|       <Note {color} /> | ||||
|       <Note {color} class={clss}/> | ||||
|     {:else if icon === "resolved"} | ||||
|       <Resolved {color} /> | ||||
|       <Resolved {color} class={clss}/> | ||||
|     {:else if icon === "ring"} | ||||
|       <Ring {color} /> | ||||
|       <Ring {color} class={clss}/> | ||||
|     {:else if icon === "scissors"} | ||||
|       <Scissors {color} /> | ||||
|       <Scissors {color} class={clss}/> | ||||
|     {:else if icon === "teardrop"} | ||||
|       <Teardrop {color} /> | ||||
|       <Teardrop {color} class={clss}/> | ||||
|     {:else if icon === "teardrop_with_hole_green"} | ||||
|       <Teardrop_with_hole_green {color} /> | ||||
|       <Teardrop_with_hole_green {color} class={clss}/> | ||||
|     {:else if icon === "triangle"} | ||||
|       <Triangle {color} /> | ||||
|       <Triangle {color} class={clss}/> | ||||
|     {:else if icon === "brick_wall_square"} | ||||
|       <Brick_wall_square {color} /> | ||||
|       <Brick_wall_square {color} class={clss}/> | ||||
|     {:else if icon === "brick_wall_round"} | ||||
|       <Brick_wall_round {color} /> | ||||
|       <Brick_wall_round {color} class={clss}/> | ||||
|     {:else if icon === "gps_arrow"} | ||||
|       <Gps_arrow {color} /> | ||||
|       <Gps_arrow {color} class={clss}/> | ||||
|     {:else if icon === "checkmark"} | ||||
|       <Checkmark {color} /> | ||||
|       <Checkmark {color} class={clss}/> | ||||
|     {:else if icon === "help"} | ||||
|       <Help {color} /> | ||||
|       <Help {color} class={clss}/> | ||||
|     {:else if icon === "close"} | ||||
|       <Close {color} /> | ||||
|       <Close {color} class={clss}/> | ||||
|     {:else if icon === "invalid"} | ||||
|       <Invalid {color} /> | ||||
|       <Invalid {color} class={clss}/> | ||||
|     {:else if icon === "heart"} | ||||
|       <HeartIcon class={clss}/> | ||||
|     {:else if icon === "heart_outline"} | ||||
|       <HeartOutlineIcon class={clss}/> | ||||
|     {:else} | ||||
|       <img class="h-full w-full" src={icon} /> | ||||
|       <img class={clss ?? "h-full w-full"} src={icon}  aria-hidden="true" | ||||
|            alt="" /> | ||||
|     {/if} | ||||
|   </div> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -1,16 +1,18 @@ | |||
| <script lang="ts"> | ||||
|   import Icon from "./Icon.svelte" | ||||
|   import Icon from "./Icon.svelte"; | ||||
| 
 | ||||
|   /** | ||||
|    * Renders a 'marker', which consists of multiple 'icons' | ||||
|    */ | ||||
|   export let icons: { icon: string; color: string }[] | ||||
|   export let icons: { icon: string; color: string }[]; | ||||
| </script> | ||||
| 
 | ||||
| {#if icons !== undefined && icons.length > 0} | ||||
|   <div class="relative h-full w-full"> | ||||
|     {#each icons as icon} | ||||
|       <div class="absolute top-0 left-0 h-full w-full"> | ||||
|         <Icon icon={icon.icon} color={icon.color} /> | ||||
|       </div> | ||||
|     {/each} | ||||
|   </div> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -12,11 +12,9 @@ import { Feature, Point } from "geojson" | |||
| import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" | ||||
| import { Utils } from "../../Utils" | ||||
| import * as range_layer from "../../../assets/layers/range/range.json" | ||||
| import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" | ||||
| import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" | ||||
| import FilteredLayer from "../../Models/FilteredLayer" | ||||
| import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" | ||||
| import { CLIENT_RENEG_LIMIT } from "tls" | ||||
| 
 | ||||
| class PointRenderingLayer { | ||||
|     private readonly _config: PointRenderingConfig | ||||
|  |  | |||
							
								
								
									
										50
									
								
								src/UI/OpeningHours/NextChangeViz.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/UI/OpeningHours/NextChangeViz.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| <script lang="ts">/** | ||||
|  * Simple visualisation which shows when the POI opens/closes next. | ||||
|  */ | ||||
| import type { SpecialVisualizationState } from "../SpecialVisualization"; | ||||
| import { Store, Stores } from "../../Logic/UIEventSource"; | ||||
| import { OH } from "./OpeningHours"; | ||||
| import opening_hours from "opening_hours"; | ||||
| import Clock from "../../assets/svg/Clock.svelte"; | ||||
| import { Utils } from "../../Utils"; | ||||
| import Circle from "../../assets/svg/Circle.svelte"; | ||||
| import Ring from "../../assets/svg/Ring.svelte"; | ||||
| import { twMerge } from "tailwind-merge"; | ||||
| 
 | ||||
| export let state: SpecialVisualizationState; | ||||
| export let tags: Store<Record<string, string>>; | ||||
| export let keyToUse: string = "opening_hours"; | ||||
| export let prefix: string = undefined; | ||||
| export let postfix: string = undefined; | ||||
| let oh: Store<opening_hours | "error" | undefined> = OH.CreateOhObjectStore(tags, keyToUse, prefix, postfix); | ||||
| 
 | ||||
| let currentState = oh.mapD(oh => typeof oh === "string" ? undefined : oh.getState()); | ||||
| let tomorrow = new Date(); | ||||
| tomorrow.setTime(tomorrow.getTime() + 24 * 60 * 60 * 1000); | ||||
| let nextChange = oh | ||||
|   .mapD(oh => typeof oh === "string" ? undefined : oh.getNextChange(new Date(), tomorrow), [Stores.Chronic(5 * 60 * 1000)]) | ||||
|   .mapD(date => Utils.TwoDigits(date.getHours()) + ":" + Utils.TwoDigits(date.getMinutes())); | ||||
| 
 | ||||
| let size = nextChange.map(change => change === undefined ? "absolute h-7 w-7" : "absolute h-5 w-5 top-0 left-1/4"); | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| {#if $currentState !== undefined} | ||||
|   <div class="relative h-8 w-8"> | ||||
|     {#if $currentState === true} | ||||
|       <Ring class={$size} color="#0f0" style="z-index: 0" /> | ||||
|       <Clock class={$size} color="#0f0" style="z-index: 0" /> | ||||
|     {:else if $currentState === false} | ||||
|       <Circle class={$size} color="#f00" style="z-index: 0" /> | ||||
|       <Clock class={$size} color="#fff" style="z-index: 0" /> | ||||
|     {/if} | ||||
| 
 | ||||
|     {#if $nextChange !== undefined} | ||||
|       <span class="absolute bottom-0 font-bold text-sm" style="z-index: 1; background-color: #ffffff88; margin-top: 3px"> | ||||
|         {$nextChange} | ||||
|       </span> | ||||
|     {/if} | ||||
| 
 | ||||
|   </div> | ||||
| 
 | ||||
| {/if} | ||||
|  | @ -1,5 +1,6 @@ | |||
| import { Utils } from "../../Utils" | ||||
| import opening_hours from "opening_hours" | ||||
| import { Store } from "../../Logic/UIEventSource" | ||||
| 
 | ||||
| export interface OpeningHour { | ||||
|     weekday: number // 0 is monday, 1 is tuesday, ...
 | ||||
|  | @ -494,10 +495,48 @@ This list will be sorted | |||
| 
 | ||||
|         return [changeHours, changeHourText] | ||||
|     } | ||||
|     public static CreateOhObjectStore( | ||||
|         tags: Store<Record<string, string>>, | ||||
|         key: string = "opening_hours", | ||||
|         prefixToIgnore?: string, | ||||
|         postfixToIgnore?: string | ||||
|     ): Store<opening_hours | undefined | "error"> { | ||||
|         prefixToIgnore ??= "" | ||||
|         postfixToIgnore ??= "" | ||||
|         const country = tags.map((tags) => tags._country) | ||||
|         return tags | ||||
|             .mapD((tags) => { | ||||
|                 const value: string = tags[key] | ||||
|                 if (value === undefined) { | ||||
|                     return undefined | ||||
|                 } | ||||
| 
 | ||||
|                 if ( | ||||
|                     (prefixToIgnore || postfixToIgnore) && | ||||
|                     value.startsWith(prefixToIgnore) && | ||||
|                     value.endsWith(postfixToIgnore) | ||||
|                 ) { | ||||
|                     return value | ||||
|                         .substring(prefixToIgnore.length, value.length - postfixToIgnore.length) | ||||
|                         .trim() | ||||
|                 } | ||||
|                 return value | ||||
|             }) | ||||
|             .mapD( | ||||
|                 (ohtext) => { | ||||
|                     try { | ||||
|                         return OH.CreateOhObject(<any>tags.data, ohtext, country.data) | ||||
|                     } catch (e) { | ||||
|                         return "error" | ||||
|                     } | ||||
|                 }, | ||||
|                 [country] | ||||
|             ) | ||||
|     } | ||||
|     public static CreateOhObject( | ||||
|         tags: Record<string, string> & { _lat: number; _lon: number; _country?: string }, | ||||
|         textToParse: string | ||||
|         textToParse: string, | ||||
|         country?: string | ||||
|     ) { | ||||
|         // noinspection JSPotentiallyInvalidConstructorUsage
 | ||||
|         return new opening_hours( | ||||
|  | @ -506,7 +545,7 @@ This list will be sorted | |||
|                 lat: tags._lat, | ||||
|                 lon: tags._lon, | ||||
|                 address: { | ||||
|                     country_code: tags._country?.toLowerCase(), | ||||
|                     country_code: country.toLowerCase(), | ||||
|                     state: undefined, | ||||
|                 }, | ||||
|             }, | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ import Combine from "../Base/Combine" | |||
| import { FixedUiElement } from "../Base/FixedUiElement" | ||||
| import { OH } from "./OpeningHours" | ||||
| import Translations from "../i18n/Translations" | ||||
| import Constants from "../../Models/Constants" | ||||
| import BaseUIElement from "../BaseUIElement" | ||||
| import Toggle from "../Input/Toggle" | ||||
| import { VariableUiElement } from "../Base/VariableUIElement" | ||||
|  | @ -30,48 +29,20 @@ export default class OpeningHoursVisualization extends Toggle { | |||
|         prefix = "", | ||||
|         postfix = "" | ||||
|     ) { | ||||
|         const country = tags.map((tags) => tags._country) | ||||
|         const openingHoursStore = OH.CreateOhObjectStore(tags, key, prefix, postfix) | ||||
|         const ohTable = new VariableUiElement( | ||||
|             tags | ||||
|                 .map((tags) => { | ||||
|                     const value: string = tags[key] | ||||
|                     if (value === undefined) { | ||||
|                         return undefined | ||||
|                     } | ||||
|                     if (value.startsWith(prefix) && value.endsWith(postfix)) { | ||||
|                         return value.substring(prefix.length, value.length - postfix.length).trim() | ||||
|                     } | ||||
|                     return value | ||||
|                 }) // This mapping will absorb all other changes to tags in order to prevent regeneration
 | ||||
|                 .map( | ||||
|                     (ohtext) => { | ||||
|                         if (ohtext === undefined) { | ||||
|                             return new FixedUiElement( | ||||
|                                 "No opening hours defined with key " + key | ||||
|                             ).SetClass("alert") | ||||
|                         } | ||||
|                         try { | ||||
|                             return OpeningHoursVisualization.CreateFullVisualisation( | ||||
|                                 OH.CreateOhObject(<any>tags.data, ohtext) | ||||
|             openingHoursStore.map((opening_hours_obj) => { | ||||
|                 if (opening_hours_obj === undefined) { | ||||
|                     return new FixedUiElement("No opening hours defined with key " + key).SetClass( | ||||
|                         "alert" | ||||
|                     ) | ||||
|                         } catch (e) { | ||||
|                             console.warn(e, e.stack) | ||||
|                             return new Combine([ | ||||
|                                 Translations.t.general.opening_hours.error_loading, | ||||
|                                 new Toggle( | ||||
|                                     new FixedUiElement(e).SetClass("subtle"), | ||||
|                                     undefined, | ||||
|                                     state?.osmConnection?.userDetails.map( | ||||
|                                         (userdetails) => | ||||
|                                             userdetails.csCount >= | ||||
|                                             Constants.userJourney.tagsVisibleAndWikiLinked | ||||
|                                     ) | ||||
|                                 ), | ||||
|                             ]) | ||||
|                 } | ||||
|                     }, | ||||
|                     [country] | ||||
|                 ) | ||||
| 
 | ||||
|                 if (opening_hours_obj === "error") { | ||||
|                     return Translations.t.general.opening_hours.error_loading | ||||
|                 } | ||||
|                 return OpeningHoursVisualization.CreateFullVisualisation(opening_hours_obj) | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         super( | ||||
|  |  | |||
|  | @ -161,7 +161,7 @@ | |||
|       2. What do we want to add? | ||||
|       3. Are all elements of this category visible? (i.e. there are no filters possibly hiding this, is the data still loading, ...) --> | ||||
|   <LoginButton osmConnection={state.osmConnection} slot="not-logged-in"> | ||||
|     <Tr slot="message" t={Translations.t.general.add.pleaseLogin} /> | ||||
|     <Tr t={Translations.t.general.add.pleaseLogin} /> | ||||
|   </LoginButton> | ||||
|   <div class="h-full w-full"> | ||||
|     {#if $zoom < Constants.minZoomLevelToAddNewPoint} | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ | |||
|     ...(state?.layoutToUse?.layers?.map((l) => l.calculatedTags?.map((c) => c[0]) ?? []) ?? []) | ||||
|   ) | ||||
| 
 | ||||
|   const allTags = tags.map((tags) => { | ||||
|   const allTags = tags.mapD((tags) => { | ||||
|     const parts: (string | BaseUIElement)[][] = [] | ||||
|     for (const key in tags) { | ||||
|       let v = tags[key] | ||||
|  |  | |||
|  | @ -31,7 +31,9 @@ export class ExportAsGpxViz implements SpecialVisualization { | |||
|                 t.downloadFeatureAsGpx.SetClass("font-bold text-lg"), | ||||
|                 t.downloadGpxHelper.SetClass("subtle"), | ||||
|             ]).SetClass("flex flex-col") | ||||
|         ).onClick(() => { | ||||
|         ) | ||||
|             .SetClass("w-full") | ||||
|             .onClick(() => { | ||||
|                 console.log("Exporting as GPX!") | ||||
|                 const tags = tagSource.data | ||||
|                 const title = layer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track" | ||||
|  |  | |||
|  | @ -28,7 +28,6 @@ | |||
| 
 | ||||
|   const t = Translations.t.image.nearby | ||||
|   const c = [lon, lat] | ||||
|   console.log(">>>", image) | ||||
|   let attributedImage = new AttributedImage({ | ||||
|     url: image.thumbUrl ?? image.pictureUrl, | ||||
|     provider: AllImageProviders.byName(image.provider), | ||||
|  | @ -45,7 +44,7 @@ | |||
|     const url = image.osmTags[key] | ||||
|     if (isLinked) { | ||||
|       const action = new LinkImageAction(currentTags.id, key, url, tags, { | ||||
|         theme: state.layout.id, | ||||
|         theme: tags.data._orig_theme ??  state.layout.id, | ||||
|         changeType: "link-image", | ||||
|       }) | ||||
|       state.changes.applyAction(action) | ||||
|  | @ -54,7 +53,7 @@ | |||
|         const v = currentTags[k] | ||||
|         if (v === url) { | ||||
|           const action = new ChangeTagAction(currentTags.id, new Tag(k, ""), currentTags, { | ||||
|             theme: state.layout.id, | ||||
|             theme: tags.data._orig_theme ?? state.layout.id, | ||||
|             changeType: "remove-image", | ||||
|           }) | ||||
|           state.changes.applyAction(action) | ||||
|  |  | |||
							
								
								
									
										48
									
								
								src/UI/Popup/MarkAsFavourite.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/UI/Popup/MarkAsFavourite.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| <script lang="ts"> | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization"; | ||||
|   import { HeartIcon as HeartSolidIcon } from "@babeard/svelte-heroicons/solid"; | ||||
|   import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"; | ||||
|   import Tr from "../Base/Tr.svelte"; | ||||
|   import Translations from "../i18n/Translations"; | ||||
|   import LoginToggle from "../Base/LoginToggle.svelte"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; | ||||
| 
 | ||||
|   /** | ||||
|    * A full-blown 'mark as favourite'-button | ||||
|    */ | ||||
|   export let state: SpecialVisualizationState; | ||||
|   export let feature: Feature | ||||
|   export let tags: Record<string, string>; | ||||
|   export let layer: LayerConfig | ||||
|   let isFavourite = tags?.map(tags => tags._favourite === "yes"); | ||||
|   const t = Translations.t.favouritePoi; | ||||
| 
 | ||||
|   function markFavourite(isFavourite: boolean) { | ||||
|     state.favourites.markAsFavourite(feature, layer.id, state.layout.id, tags, isFavourite) | ||||
|   } | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <LoginToggle ignoreLoading={true} {state}> | ||||
| {#if $isFavourite}   | ||||
|   <div class="flex h-fit items-start"> | ||||
|     <HeartSolidIcon class="w-16 shrink-0 mr-2" on:click={() => markFavourite(false)} /> | ||||
|     <div class="flex flex-col w-full"> | ||||
|       <button class="flex flex-col items-start" on:click={() => markFavourite(false)}> | ||||
|         <Tr t={t.button.unmark} /> | ||||
|         <Tr cls="normal-font subtle" t={t.button.unmarkNotDeleted}/> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|     <Tr cls="font-bold thanks m-2 p-2 block" t={t.button.isFavourite} /> | ||||
| {:else} | ||||
|   <div class="flex items-start"> | ||||
|     <HeartOutlineIcon class="w-16 shrink-0 mr-2" on:click={() => markFavourite(true)} /> | ||||
|       <button class="flex w-full flex-col items-start" on:click={() => markFavourite(true)}> | ||||
|         <Tr t={t.button.markAsFavouriteTitle} /> | ||||
|         <Tr cls="normal-font subtle" t={t.button.markDescription}/> | ||||
|       </button> | ||||
|   </div> | ||||
| {/if} | ||||
| </LoginToggle> | ||||
							
								
								
									
										36
									
								
								src/UI/Popup/MarkAsFavouriteMini.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/UI/Popup/MarkAsFavouriteMini.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| <script lang="ts"> | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization"; | ||||
|   import { HeartIcon as HeartSolidIcon } from "@babeard/svelte-heroicons/solid"; | ||||
|   import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"; | ||||
|   import Translations from "../i18n/Translations"; | ||||
|   import LoginToggle from "../Base/LoginToggle.svelte"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; | ||||
| 
 | ||||
|   /** | ||||
|    * A small 'mark as favourite'-button to serve as title-icon | ||||
|    */ | ||||
|   export let state: SpecialVisualizationState; | ||||
|   export let feature: Feature; | ||||
|   export let tags: Record<string, string>; | ||||
|   export let layer: LayerConfig; | ||||
|   let isFavourite = tags?.map(tags => tags._favourite === "yes"); | ||||
|   const t = Translations.t.favouritePoi; | ||||
| 
 | ||||
|   function markFavourite(isFavourite: boolean) { | ||||
|     state.favourites.markAsFavourite(feature, layer.id, state.layout.id, tags, isFavourite); | ||||
|   } | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <LoginToggle ignoreLoading={true} {state}> | ||||
|   {#if $isFavourite} | ||||
|     <button class="p-0 m-0 h-8 w-8" on:click={() => markFavourite(false)}> | ||||
|       <HeartSolidIcon/> | ||||
|     </button> | ||||
|   {:else} | ||||
|     <button class="p-0 m-0 h-8 w-8 no-image-background"  on:click={() => markFavourite(true)} > | ||||
|       <HeartOutlineIcon/> | ||||
|     </button> | ||||
|   {/if} | ||||
| </LoginToggle> | ||||
|  | @ -3,16 +3,15 @@ | |||
|    * Shows all questions for which the answers are unknown. | ||||
|    * The questions can either be shown all at once or one at a time (in which case they can be skipped) | ||||
|    */ | ||||
|   import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig" | ||||
|   import { UIEventSource } from "../../../Logic/UIEventSource" | ||||
|   import type { Feature } from "geojson" | ||||
|   import type { SpecialVisualizationState } from "../../SpecialVisualization" | ||||
|   import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" | ||||
|   import If from "../../Base/If.svelte" | ||||
|   import TagRenderingQuestion from "./TagRenderingQuestion.svelte" | ||||
|   import Tr from "../../Base/Tr.svelte" | ||||
|   import Translations from "../../i18n/Translations.js" | ||||
|   import { Utils } from "../../../Utils" | ||||
|   import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; | ||||
|   import { UIEventSource } from "../../../Logic/UIEventSource"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import type { SpecialVisualizationState } from "../../SpecialVisualization"; | ||||
|   import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; | ||||
|   import TagRenderingQuestion from "./TagRenderingQuestion.svelte"; | ||||
|   import Tr from "../../Base/Tr.svelte"; | ||||
|   import Translations from "../../i18n/Translations.js"; | ||||
|   import { Utils } from "../../../Utils"; | ||||
| 
 | ||||
|   export let layer: LayerConfig | ||||
|   export let tags: UIEventSource<Record<string, string>> | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ | |||
|   onDestroy( | ||||
|     tags.addCallbackAndRun((tags) => { | ||||
|       _tags = tags | ||||
|       console.log("Getting render value for", _tags,config) | ||||
|       trs = Utils.NoNull(config?.GetRenderValues(_tags)) | ||||
|     }) | ||||
|   ) | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ | |||
|   import Translations from "../../i18n/Translations.js" | ||||
|   import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" | ||||
|   import { Utils } from "../../../Utils" | ||||
| 
 | ||||
|   import { twMerge } from "tailwind-merge" | ||||
|   export let config: TagRenderingConfig | ||||
|   export let tags: UIEventSource<Record<string, string>> | ||||
|   export let selectedElement: Feature | undefined | ||||
|  | @ -71,7 +71,7 @@ | |||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <div bind:this={htmlElem} class={clss}> | ||||
| <div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}> | ||||
|   {#if config.question && (!editingEnabled || $editingEnabled)} | ||||
|     {#if editMode} | ||||
|       <TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}> | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
|   import { UIEventSource } from "../../../Logic/UIEventSource" | ||||
|   import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" | ||||
|   import { twJoin } from "tailwind-merge" | ||||
|   import Icon from "../../Map/Icon.svelte"; | ||||
| 
 | ||||
|   export let selectedElement: Feature | ||||
|   export let tags: UIEventSource<Record<string, string>> | ||||
|  | @ -27,13 +28,8 @@ | |||
| </script> | ||||
| 
 | ||||
| {#if mapping.icon !== undefined} | ||||
|   <div class="inline-flex items-center"> | ||||
|     <img | ||||
|       class={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")} | ||||
|       src={mapping.icon} | ||||
|       aria-hidden="true" | ||||
|       alt="" | ||||
|     /> | ||||
|   <div class="inline-flex"> | ||||
|     <Icon icon={mapping.icon} clss={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")}/> | ||||
|     <SpecialTranslation t={mapping.then} {tags} {state} {layer} feature={selectedElement} /> | ||||
|   </div> | ||||
| {:else if mapping.then !== undefined} | ||||
|  |  | |||
|  | @ -1,45 +1,56 @@ | |||
| <script lang="ts"> | ||||
|     import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource" | ||||
|     import type { SpecialVisualizationState } from "../../SpecialVisualization" | ||||
|     import Tr from "../../Base/Tr.svelte" | ||||
|     import type { Feature } from "geojson" | ||||
|     import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig" | ||||
|     import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig" | ||||
|     import { TagsFilter } from "../../../Logic/Tags/TagsFilter" | ||||
|     import FreeformInput from "./FreeformInput.svelte" | ||||
|     import Translations from "../../i18n/Translations.js" | ||||
|     import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction" | ||||
|     import { createEventDispatcher, onDestroy } from "svelte" | ||||
|     import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" | ||||
|     import SpecialTranslation from "./SpecialTranslation.svelte" | ||||
|     import TagHint from "../TagHint.svelte" | ||||
|     import LoginToggle from "../../Base/LoginToggle.svelte" | ||||
|     import SubtleButton from "../../Base/SubtleButton.svelte" | ||||
|     import Loading from "../../Base/Loading.svelte" | ||||
|     import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte" | ||||
|     import { Translation } from "../../i18n/Translation" | ||||
|     import Constants from "../../../Models/Constants" | ||||
|     import { Unit } from "../../../Models/Unit" | ||||
|     import UserRelatedState from "../../../Logic/State/UserRelatedState" | ||||
|     import { twJoin } from "tailwind-merge" | ||||
|     import { TagUtils } from "../../../Logic/Tags/TagUtils" | ||||
|     import Search from "../../../assets/svg/Search.svelte" | ||||
|     import Login from "../../../assets/svg/Login.svelte" | ||||
|   import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource"; | ||||
|   import type { SpecialVisualizationState } from "../../SpecialVisualization"; | ||||
|   import Tr from "../../Base/Tr.svelte"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig"; | ||||
|   import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; | ||||
|   import { TagsFilter } from "../../../Logic/Tags/TagsFilter"; | ||||
|   import FreeformInput from "./FreeformInput.svelte"; | ||||
|   import Translations from "../../i18n/Translations.js"; | ||||
|   import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"; | ||||
|   import { createEventDispatcher, onDestroy } from "svelte"; | ||||
|   import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; | ||||
|   import SpecialTranslation from "./SpecialTranslation.svelte"; | ||||
|   import TagHint from "../TagHint.svelte"; | ||||
|   import LoginToggle from "../../Base/LoginToggle.svelte"; | ||||
|   import SubtleButton from "../../Base/SubtleButton.svelte"; | ||||
|   import Loading from "../../Base/Loading.svelte"; | ||||
|   import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte"; | ||||
|   import { Translation } from "../../i18n/Translation"; | ||||
|   import Constants from "../../../Models/Constants"; | ||||
|   import { Unit } from "../../../Models/Unit"; | ||||
|   import UserRelatedState from "../../../Logic/State/UserRelatedState"; | ||||
|   import { twJoin } from "tailwind-merge"; | ||||
|   import { TagUtils } from "../../../Logic/Tags/TagUtils"; | ||||
|   import Search from "../../../assets/svg/Search.svelte"; | ||||
|   import Login from "../../../assets/svg/Login.svelte"; | ||||
| 
 | ||||
|     export let config: TagRenderingConfig | ||||
|     export let tags: UIEventSource<Record<string, string>> | ||||
|     export let selectedElement: Feature | ||||
|     export let state: SpecialVisualizationState | ||||
|     export let layer: LayerConfig | undefined | ||||
|   export let config: TagRenderingConfig; | ||||
|   export let tags: UIEventSource<Record<string, string>>; | ||||
|   export let selectedElement: Feature; | ||||
|   export let state: SpecialVisualizationState; | ||||
|   export let layer: LayerConfig | undefined; | ||||
|   export let selectedTags: TagsFilter = undefined; | ||||
| 
 | ||||
|     let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined) | ||||
|   let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined); | ||||
| 
 | ||||
|     let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)) | ||||
|   let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)); | ||||
| 
 | ||||
|   // Will be bound if a freeform is available | ||||
|     let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]) | ||||
|     let selectedMapping: number = undefined | ||||
|     let checkedMappings: boolean[] | ||||
|   let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]); | ||||
|   let selectedMapping: number = undefined; | ||||
|   let checkedMappings: boolean[]; | ||||
| 
 | ||||
|   let mappings: Mapping[] = config?.mappings; | ||||
|   let searchTerm: UIEventSource<string> = new UIEventSource(""); | ||||
| 
 | ||||
|   let dispatch = createEventDispatcher<{ | ||||
|     saved: { | ||||
|       config: TagRenderingConfig | ||||
|       applied: TagsFilter | ||||
|     } | ||||
|   }>(); | ||||
| 
 | ||||
|   /** | ||||
|    * Prepares and fills the checkedMappings | ||||
|  | @ -47,12 +58,12 @@ | |||
|   function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) { | ||||
|     mappings = confg.mappings?.filter((m) => { | ||||
|       if (typeof m.hideInAnswer === "boolean") { | ||||
|                 return !m.hideInAnswer | ||||
|         return !m.hideInAnswer; | ||||
|       } | ||||
|             return !m.hideInAnswer.matchesProperties(tgs) | ||||
|         }) | ||||
|       return !m.hideInAnswer.matchesProperties(tgs); | ||||
|     }); | ||||
|     // We received a new config -> reinit | ||||
|         unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)) | ||||
|     unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)); | ||||
| 
 | ||||
|     if ( | ||||
|       confg.mappings?.length > 0 && | ||||
|  | @ -60,56 +71,53 @@ | |||
|       (checkedMappings === undefined || | ||||
|         checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0)) | ||||
|     ) { | ||||
|             const seenFreeforms = [] | ||||
|             TagUtils.FlattenMultiAnswer() | ||||
|       const seenFreeforms = []; | ||||
|       TagUtils.FlattenMultiAnswer(); | ||||
|       checkedMappings = [ | ||||
|         ...confg.mappings.map((mapping) => { | ||||
|                     const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs) | ||||
|           const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs); | ||||
|           if (matches && confg.freeform) { | ||||
|                         const newProps = TagUtils.changeAsProperties(mapping.if.asChange()) | ||||
|                         seenFreeforms.push(newProps[confg.freeform.key]) | ||||
|             const newProps = TagUtils.changeAsProperties(mapping.if.asChange()); | ||||
|             seenFreeforms.push(newProps[confg.freeform.key]); | ||||
|           } | ||||
|                     return matches | ||||
|                 }), | ||||
|             ] | ||||
|           return matches; | ||||
|         }) | ||||
|       ]; | ||||
| 
 | ||||
|       if (tgs !== undefined && confg.freeform) { | ||||
|                 const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? [] | ||||
|         const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []; | ||||
|         for (const seenFreeform of seenFreeforms) { | ||||
|           if (!seenFreeform) { | ||||
|                         continue | ||||
|             continue; | ||||
|           } | ||||
|                     const index = unseenFreeformValues.indexOf(seenFreeform) | ||||
|           const index = unseenFreeformValues.indexOf(seenFreeform); | ||||
|           if (index < 0) { | ||||
|                         continue | ||||
|             continue; | ||||
|           } | ||||
|                     unseenFreeformValues.splice(index, 1) | ||||
|           unseenFreeformValues.splice(index, 1); | ||||
|         } | ||||
|         // TODO this has _to much_ values | ||||
|                 freeformInput.setData(unseenFreeformValues.join(";")) | ||||
|                 checkedMappings.push(unseenFreeformValues.length > 0) | ||||
|         freeformInput.setData(unseenFreeformValues.join(";")); | ||||
|         checkedMappings.push(unseenFreeformValues.length > 0); | ||||
|       } | ||||
|     } | ||||
|     if (confg.freeform?.key) { | ||||
|       if (!confg.multiAnswer) { | ||||
|         // Somehow, setting multi-answer freeform values is broken if this is not set | ||||
|                 freeformInput.setData(tgs[confg.freeform.key]) | ||||
|         freeformInput.setData(tgs[confg.freeform.key]); | ||||
|       } | ||||
| 
 | ||||
|     } else { | ||||
|             freeformInput.setData(undefined) | ||||
|       freeformInput.setData(undefined); | ||||
|     } | ||||
|         feedback.setData(undefined) | ||||
|     feedback.setData(undefined); | ||||
|   } | ||||
| 
 | ||||
|   $: { | ||||
|     // Even though 'config' is not declared as a store, Svelte uses it as one to update the component | ||||
|     // We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes | ||||
|         initialize($tags, config) | ||||
|     initialize($tags, config); | ||||
|   } | ||||
|     export let selectedTags: TagsFilter = undefined | ||||
| 
 | ||||
|     let mappings: Mapping[] = config?.mappings | ||||
|     let searchTerm: UIEventSource<string> = new UIEventSource("") | ||||
| 
 | ||||
|   $: { | ||||
|     try { | ||||
|  | @ -117,71 +125,85 @@ | |||
|         $freeformInput, | ||||
|         selectedMapping, | ||||
|         checkedMappings, | ||||
|                 tags.data, | ||||
|             ) | ||||
|         tags.data | ||||
|       ); | ||||
|     } catch (e) { | ||||
|             console.error("Could not calculate changeSpecification:", e) | ||||
|             selectedTags = undefined | ||||
|       console.error("Could not calculate changeSpecification:", e); | ||||
|       selectedTags = undefined; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|     let dispatch = createEventDispatcher<{ | ||||
|         saved: { | ||||
|             config: TagRenderingConfig | ||||
|             applied: TagsFilter | ||||
|         } | ||||
|     }>() | ||||
| 
 | ||||
|   function onSave() { | ||||
|     if (selectedTags === undefined) { | ||||
|             console.log("SelectedTags is undefined, ignoring 'onSave'-event") | ||||
|             return | ||||
|       console.log("SelectedTags is undefined, ignoring 'onSave'-event"); | ||||
|       return; | ||||
|     } | ||||
|         if (layer === undefined || layer?.source === null) { | ||||
|     if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) { | ||||
|       /** | ||||
|        * This is a special, priviliged layer. | ||||
|        * We simply apply the tags onto the records | ||||
|        */ | ||||
|             const kv = selectedTags.asChange(tags.data) | ||||
|       const kv = selectedTags.asChange(tags.data); | ||||
|       for (const { k, v } of kv) { | ||||
|         if (v === undefined || v === "") { | ||||
|                     delete tags.data[k] | ||||
|           delete tags.data[k]; | ||||
|         } else { | ||||
|                     tags.data[k] = v | ||||
|           freeformInput.setData(undefined); | ||||
|         } | ||||
|         feedback.setData(undefined); | ||||
|       } | ||||
|             tags.ping() | ||||
|             return | ||||
|     } | ||||
| 
 | ||||
|         dispatch("saved", { config, applied: selectedTags }) | ||||
|     dispatch("saved", { config, applied: selectedTags }); | ||||
|     const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, { | ||||
|             theme: state.layout.id, | ||||
|             changeType: "answer", | ||||
|         }) | ||||
|         freeformInput.setData(undefined) | ||||
|         selectedMapping = undefined | ||||
|         selectedTags = undefined | ||||
|       theme: tags.data["_orig_theme"] ?? state.layout.id, | ||||
|       changeType: "answer" | ||||
|     }); | ||||
|     freeformInput.setData(undefined); | ||||
|     selectedMapping = undefined; | ||||
|     selectedTags = undefined; | ||||
| 
 | ||||
|     change | ||||
|       .CreateChangeDescriptions() | ||||
|       .then((changes) => state.changes.applyChanges(changes)) | ||||
|             .catch(console.error) | ||||
|       .catch(console.error); | ||||
|   } | ||||
| 
 | ||||
|     let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false) | ||||
|   function onInputKeypress(e: Event) { | ||||
|     if (e.key === "Enter") { | ||||
|       onSave(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   $: { | ||||
|     try { | ||||
|       selectedTags = config?.constructChangeSpecification( | ||||
|         $freeformInput, | ||||
|         selectedMapping, | ||||
|         checkedMappings, | ||||
|         tags.data | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.error("Could not calculate changeSpecification:", e); | ||||
|       selectedTags = undefined; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false); | ||||
|   let featureSwitchIsDebugging = | ||||
|         state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false) | ||||
|     let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined) | ||||
|     let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0 | ||||
|     let question = config.question | ||||
|     $: question = config.question | ||||
|     state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false); | ||||
|   let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined); | ||||
|   let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0; | ||||
|   let question = config.question; | ||||
|   $: question = config.question; | ||||
|   if (state?.osmConnection) { | ||||
|     onDestroy( | ||||
|       state.osmConnection?.userDetails?.addCallbackAndRun((ud) => { | ||||
|                 numberOfCs = ud.csCount | ||||
|             }), | ||||
|         ) | ||||
|         numberOfCs = ud.csCount; | ||||
|       }) | ||||
|     ); | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
|  | @ -246,9 +268,8 @@ | |||
|               bind:group={selectedMapping} | ||||
|               name={"mappings-radio-" + config.id} | ||||
|               value={i} | ||||
|               on:keypress={(e) => { | ||||
|                 if (e.key === "Enter") onSave() | ||||
|               }} | ||||
|               on:keypress={e => onInputKeypress(e)} | ||||
| 
 | ||||
|             /> | ||||
|           </TagRenderingMappingInput> | ||||
|         {/each} | ||||
|  | @ -259,6 +280,7 @@ | |||
|               bind:group={selectedMapping} | ||||
|               name={"mappings-radio-" + config.id} | ||||
|               value={config.mappings?.length} | ||||
|               on:keypress={e => onInputKeypress(e)} | ||||
|             /> | ||||
|             <FreeformInput | ||||
|               {config} | ||||
|  | @ -290,6 +312,7 @@ | |||
|               type="checkbox" | ||||
|               name={"mappings-checkbox-" + config.id + "-" + i} | ||||
|               bind:checked={checkedMappings[i]} | ||||
|               on:keypress={e => onInputKeypress(e)} | ||||
|             /> | ||||
|           </TagRenderingMappingInput> | ||||
|         {/each} | ||||
|  | @ -299,6 +322,7 @@ | |||
|               type="checkbox" | ||||
|               name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length} | ||||
|               bind:checked={checkedMappings[config.mappings.length]} | ||||
|               on:keypress={e => onInputKeypress(e)} | ||||
|             /> | ||||
|             <FreeformInput | ||||
|               {config} | ||||
|  | @ -307,7 +331,6 @@ | |||
|               {unit} | ||||
|               feature={selectedElement} | ||||
|               value={freeformInput} | ||||
|               on:selected={() => (checkedMappings[config.mappings.length] = true)} | ||||
|               on:submit={onSave} | ||||
|             /> | ||||
|           </label> | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" | |||
| import { RasterLayerPolygon } from "../Models/RasterLayers" | ||||
| import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" | ||||
| import { OsmTags } from "../Models/OsmFeature" | ||||
| import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource" | ||||
| 
 | ||||
| /** | ||||
|  * The state needed to render a special Visualisation. | ||||
|  | @ -33,7 +34,6 @@ export interface SpecialVisualizationState { | |||
|     } | ||||
| 
 | ||||
|     readonly indexedFeatures: IndexedFeatureSource | ||||
| 
 | ||||
|     /** | ||||
|      * Some features will create a new element that should be displayed. | ||||
|      * These can be injected by appending them to this featuresource (and pinging it) | ||||
|  | @ -59,6 +59,8 @@ export interface SpecialVisualizationState { | |||
|     readonly selectedLayer: UIEventSource<LayerConfig> | ||||
|     readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }> | ||||
| 
 | ||||
|     readonly favourites: FavouritesFeatureSource | ||||
| 
 | ||||
|     /** | ||||
|      * If data is currently being fetched from external sources | ||||
|      */ | ||||
|  |  | |||
|  | @ -79,6 +79,9 @@ import ThemeViewState from "../Models/ThemeViewState" | |||
| import LanguagePicker from "./InputElement/LanguagePicker.svelte" | ||||
| import LogoutButton from "./Base/LogoutButton.svelte" | ||||
| import OpenJosm from "./Base/OpenJosm.svelte" | ||||
| import MarkAsFavourite from "./Popup/MarkAsFavourite.svelte" | ||||
| import MarkAsFavouriteMini from "./Popup/MarkAsFavouriteMini.svelte" | ||||
| import NextChangeViz from "./OpeningHours/NextChangeViz.svelte" | ||||
| 
 | ||||
| class NearbyImageVis implements SpecialVisualization { | ||||
|     // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
 | ||||
|  | @ -532,6 +535,9 @@ export default class SpecialVisualizations { | |||
|                     feature: Feature, | ||||
|                     layer: LayerConfig | ||||
|                 ): BaseUIElement { | ||||
|                     if (!layer.deletion) { | ||||
|                         return undefined | ||||
|                     } | ||||
|                     return new SvelteUIElement(DeleteWizard, { | ||||
|                         tags: tagSource, | ||||
|                         deleteConfig: layer.deletion, | ||||
|  | @ -822,6 +828,46 @@ export default class SpecialVisualizations { | |||
|                     ) | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 funcName: "opening_hours_state", | ||||
|                 docs: "A small element, showing if the POI is currently open and when the next change is", | ||||
|                 args: [ | ||||
|                     { | ||||
|                         name: "key", | ||||
|                         defaultValue: "opening_hours", | ||||
|                         doc: "The tagkey from which the opening hours are read.", | ||||
|                     }, | ||||
|                     { | ||||
|                         name: "prefix", | ||||
|                         defaultValue: "", | ||||
|                         doc: "Remove this string from the start of the value before parsing. __Note: use `&LPARENs` to indicate `(` if needed__", | ||||
|                     }, | ||||
|                     { | ||||
|                         name: "postfix", | ||||
|                         defaultValue: "", | ||||
|                         doc: "Remove this string from the end of the value before parsing. __Note: use `&RPARENs` to indicate `)` if needed__", | ||||
|                     }, | ||||
|                 ], | ||||
|                 needsUrls: [], | ||||
|                 constr( | ||||
|                     state: SpecialVisualizationState, | ||||
|                     tags: UIEventSource<Record<string, string>>, | ||||
|                     args: string[], | ||||
|                     feature: Feature, | ||||
|                     layer: LayerConfig | ||||
|                 ): BaseUIElement { | ||||
|                     const keyToUse = args[0] | ||||
|                     const prefix = args[1] | ||||
|                     const postfix = args[2] | ||||
|                     return new SvelteUIElement(NextChangeViz, { | ||||
|                         state, | ||||
|                         keyToUse, | ||||
|                         tags, | ||||
|                         prefix, | ||||
|                         postfix, | ||||
|                     }) | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 funcName: "canonical", | ||||
|                 needsUrls: [], | ||||
|  | @ -872,7 +918,8 @@ export default class SpecialVisualizations { | |||
|                             t.downloadFeatureAsGeojson.SetClass("font-bold text-lg"), | ||||
|                             t.downloadGeoJsonHelper.SetClass("subtle"), | ||||
|                         ]).SetClass("flex flex-col") | ||||
|                     ).onClick(() => { | ||||
|                     ) | ||||
|                         .onClick(() => { | ||||
|                             console.log("Exporting as Geojson") | ||||
|                             const tags = tagSource.data | ||||
|                             const title = | ||||
|  | @ -886,6 +933,7 @@ export default class SpecialVisualizations { | |||
|                                 } | ||||
|                             ) | ||||
|                         }) | ||||
|                         .SetClass("w-full") | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|  | @ -1482,7 +1530,7 @@ export default class SpecialVisualizations { | |||
|                     const tags = (<ThemeViewState>( | ||||
|                         state | ||||
|                     )).geolocation.currentUserLocation.features.map( | ||||
|                         (features) => features[0].properties | ||||
|                         (features) => features[0]?.properties | ||||
|                     ) | ||||
|                     return new SvelteUIElement(AllTagsPanel, { | ||||
|                         state, | ||||
|  | @ -1490,6 +1538,46 @@ export default class SpecialVisualizations { | |||
|                     }) | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 funcName: "favourite_status", | ||||
|                 needsUrls: [], | ||||
|                 docs: "A button that allows a (logged in) contributor to mark a location as a favourite location", | ||||
|                 args: [], | ||||
|                 constr( | ||||
|                     state: SpecialVisualizationState, | ||||
|                     tagSource: UIEventSource<Record<string, string>>, | ||||
|                     argument: string[], | ||||
|                     feature: Feature, | ||||
|                     layer: LayerConfig | ||||
|                 ): BaseUIElement { | ||||
|                     return new SvelteUIElement(MarkAsFavourite, { | ||||
|                         tags: tagSource, | ||||
|                         state, | ||||
|                         layer, | ||||
|                         feature, | ||||
|                     }) | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 funcName: "favourite_icon", | ||||
|                 needsUrls: [], | ||||
|                 docs: "A small button that allows a (logged in) contributor to mark a location as a favourite location, sized to fit a title-icon", | ||||
|                 args: [], | ||||
|                 constr( | ||||
|                     state: SpecialVisualizationState, | ||||
|                     tagSource: UIEventSource<Record<string, string>>, | ||||
|                     argument: string[], | ||||
|                     feature: Feature, | ||||
|                     layer: LayerConfig | ||||
|                 ): BaseUIElement { | ||||
|                     return new SvelteUIElement(MarkAsFavouriteMini, { | ||||
|                         tags: tagSource, | ||||
|                         state, | ||||
|                         layer, | ||||
|                         feature, | ||||
|                     }) | ||||
|                 }, | ||||
|             }, | ||||
|         ] | ||||
| 
 | ||||
|         specialVisualizations.push(new AutoApplyButton(specialVisualizations)) | ||||
|  |  | |||
|  | @ -1,30 +1,30 @@ | |||
| <script lang="ts"> | ||||
|     import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|     import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
|     import Marker from "../Map/Marker.svelte" | ||||
|     import NextButton from "../Base/NextButton.svelte" | ||||
|     import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts" | ||||
|     import { AllSharedLayers } from "../../Customizations/AllSharedLayers" | ||||
|     import { createEventDispatcher } from "svelte" | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource"; | ||||
|   import { OsmConnection } from "../../Logic/Osm/OsmConnection"; | ||||
|   import Marker from "../Map/Marker.svelte"; | ||||
|   import NextButton from "../Base/NextButton.svelte"; | ||||
|   import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts"; | ||||
|   import { AllSharedLayers } from "../../Customizations/AllSharedLayers"; | ||||
|   import { createEventDispatcher } from "svelte"; | ||||
| 
 | ||||
|     export let info: { id: string; owner: number } | ||||
|     export let category: "layers" | "themes" | ||||
|     export let osmConnection: OsmConnection | ||||
|   export let info: { id: string; owner: number }; | ||||
|   export let category: "layers" | "themes"; | ||||
|   export let osmConnection: OsmConnection; | ||||
|   const dispatch = createEventDispatcher<{ layerSelected: string }>(); | ||||
| 
 | ||||
|   let displayName = UIEventSource.FromPromise( | ||||
|         osmConnection.getInformationAboutUser(info.owner), | ||||
|     ).mapD((response) => response.display_name) | ||||
|     osmConnection.getInformationAboutUser(info.owner) | ||||
|   ).mapD((response) => response.display_name); | ||||
|   let selfId = osmConnection.userDetails.mapD((ud) => ud.uid); | ||||
| 
 | ||||
|     let selfId = osmConnection.userDetails.mapD((ud) => ud.uid) | ||||
| 
 | ||||
|   function fetchIconDescription(layerId): any { | ||||
|     if (category === "themes") { | ||||
|             return AllKnownLayouts.allKnownLayouts.get(layerId).icon | ||||
|       return AllKnownLayouts.allKnownLayouts.get(layerId).icon; | ||||
|     } | ||||
|         return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon | ||||
|     return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon; | ||||
|   } | ||||
| 
 | ||||
|     const dispatch = createEventDispatcher<{ layerSelected: string }>() | ||||
| </script> | ||||
| 
 | ||||
| <NextButton clss="small" on:click={() => dispatch("layerSelected", info)}> | ||||
|  |  | |||
|  | @ -1,69 +1,70 @@ | |||
| <script lang="ts"> | ||||
|   import { Store, UIEventSource } from "../Logic/UIEventSource" | ||||
|   import { Map as MlMap } from "maplibre-gl" | ||||
|   import MaplibreMap from "./Map/MaplibreMap.svelte" | ||||
|   import FeatureSwitchState from "../Logic/State/FeatureSwitchState" | ||||
|   import MapControlButton from "./Base/MapControlButton.svelte" | ||||
|   import ToSvelte from "./Base/ToSvelte.svelte" | ||||
|   import If from "./Base/If.svelte" | ||||
|   import { GeolocationControl } from "./BigComponents/GeolocationControl" | ||||
|   import type { Feature } from "geojson" | ||||
|   import SelectedElementView from "./BigComponents/SelectedElementView.svelte" | ||||
|   import LayerConfig from "../Models/ThemeConfig/LayerConfig" | ||||
|   import Filterview from "./BigComponents/Filterview.svelte" | ||||
|   import ThemeViewState from "../Models/ThemeViewState" | ||||
|   import type { MapProperties } from "../Models/MapProperties" | ||||
|   import Geosearch from "./BigComponents/Geosearch.svelte" | ||||
|   import Translations from "./i18n/Translations" | ||||
|   import { CogIcon, EyeIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import Tr from "./Base/Tr.svelte" | ||||
|   import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte" | ||||
|   import FloatOver from "./Base/FloatOver.svelte" | ||||
|   import PrivacyPolicy from "./BigComponents/PrivacyPolicy" | ||||
|   import Constants from "../Models/Constants" | ||||
|   import TabbedGroup from "./Base/TabbedGroup.svelte" | ||||
|   import UserRelatedState from "../Logic/State/UserRelatedState" | ||||
|   import LoginToggle from "./Base/LoginToggle.svelte" | ||||
|   import LoginButton from "./Base/LoginButton.svelte" | ||||
|   import CopyrightPanel from "./BigComponents/CopyrightPanel" | ||||
|   import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte" | ||||
|   import ModalRight from "./Base/ModalRight.svelte" | ||||
|   import { Utils } from "../Utils" | ||||
|   import Hotkeys from "./Base/Hotkeys" | ||||
|   import { VariableUiElement } from "./Base/VariableUIElement" | ||||
|   import SvelteUIElement from "./Base/SvelteUIElement" | ||||
|   import OverlayToggle from "./BigComponents/OverlayToggle.svelte" | ||||
|   import LevelSelector from "./BigComponents/LevelSelector.svelte" | ||||
|   import ExtraLinkButton from "./BigComponents/ExtraLinkButton" | ||||
|   import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte" | ||||
|   import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte" | ||||
|   import type { RasterLayerPolygon } from "../Models/RasterLayers" | ||||
|   import { AvailableRasterLayers } from "../Models/RasterLayers" | ||||
|   import RasterLayerOverview from "./Map/RasterLayerOverview.svelte" | ||||
|   import IfHidden from "./Base/IfHidden.svelte" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import MapillaryLink from "./BigComponents/MapillaryLink.svelte" | ||||
|   import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte" | ||||
|   import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte" | ||||
|   import StateIndicator from "./BigComponents/StateIndicator.svelte" | ||||
|   import ShareScreen from "./BigComponents/ShareScreen.svelte" | ||||
|   import UploadingImageCounter from "./Image/UploadingImageCounter.svelte" | ||||
|   import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte" | ||||
|   import Cross from "../assets/svg/Cross.svelte" | ||||
|   import Summary from "./BigComponents/Summary.svelte" | ||||
|   import Mastodon from "../assets/svg/Mastodon.svelte" | ||||
|   import Bug from "../assets/svg/Bug.svelte" | ||||
|   import Liberapay from "../assets/svg/Liberapay.svelte" | ||||
|   import Min from "../assets/svg/Min.svelte" | ||||
|   import Plus from "../assets/svg/Plus.svelte" | ||||
|   import Filter from "../assets/svg/Filter.svelte" | ||||
|   import Add from "../assets/svg/Add.svelte" | ||||
|   import Statistics from "../assets/svg/Statistics.svelte" | ||||
|   import Community from "../assets/svg/Community.svelte" | ||||
|   import Download from "../assets/svg/Download.svelte" | ||||
|   import Share from "../assets/svg/Share.svelte" | ||||
|   import LanguagePicker from "./InputElement/LanguagePicker.svelte" | ||||
|   import OpenJosm from "./Base/OpenJosm.svelte" | ||||
|   import { Store, UIEventSource } from "../Logic/UIEventSource"; | ||||
|   import { Map as MlMap } from "maplibre-gl"; | ||||
|   import MaplibreMap from "./Map/MaplibreMap.svelte"; | ||||
|   import FeatureSwitchState from "../Logic/State/FeatureSwitchState"; | ||||
|   import MapControlButton from "./Base/MapControlButton.svelte"; | ||||
|   import ToSvelte from "./Base/ToSvelte.svelte"; | ||||
|   import If from "./Base/If.svelte"; | ||||
|   import { GeolocationControl } from "./BigComponents/GeolocationControl"; | ||||
|   import type { Feature } from "geojson"; | ||||
|   import SelectedElementView from "./BigComponents/SelectedElementView.svelte"; | ||||
|   import LayerConfig from "../Models/ThemeConfig/LayerConfig"; | ||||
|   import Filterview from "./BigComponents/Filterview.svelte"; | ||||
|   import ThemeViewState from "../Models/ThemeViewState"; | ||||
|   import type { MapProperties } from "../Models/MapProperties"; | ||||
|   import Geosearch from "./BigComponents/Geosearch.svelte"; | ||||
|   import Translations from "./i18n/Translations"; | ||||
|   import { CogIcon, EyeIcon, HeartIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"; | ||||
|   import Tr from "./Base/Tr.svelte"; | ||||
|   import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte"; | ||||
|   import FloatOver from "./Base/FloatOver.svelte"; | ||||
|   import PrivacyPolicy from "./BigComponents/PrivacyPolicy"; | ||||
|   import Constants from "../Models/Constants"; | ||||
|   import TabbedGroup from "./Base/TabbedGroup.svelte"; | ||||
|   import UserRelatedState from "../Logic/State/UserRelatedState"; | ||||
|   import LoginToggle from "./Base/LoginToggle.svelte"; | ||||
|   import LoginButton from "./Base/LoginButton.svelte"; | ||||
|   import CopyrightPanel from "./BigComponents/CopyrightPanel"; | ||||
|   import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte"; | ||||
|   import ModalRight from "./Base/ModalRight.svelte"; | ||||
|   import { Utils } from "../Utils"; | ||||
|   import Hotkeys from "./Base/Hotkeys"; | ||||
|   import { VariableUiElement } from "./Base/VariableUIElement"; | ||||
|   import SvelteUIElement from "./Base/SvelteUIElement"; | ||||
|   import OverlayToggle from "./BigComponents/OverlayToggle.svelte"; | ||||
|   import LevelSelector from "./BigComponents/LevelSelector.svelte"; | ||||
|   import ExtraLinkButton from "./BigComponents/ExtraLinkButton"; | ||||
|   import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte"; | ||||
|   import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte"; | ||||
|   import type { RasterLayerPolygon } from "../Models/RasterLayers"; | ||||
|   import { AvailableRasterLayers } from "../Models/RasterLayers"; | ||||
|   import RasterLayerOverview from "./Map/RasterLayerOverview.svelte"; | ||||
|   import IfHidden from "./Base/IfHidden.svelte"; | ||||
|   import { onDestroy } from "svelte"; | ||||
|   import MapillaryLink from "./BigComponents/MapillaryLink.svelte"; | ||||
|   import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"; | ||||
|   import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte"; | ||||
|   import StateIndicator from "./BigComponents/StateIndicator.svelte"; | ||||
|   import ShareScreen from "./BigComponents/ShareScreen.svelte"; | ||||
|   import UploadingImageCounter from "./Image/UploadingImageCounter.svelte"; | ||||
|   import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte"; | ||||
|   import Cross from "../assets/svg/Cross.svelte"; | ||||
|   import Summary from "./BigComponents/Summary.svelte"; | ||||
|   import LanguagePicker from "./InputElement/LanguagePicker.svelte"; | ||||
|   import Mastodon from "../assets/svg/Mastodon.svelte"; | ||||
|   import Bug from "../assets/svg/Bug.svelte"; | ||||
|   import Liberapay from "../assets/svg/Liberapay.svelte"; | ||||
|   import OpenJosm from "./Base/OpenJosm.svelte"; | ||||
|   import Min from "../assets/svg/Min.svelte"; | ||||
|   import Plus from "../assets/svg/Plus.svelte"; | ||||
|   import Filter from "../assets/svg/Filter.svelte"; | ||||
|   import Add from "../assets/svg/Add.svelte"; | ||||
|   import Statistics from "../assets/svg/Statistics.svelte"; | ||||
|   import Community from "../assets/svg/Community.svelte"; | ||||
|   import Download from "../assets/svg/Download.svelte"; | ||||
|   import Share from "../assets/svg/Share.svelte"; | ||||
|   import Favourites from "./Favourites/Favourites.svelte"; | ||||
| 
 | ||||
|   export let state: ThemeViewState | ||||
|     let layout = state.layout | ||||
|  | @ -72,18 +73,18 @@ | |||
|     let selectedElement: UIEventSource<Feature> = state.selectedElement | ||||
|     let selectedLayer: UIEventSource<LayerConfig> = state.selectedLayer | ||||
| 
 | ||||
|     let currentZoom = state.mapProperties.zoom | ||||
|     let showCrosshair = state.userRelatedState.showCrosshair | ||||
|     let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation | ||||
|     let centerFeatures = state.closestFeatures.features | ||||
|   let currentZoom = state.mapProperties.zoom; | ||||
|   let showCrosshair = state.userRelatedState.showCrosshair; | ||||
|   let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation; | ||||
|   let centerFeatures = state.closestFeatures.features; | ||||
|   const selectedElementView = selectedElement.map( | ||||
|     (selectedElement) => { | ||||
|       // Svelte doesn't properly reload some of the legacy UI-elements | ||||
|       // As such, we _reconstruct_ the selectedElementView every time a new feature is selected | ||||
|       // This is a bit wasteful, but until everything is a svelte-component, this should do the trick | ||||
|             const layer = selectedLayer.data | ||||
|       const layer = selectedLayer.data; | ||||
|       if (selectedElement === undefined || layer === undefined) { | ||||
|                 return undefined | ||||
|         return undefined; | ||||
|       } | ||||
| 
 | ||||
|             if (!(layer.tagRenderings?.length > 0) || layer.title === undefined) { | ||||
|  | @ -230,18 +231,16 @@ | |||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|     {#if $arrowKeysWereUsed !== undefined} | ||||
|       {#if $centerFeatures.length > 0} | ||||
|         <div class="interactive pointer-events-auto p-1"> | ||||
| 
 | ||||
|     {#if $arrowKeysWereUsed !== undefined && $centerFeatures?.length > 0} | ||||
|       <div class="pointer-events-auto interactive p-1"> | ||||
|         {#each $centerFeatures as feat, i (feat.properties.id)} | ||||
|           <div class="flex"> | ||||
|               <b>{i + 1}.</b> | ||||
|               <Summary {state} feature={feat} /> | ||||
|           <b>{i+1}.</b><Summary {state} feature={feat}/> | ||||
|           </div> | ||||
|         {/each} | ||||
|       </div> | ||||
|     {/if} | ||||
|     {/if} | ||||
|     <div class="flex flex-col items-end"> | ||||
|       <!-- bottom right elements --> | ||||
|       <If condition={state.floors.map((f) => f.length > 1)}> | ||||
|  | @ -495,22 +494,31 @@ | |||
|       </div> | ||||
| 
 | ||||
|       <div class="flex" slot="title2"> | ||||
|         <HeartIcon class="h-6 w-6" /> | ||||
|         <Tr t={Translations.t.favouritePoi.tab}/> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="flex flex-col m-2" slot="content2"> | ||||
|         <h3> <Tr t={Translations.t.favouritePoi.title}/></h3> | ||||
|         <Favourites {state}/> | ||||
|       </div> | ||||
|       <div class="flex" slot="title3"> | ||||
|         <Community class="h-6 w-6" /> | ||||
|         <Tr t={Translations.t.communityIndex.title} /> | ||||
|       </div> | ||||
|       <div class="m-2" slot="content2"> | ||||
|       <div class="m-2" slot="content3"> | ||||
|         <CommunityIndexView location={state.mapProperties.location} /> | ||||
|       </div> | ||||
|       <div class="flex" slot="title3"> | ||||
|       <div class="flex" slot="title4"> | ||||
|         <EyeIcon class="w-6" /> | ||||
|         <Tr t={Translations.t.privacy.title} /> | ||||
|       </div> | ||||
|       <div class="m-2" slot="content3"> | ||||
|       <div class="m-2" slot="content4"> | ||||
|         <ToSvelte construct={() => new PrivacyPolicy()} /> | ||||
|       </div> | ||||
| 
 | ||||
|       <Tr slot="title4" t={Translations.t.advanced.title} /> | ||||
|       <div class="m-2 flex flex-col" slot="content4"> | ||||
|       <Tr slot="title5" t={Translations.t.advanced.title} /> | ||||
|       <div class="m-2 flex flex-col" slot="content5"> | ||||
|         <If condition={featureSwitches.featureSwitchEnableLogin}> | ||||
|           <OpenIdEditor mapProperties={state.mapProperties} /> | ||||
|           <OpenJosm {state} /> | ||||
|  |  | |||
|  | @ -301,10 +301,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
|         if (str === undefined || str === null) { | ||||
|             return undefined | ||||
|         } | ||||
|         if (typeof str !== "string") { | ||||
|             console.error("Not a string:", str) | ||||
|             return undefined | ||||
|         } | ||||
|         if (str.length <= l) { | ||||
|             return str | ||||
|         } | ||||
|         return str.substr(0, l - 3) + "..." | ||||
|         return str.substr(0, l - 1) + "…" | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -1,229 +1,426 @@ | |||
| { | ||||
|   "#": "Generated with generateStats.ts", | ||||
|   "date": "2023-12-04T14:42:01.299Z", | ||||
|   "keys": { | ||||
|     "addr:street": 117211930, | ||||
|     "addr:housenumber": 125040768, | ||||
|     "emergency": 1939478, | ||||
|     "barrier": 18424246, | ||||
|     "tourism": 2683525, | ||||
|     "amenity": 20541353, | ||||
|     "bench": 894256, | ||||
|     "rental": 8838, | ||||
|     "bicycle_rental": 7447, | ||||
|     "vending": 206755, | ||||
|     "service:bicycle:rental": 3570, | ||||
|     "pub": 316, | ||||
|     "theme": 426, | ||||
|     "service:bicycle:.*": 0, | ||||
|     "service:bicycle:cleaning": 807, | ||||
|     "shop": 5062252, | ||||
|     "service:bicycle:retail": 9162, | ||||
|     "network": 2181336, | ||||
|     "sport": 2194801, | ||||
|     "service:bicycle:repair": 11381, | ||||
|     "association": 369, | ||||
|     "ngo": 42, | ||||
|     "leisure": 7368076, | ||||
|     "club": 38429, | ||||
|     "disused:amenity": 40880, | ||||
|     "planned:amenity": 205, | ||||
|     "tileId": 0, | ||||
|     "construction:amenity": 1206, | ||||
|     "cycleway": 906487, | ||||
|     "highway": 218189453, | ||||
|     "bicycle": 6218071, | ||||
|     "cyclestreet": 8185, | ||||
|     "camera:direction": 40676, | ||||
|     "direction": 1896015, | ||||
|     "access": 16030036, | ||||
|     "entrance": 2954076, | ||||
|     "name:etymology": 24485, | ||||
|     "memorial": 132172, | ||||
|     "indoor": 353116, | ||||
|     "name:etymology:wikidata": 285224, | ||||
|     "landuse": 35524214, | ||||
|     "name": 88330405, | ||||
|     "protect_class": 73801, | ||||
|     "information": 831513, | ||||
|     "man_made": 5116088, | ||||
|     "boundary": 2142378, | ||||
|     "tower:type": 451658, | ||||
|     "playground": 109175, | ||||
|     "route": 939184, | ||||
|     "surveillance:type": 116760, | ||||
|     "natural": 52353504, | ||||
|     "building": 500469053 | ||||
|     "FIXME": 119237, | ||||
|     "access": 20023328, | ||||
|     "addr:housenumber": 146524978, | ||||
|     "addr:street": 137485111, | ||||
|     "advertising": 158347, | ||||
|     "amenity": 25340913, | ||||
|     "area": 1803451, | ||||
|     "association": 757, | ||||
|     "barrier": 23634152, | ||||
|     "bench": 1300789, | ||||
|     "bicycle": 7507086, | ||||
|     "bicycle_rental": 26948, | ||||
|     "boundary": 2366033, | ||||
|     "brand": 2317628, | ||||
|     "building": 585543589, | ||||
|     "camera:direction": 61201, | ||||
|     "climbing": 9051, | ||||
|     "club": 53046, | ||||
|     "construction:amenity": 1943, | ||||
|     "conveying": 27311, | ||||
|     "craft": 296376, | ||||
|     "crossing": 8736722, | ||||
|     "cyclestreet": 12505, | ||||
|     "cycleway": 1016837, | ||||
|     "direction": 2978834, | ||||
|     "disused:amenity": 63413, | ||||
|     "dog": 95086, | ||||
|     "door": 280843, | ||||
|     "drinking_water": 136067, | ||||
|     "emergency": 2542692, | ||||
|     "entrance": 3769592, | ||||
|     "fixme": 1746318, | ||||
|     "footway": 7540651, | ||||
|     "generator:source": 2387982, | ||||
|     "healthcare": 790125, | ||||
|     "highway": 249307936, | ||||
|     "indoor": 562051, | ||||
|     "information": 1073014, | ||||
|     "isced:2011:level": 27, | ||||
|     "isced:level:2011": 74, | ||||
|     "landuse": 41730047, | ||||
|     "leisure": 8955744, | ||||
|     "man_made": 6799900, | ||||
|     "memorial": 209327, | ||||
|     "motorcar": 621864, | ||||
|     "name": 98684655, | ||||
|     "name:etymology": 56375, | ||||
|     "name:etymology:wikidata": 1174439, | ||||
|     "name:nl": 80468, | ||||
|     "natural": 64176097, | ||||
|     "ngo": 57, | ||||
|     "office": 1092855, | ||||
|     "parking_space": 600707, | ||||
|     "planned:amenity": 237, | ||||
|     "playground": 182188, | ||||
|     "post_office": 16379, | ||||
|     "protect_class": 83815, | ||||
|     "pub": 324, | ||||
|     "public_transport": 5111577, | ||||
|     "railway": 7068070, | ||||
|     "recycling_type": 385569, | ||||
|     "ref": 18607577, | ||||
|     "rental": 13611, | ||||
|     "route": 1075802, | ||||
|     "service:bicycle:cleaning": 1179, | ||||
|     "service:bicycle:pump": 14053, | ||||
|     "service:bicycle:pump:operational_status": 344, | ||||
|     "service:bicycle:rental": 4599, | ||||
|     "service:bicycle:repair": 15470, | ||||
|     "service:bicycle:retail": 11467, | ||||
|     "service:bicycle:tools": 6227, | ||||
|     "shelter": 1647743, | ||||
|     "shop": 5860878, | ||||
|     "species": 1656206, | ||||
|     "species:wikidata": 107778, | ||||
|     "sport": 2580042, | ||||
|     "subject": 40076, | ||||
|     "surface:colour": 17851, | ||||
|     "surveillance:type": 171923, | ||||
|     "theme": 906, | ||||
|     "toilets": 90842, | ||||
|     "tourism": 3211694, | ||||
|     "tower:type": 596349, | ||||
|     "type": 11757856, | ||||
|     "vending": 252016 | ||||
|   }, | ||||
|   "tags": { | ||||
|     "emergency": { | ||||
|       "defibrillator": 51273, | ||||
|       "ambulance_station": 11047, | ||||
|       "fire_extinguisher": 7355, | ||||
|       "fire_hydrant": 1598739 | ||||
|     }, | ||||
|     "barrier": { | ||||
|       "cycle_barrier": 104166, | ||||
|       "bollard": 502220, | ||||
|       "wall": 3535056 | ||||
|     }, | ||||
|     "tourism": { | ||||
|       "artwork": 187470, | ||||
|       "map": 51, | ||||
|       "viewpoint": 191765 | ||||
|     "advertising": { | ||||
|       "billboard": 76420, | ||||
|       "board": 15040, | ||||
|       "column": 21212, | ||||
|       "flag": 4264, | ||||
|       "poster_box": 22932, | ||||
|       "screen": 1352, | ||||
|       "sculpture": 145, | ||||
|       "sign": 6172, | ||||
|       "tarp": 407, | ||||
|       "totem": 7097, | ||||
|       "wall_painting": 132 | ||||
|     }, | ||||
|     "amenity": { | ||||
|       "bench": 1736979, | ||||
|       "bicycle_library": 36, | ||||
|       "bicycle_rental": 49082, | ||||
|       "vending_machine": 201871, | ||||
|       "bar": 199662, | ||||
|       "pub": 174979, | ||||
|       "cafe": 467521, | ||||
|       "restaurant": 1211671, | ||||
|       "bicycle_wash": 44, | ||||
|       "bike_wash": 0, | ||||
|       "bicycle_repair_station": 9247, | ||||
|       "bicycle_parking": 435959, | ||||
|       "binoculars": 479, | ||||
|       "biergarten": 10309, | ||||
|       "charging_station": 65402, | ||||
|       "drinking_water": 250463, | ||||
|       "fast_food": 460079, | ||||
|       "fire_station": 122200, | ||||
|       "parking": 4255206, | ||||
|       "public_bookcase": 13120, | ||||
|       "toilets": 350648, | ||||
|       "recycling": 333925, | ||||
|       "waste_basket": 550357, | ||||
|       "waste_disposal": 156765 | ||||
|     }, | ||||
|     "bench": { | ||||
|       "stand_up_bench": 87, | ||||
|       "yes": 524993 | ||||
|     }, | ||||
|     "service:bicycle:rental": { | ||||
|       "yes": 3054 | ||||
|     }, | ||||
|     "pub": { | ||||
|       "cycling": 9, | ||||
|       "bicycle": 0 | ||||
|     }, | ||||
|     "theme": { | ||||
|       "cycling": 8, | ||||
|       "bicycle": 16 | ||||
|     }, | ||||
|     "service:bicycle:cleaning": { | ||||
|       "yes": 607, | ||||
|       "diy": 0 | ||||
|     }, | ||||
|     "shop": { | ||||
|       "bicycle": 46488, | ||||
|       "sports": 37024 | ||||
|     }, | ||||
|     "sport": { | ||||
|       "cycling": 6045, | ||||
|       "bicycle": 96 | ||||
|       "animal_shelter": 6056, | ||||
|       "atm": 207899, | ||||
|       "bank": 389470, | ||||
|       "bar": 219208, | ||||
|       "bench": 2313183, | ||||
|       "bicycle_library": 46, | ||||
|       "bicycle_parking": 616881, | ||||
|       "bicycle_rental": 63710, | ||||
|       "bicycle_repair_station": 14026, | ||||
|       "bicycle_wash": 79, | ||||
|       "biergarten": 10323, | ||||
|       "bike_wash": 1, | ||||
|       "binoculars": 1109, | ||||
|       "cafe": 530066, | ||||
|       "car_rental": 26726, | ||||
|       "charging_station": 111996, | ||||
|       "childcare": 50390, | ||||
|       "clinic": 179739, | ||||
|       "clock": 25274, | ||||
|       "college": 64379, | ||||
|       "dentist": 122076, | ||||
|       "doctors": 166850, | ||||
|       "drinking_water": 294750, | ||||
|       "fast_food": 533335, | ||||
|       "fire_station": 131842, | ||||
|       "hospital": 204756, | ||||
|       "ice_cream": 48853, | ||||
|       "kindergarten": 294441, | ||||
|       "nightclub": 22779, | ||||
|       "parcel_locker": 44270, | ||||
|       "parking": 5158899, | ||||
|       "parking_space": 2292063, | ||||
|       "pharmacy": 383181, | ||||
|       "post_box": 370286, | ||||
|       "post_office": 198908, | ||||
|       "pub": 185475, | ||||
|       "public_bookcase": 21608, | ||||
|       "reception_desk": 2426, | ||||
|       "recycling": 417512, | ||||
|       "restaurant": 1346895, | ||||
|       "school": 1286594, | ||||
|       "shelter": 494594, | ||||
|       "shower": 27029, | ||||
|       "ticket_validator": 7730, | ||||
|       "toilets": 417991, | ||||
|       "university": 54299, | ||||
|       "vending_machine": 247257, | ||||
|       "veterinary": 52813, | ||||
|       "waste_basket": 759718, | ||||
|       "waste_disposal": 219245 | ||||
|     }, | ||||
|     "association": { | ||||
|       "cycling": 5, | ||||
|       "bicycle": 20 | ||||
|       "bicycle": 47, | ||||
|       "cycling": 5 | ||||
|     }, | ||||
|     "ngo": { | ||||
|       "cycling": 0, | ||||
|       "bicycle": 0 | ||||
|     "barrier": { | ||||
|       "bollard": 668017, | ||||
|       "cycle_barrier": 122201, | ||||
|       "kerb": 1178769, | ||||
|       "retaining_wall": 472454, | ||||
|       "wall": 4448788 | ||||
|     }, | ||||
|     "leisure": { | ||||
|       "bird_hide": 5669, | ||||
|       "nature_reserve": 117016, | ||||
|       "picnic_table": 206322, | ||||
|       "pitch": 1990293, | ||||
|       "playground": 705102 | ||||
|     }, | ||||
|     "club": { | ||||
|       "cycling": 3, | ||||
|       "bicycle": 49 | ||||
|     }, | ||||
|     "disused:amenity": { | ||||
|       "charging_station": 164 | ||||
|     }, | ||||
|     "planned:amenity": { | ||||
|       "charging_station": 115 | ||||
|     }, | ||||
|     "construction:amenity": { | ||||
|       "charging_station": 221 | ||||
|     }, | ||||
|     "cycleway": { | ||||
|       "lane": 314576, | ||||
|       "track": 86541, | ||||
|       "shared_lane": 60824 | ||||
|     }, | ||||
|     "highway": { | ||||
|       "residential": 61321708, | ||||
|       "crossing": 6119521, | ||||
|       "cycleway": 1423789, | ||||
|       "traffic_signals": 1512639, | ||||
|       "tertiary": 7051727, | ||||
|       "unclassified": 15756878, | ||||
|       "secondary": 4486617, | ||||
|       "primary": 3110552, | ||||
|       "footway": 16496620, | ||||
|       "path": 11438303, | ||||
|       "steps": 1327396, | ||||
|       "corridor": 27051, | ||||
|       "pedestrian": 685989, | ||||
|       "bridleway": 102280, | ||||
|       "track": 22670967, | ||||
|       "living_street": 1519108, | ||||
|       "street_lamp": 2811705 | ||||
|     "bench": { | ||||
|       "stand_up_bench": 212, | ||||
|       "yes": 778144 | ||||
|     }, | ||||
|     "bicycle": { | ||||
|       "designated": 1110839 | ||||
|     }, | ||||
|     "cyclestreet": { | ||||
|       "yes": 8164 | ||||
|     }, | ||||
|     "access": { | ||||
|       "public": 6222, | ||||
|       "yes": 1363526 | ||||
|     }, | ||||
|     "memorial": { | ||||
|       "ghost_bike": 503 | ||||
|     }, | ||||
|     "indoor": { | ||||
|       "door": 9722 | ||||
|     }, | ||||
|     "landuse": { | ||||
|       "grass": 4898559, | ||||
|       "village_green": 104681 | ||||
|     }, | ||||
|     "name": { | ||||
|       "Park Oude God": 1 | ||||
|     }, | ||||
|     "information": { | ||||
|       "board": 242007, | ||||
|       "map": 85912, | ||||
|       "office": 24139, | ||||
|       "visitor_centre": 285 | ||||
|     }, | ||||
|     "man_made": { | ||||
|       "surveillance": 148172, | ||||
|       "watermill": 9699 | ||||
|       "designated": 1499247, | ||||
|       "no": 1614544, | ||||
|       "yes": 3753651 | ||||
|     }, | ||||
|     "boundary": { | ||||
|       "protected_area": 97075 | ||||
|       "protected_area": 111282 | ||||
|     }, | ||||
|     "tower:type": { | ||||
|       "observation": 19654 | ||||
|     "climbing": { | ||||
|       "area": 191, | ||||
|       "crag": 2873, | ||||
|       "route": 1040, | ||||
|       "site": 14 | ||||
|     }, | ||||
|     "playground": { | ||||
|       "forest": 56 | ||||
|     "club": { | ||||
|       "bicycle": 60, | ||||
|       "climbing": 1, | ||||
|       "cycling": 7 | ||||
|     }, | ||||
|     "surveillance:type": { | ||||
|       "camera": 112963, | ||||
|       "ALPR": 2522, | ||||
|       "ANPR": 3 | ||||
|     "construction:amenity": { | ||||
|       "charging_station": 259 | ||||
|     }, | ||||
|     "conveying": { | ||||
|       "yes": 12153 | ||||
|     }, | ||||
|     "craft": { | ||||
|       "key_cutter": 3711, | ||||
|       "shoe_repair": 64 | ||||
|     }, | ||||
|     "crossing": { | ||||
|       "traffic_signals": 1408141 | ||||
|     }, | ||||
|     "cyclestreet": { | ||||
|       "yes": 12480 | ||||
|     }, | ||||
|     "cycleway": { | ||||
|       "lane": 300810, | ||||
|       "shared_lane": 71051, | ||||
|       "track": 77166 | ||||
|     }, | ||||
|     "disused:amenity": { | ||||
|       "charging_station": 289, | ||||
|       "drinking_water": 2758 | ||||
|     }, | ||||
|     "dog": { | ||||
|       "unleashed": 727 | ||||
|     }, | ||||
|     "drinking_water": { | ||||
|       "yes": 74561 | ||||
|     }, | ||||
|     "emergency": { | ||||
|       "ambulance_station": 13020, | ||||
|       "defibrillator": 80699, | ||||
|       "fire_extinguisher": 11605, | ||||
|       "fire_hydrant": 1928477 | ||||
|     }, | ||||
|     "footway": { | ||||
|       "crossing": 3111184 | ||||
|     }, | ||||
|     "generator:source": { | ||||
|       "wind": 390537 | ||||
|     }, | ||||
|     "healthcare": { | ||||
|       "physiotherapist": 17548 | ||||
|     }, | ||||
|     "highway": { | ||||
|       "bridleway": 107507, | ||||
|       "bus_stop": 3459595, | ||||
|       "corridor": 46847, | ||||
|       "crossing": 8505991, | ||||
|       "cycleway": 1693405, | ||||
|       "elevator": 39221, | ||||
|       "footway": 21573091, | ||||
|       "living_street": 1753722, | ||||
|       "motorway": 1182914, | ||||
|       "motorway_link": 829035, | ||||
|       "path": 13690001, | ||||
|       "pedestrian": 767066, | ||||
|       "primary": 3462637, | ||||
|       "primary_link": 433106, | ||||
|       "residential": 65553821, | ||||
|       "secondary": 5008689, | ||||
|       "secondary_link": 340521, | ||||
|       "service": 54202864, | ||||
|       "speed_camera": 61915, | ||||
|       "speed_display": 2621, | ||||
|       "steps": 1618344, | ||||
|       "street_lamp": 3879570, | ||||
|       "tertiary": 7809143, | ||||
|       "tertiary_link": 245867, | ||||
|       "track": 25718176, | ||||
|       "traffic_signals": 1709993, | ||||
|       "trunk": 1679773, | ||||
|       "trunk_link": 519826, | ||||
|       "unclassified": 16914480 | ||||
|     }, | ||||
|     "indoor": { | ||||
|       "area": 25332, | ||||
|       "corridor": 17609, | ||||
|       "door": 19157, | ||||
|       "level": 4253, | ||||
|       "room": 157006, | ||||
|       "wall": 32366 | ||||
|     }, | ||||
|     "information": { | ||||
|       "board": 321201, | ||||
|       "guidepost": 520873, | ||||
|       "map": 108166, | ||||
|       "office": 27749, | ||||
|       "route_marker": 59596, | ||||
|       "visitor_centre": 523 | ||||
|     }, | ||||
|     "isced:level:2011": { | ||||
|       "early_childhood": 0 | ||||
|     }, | ||||
|     "landuse": { | ||||
|       "village_green": 102589 | ||||
|     }, | ||||
|     "leisure": { | ||||
|       "bird_hide": 6607, | ||||
|       "dog_park": 21993, | ||||
|       "fitness_centre": 72920, | ||||
|       "fitness_station": 62923, | ||||
|       "hackerspace": 1537, | ||||
|       "nature_reserve": 129575, | ||||
|       "park": 1168747, | ||||
|       "picnic_table": 302582, | ||||
|       "pitch": 2307262, | ||||
|       "playground": 821692, | ||||
|       "sports_centre": 231823, | ||||
|       "track": 124600 | ||||
|     }, | ||||
|     "man_made": { | ||||
|       "surveillance": 205953 | ||||
|     }, | ||||
|     "memorial": { | ||||
|       "ghost_bike": 748, | ||||
|       "plaque": 45536 | ||||
|     }, | ||||
|     "motorcar": { | ||||
|       "no": 270350, | ||||
|       "yes": 190966 | ||||
|     }, | ||||
|     "natural": { | ||||
|       "tree": 18245059 | ||||
|       "cliff": 761375, | ||||
|       "rock": 229114, | ||||
|       "stone": 52141, | ||||
|       "tree": 23309774 | ||||
|     }, | ||||
|     "ngo": { | ||||
|       "bicycle": 0, | ||||
|       "cycling": 0 | ||||
|     }, | ||||
|     "office": { | ||||
|       "government": 250353 | ||||
|     }, | ||||
|     "parking_space": { | ||||
|       "disabled": 161162 | ||||
|     }, | ||||
|     "planned:amenity": { | ||||
|       "charging_station": 72 | ||||
|     }, | ||||
|     "playground": { | ||||
|       "forest": 77 | ||||
|     }, | ||||
|     "post_office": { | ||||
|       "post_partner": 7560 | ||||
|     }, | ||||
|     "pub": { | ||||
|       "bicycle": 0, | ||||
|       "cycling": 12 | ||||
|     }, | ||||
|     "public_transport": { | ||||
|       "platform": 3254387 | ||||
|     }, | ||||
|     "railway": { | ||||
|       "platform": 167408 | ||||
|     }, | ||||
|     "recycling_type": { | ||||
|       "centre": 29508, | ||||
|       "container": 355016 | ||||
|     }, | ||||
|     "route": { | ||||
|       "bus": 272174 | ||||
|     }, | ||||
|     "service:bicycle:cleaning": { | ||||
|       "diy": 4, | ||||
|       "yes": 909 | ||||
|     }, | ||||
|     "service:bicycle:pump": { | ||||
|       "no": 1548, | ||||
|       "yes": 12452 | ||||
|     }, | ||||
|     "service:bicycle:pump:operational_status": { | ||||
|       "broken": 122 | ||||
|     }, | ||||
|     "service:bicycle:rental": { | ||||
|       "yes": 3902 | ||||
|     }, | ||||
|     "service:bicycle:repair": { | ||||
|       "yes": 15134 | ||||
|     }, | ||||
|     "service:bicycle:tools": { | ||||
|       "no": 354, | ||||
|       "yes": 5872 | ||||
|     }, | ||||
|     "shelter": { | ||||
|       "yes": 884942 | ||||
|     }, | ||||
|     "shop": { | ||||
|       "bicycle": 51336, | ||||
|       "bicycle_rental": 1, | ||||
|       "rental": 5206, | ||||
|       "sports": 40802 | ||||
|     }, | ||||
|     "sport": { | ||||
|       "bicycle": 114, | ||||
|       "climbing": 29028, | ||||
|       "cycling": 8225 | ||||
|     }, | ||||
|     "surface:colour": { | ||||
|       "rainbow": 217 | ||||
|     }, | ||||
|     "surveillance:type": { | ||||
|       "ALPR": 4424, | ||||
|       "ANPR": 3, | ||||
|       "camera": 165247 | ||||
|     }, | ||||
|     "theme": { | ||||
|       "bicycle": 16, | ||||
|       "cycling": 7 | ||||
|     }, | ||||
|     "toilets": { | ||||
|       "yes": 70811 | ||||
|     }, | ||||
|     "tourism": { | ||||
|       "artwork": 245861, | ||||
|       "hotel": 407208, | ||||
|       "map": 51, | ||||
|       "viewpoint": 219932 | ||||
|     }, | ||||
|     "tower:type": { | ||||
|       "observation": 23057 | ||||
|     }, | ||||
|     "type": { | ||||
|       "route": 1005677 | ||||
|     }, | ||||
|     "vending": { | ||||
|       "elongated_coin": 816, | ||||
|       "parcel_pickup;parcel_mail_in": 522, | ||||
|       "parking_tickets": 70753, | ||||
|       "public_transport_tickets": 26895 | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/assets/svg/Center.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/assets/svg/Center.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| <script> | ||||
| export let color = "#000000" | ||||
| </script> | ||||
|  <svg {...$$restProps} on:click on:mouseover on:mouseenter on:mouseleave on:keydown    width="544.02838"    height="544.02838"    viewBox="0 0 544.02838 544.02838"    version="1.1"    id="svg1"    sodipodi:docname="center.svg"    inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"    xmlns="http://www.w3.org/2000/svg"    xmlns:svg="http://www.w3.org/2000/svg">   <defs      id="defs1" />   <sodipodi:namedview      id="namedview1"      pagecolor="#505050"      bordercolor="#eeeeee"      borderopacity="1"      inkscape:showpageshadow="0"      inkscape:pageopacity="0"      inkscape:pagecheckerboard="0"      inkscape:deskcolor="#d1d1d1"      showguides="true"      inkscape:zoom="0.90326851"      inkscape:cx="393.57068"      inkscape:cy="250.756"      inkscape:window-width="1920"      inkscape:window-height="995"      inkscape:window-x="0"      inkscape:window-y="0"      inkscape:window-maximized="1"      inkscape:current-layer="svg1">     <sodipodi:guide        position="171.95879,103.32864"        orientation="0,-1"        id="guide4"        inkscape:locked="false" />     <sodipodi:guide        position="271.68286,132.35281"        orientation="1,0"        id="guide5"        inkscape:locked="false" />   </sodipodi:namedview>   <path      d="m 365.63918,111.75001 h -62.375 V 15.9375 c 0,-8.75 -7,-15.9375 -15.625,-15.9375 h -31.1875 c -8.5625,0 -15.625,7.1875 -15.625,15.9375 v 95.81251 h -62.375 l 93.5625,127.75 z"      id="path1"      sodipodi:nodetypes="ccsssscccc" />   <path      d="m 432.27837,365.63919 v -62.375 h 95.8125 c 8.75,0 15.9375,-7 15.9375,-15.625 v -31.1875 c 0,-8.5625 -7.1875,-15.625 -15.9375,-15.625 h -95.8125 v -62.375 l -127.75,93.5625 z"      id="path1-5"      sodipodi:nodetypes="ccsssscccc" />   <path      d="m 178.38918,432.27838 h 62.375 v 95.8125 c 0,8.75 7,15.9375 15.625,15.9375 h 31.1875 c 8.5625,0 15.625,-7.1875 15.625,-15.9375 v -95.8125 h 62.375 l -93.5625,-127.75 z"      id="path2"      sodipodi:nodetypes="ccsssscccc" />   <path      d="m 111.75,178.38919 v 62.375 H 15.9375 c -8.75,0 -15.9375,7 -15.9375,15.625 v 31.1875 c 0,8.5625 7.1875,15.625 15.9375,15.625 H 111.75 v 62.375 l 127.74999,-93.5625 z"      id="path3"      sodipodi:nodetypes="ccsssscccc" /> </svg>  | ||||
|  | @ -280,6 +280,16 @@ button.disabled:hover, .button.disabled:hover { | |||
|     color: unset; | ||||
| } | ||||
| 
 | ||||
| button.link { | ||||
|     border: none; | ||||
|     text-decoration: underline; | ||||
|     background-color: unset; | ||||
| } | ||||
| 
 | ||||
| button.link:hover { | ||||
|     color:unset; | ||||
| } | ||||
| 
 | ||||
| .interactive button.disabled svg path, .interactive .button.disabled svg path { | ||||
|     fill: var(--interactive-foreground) !important;; | ||||
| } | ||||
|  |  | |||
|  | @ -125,7 +125,21 @@ describe("PrepareTheme", () => { | |||
|                 en: "Test layer - please ignore", | ||||
|             }, | ||||
|             titleIcons: [], | ||||
|             pointRendering: [{ location: ["point"], label: "xyz" }], | ||||
|             pointRendering: [ | ||||
|                 { | ||||
|                     location: ["point"], | ||||
|                     label: "xyz", | ||||
|                     iconBadges: [ | ||||
|                         { | ||||
|                             if: "_favourite=yes", | ||||
|                             then: <any>{ | ||||
|                                 id: "circlewhiteheartred", | ||||
|                                 render: "circle:white;heart:red", | ||||
|                             }, | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|             ], | ||||
|             lineRendering: [{ width: 1 }], | ||||
|         } | ||||
|         const sharedLayers = constructSharedLayers() | ||||
|  | @ -165,7 +179,21 @@ describe("PrepareTheme", () => { | |||
|             id: "layer-example", | ||||
|             name: null, | ||||
|             minzoom: 18, | ||||
|             pointRendering: [{ location: ["point"], label: "xyz" }], | ||||
|             pointRendering: [ | ||||
|                 { | ||||
|                     location: ["point"], | ||||
|                     label: "xyz", | ||||
|                     iconBadges: [ | ||||
|                         { | ||||
|                             if: "_favourite=yes", | ||||
|                             then: { | ||||
|                                 id: "circlewhiteheartred", | ||||
|                                 render: "circle:white;heart:red", | ||||
|                             }, | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|             ], | ||||
|             lineRendering: [{ width: 1 }], | ||||
|             titleIcons: [], | ||||
|         }) | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue