forked from MapComplete/MapComplete
		
	Merge develop
This commit is contained in:
		
						commit
						ccf9c4b5f6
					
				
					 50 changed files with 1427 additions and 766 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								Docs/FilterFunctionality.gif
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								Docs/FilterFunctionality.gif
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 7.3 MiB | 
							
								
								
									
										
											BIN
										
									
								
								Docs/FilteredByDepth.gif
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								Docs/FilteredByDepth.gif
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.3 MiB | 
|  | @ -4,6 +4,11 @@ Making your own theme | |||
| In MapComplete, it is relatively simple to make your own theme. This guide will give some information on how you can do | ||||
| this. | ||||
| 
 | ||||
| Table of contents: | ||||
| 
 | ||||
| 1. [Requirements](#requirements) which lists what you should know before starting to create a theme | ||||
| 2. [What is a good theme?](#what-is-a-good-theme) | ||||
| 
 | ||||
| Requirements | ||||
| ------------ | ||||
| 
 | ||||
|  | @ -15,10 +20,213 @@ Before you start, you should have the following qualifications: | |||
| - You are in contact with your local OpenStreetMap community and do know some other members to discuss tagging and to | ||||
|   help testing | ||||
| 
 | ||||
| If you do not have those qualifications, reach out to the MapComplete community channel | ||||
| Please, do reach out to the MapComplete community channel | ||||
| on [Telegram](https://t.me/MapComplete) | ||||
| or [Matrix](https://app.element.io/#/room/#MapComplete:matrix.org). | ||||
| 
 | ||||
| 
 | ||||
| What is a good theme? | ||||
| --------------------- | ||||
| 
 | ||||
| A **theme** (or _layout_) is a single map showing one or more layers. | ||||
| The layers should work together in such a way that they serve a certain **audience**. | ||||
| You should be able to state in a few sentences whom would be the user of such a map, e.g.  | ||||
| 
 | ||||
| - a cyclist searching for bike repair | ||||
| - a thirsty person who needs water | ||||
| - someone who wants to know what their street is named after | ||||
| - ... | ||||
| 
 | ||||
| Some layers will be useful for many themes (e.g. _drinking water_, _toilets_, _shops_, ...). Due to this, MapComplete supports to reuse already existing official layers into a theme. | ||||
| 
 | ||||
| To include an already existing layer, simply type the layer id, e.g.: | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "id": "my-theme", | ||||
|   "title": "My theme for xyz", | ||||
|   "...": "...", | ||||
|   "layers": [ | ||||
|     { | ||||
|       "id": "my super-awesome new layer" | ||||
|     }, | ||||
|     "bench", | ||||
|     "shops", | ||||
|     "drinking_water", | ||||
|     "toilet" | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| Note that it is good practice to use an existing layer and to tweak it: | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "id": "my super awesome theme", | ||||
|   "...": "...", | ||||
|   "layers": [ | ||||
|     { | ||||
|       "builtin": [ | ||||
|         "toilet", | ||||
|         "bench" | ||||
|       ], | ||||
|       "override": { | ||||
|         "#": "Override is a section which copies all the keys here and 'pastes' them into the existing layers. For example, the 'minzoom' defined here will redifine the minzoom of 'toilet' and 'bench'", | ||||
|         "minzoom": 17, | ||||
|         "#0": "Appending to lists is supported to, e.g. to add an extra question", | ||||
|         "tagRenderings+": [ | ||||
|           { | ||||
|             "id": "new-question", | ||||
|             "question": "What is <some property>?", | ||||
|             "render": "{property}", | ||||
|             "...": "..." | ||||
|           } | ||||
|         ], | ||||
|         "#1": "Note that paths will be followed: the below block will add/change the icon of the layer, without changing the other properties of the first tag rendering. (Assumption: the first mapRendering is the icon rendering)", | ||||
|         "mapRendering": [ | ||||
|           { | ||||
|             "icon": { | ||||
|               "render": "new-icon.svg" | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| 
 | ||||
| ``` | ||||
| 
 | ||||
| ### What is a good layer? | ||||
| 
 | ||||
| A good layer is layer which shows **all** objects of a certain type, e.g. **all** shops, **all** restaurants, ... | ||||
| 
 | ||||
| It asks some relevant questions, with the most important and easiests questions first. | ||||
| 
 | ||||
| #### Don't: use a layer to filter | ||||
| 
 | ||||
| **Do not define a layer which filters on an attribute**, such as <del>all restaurants with a vegetarian diet</del>, <del>all shops which accept bitcoin</del>. | ||||
| This makes _addition_ of new points difficult as information might not yet be known. Conser the following situation: | ||||
| 
 | ||||
| 1. A theme defines a layer `vegetarian restaurants`, which matches `amenity=restaurant` & `diet:vegetarian=yes`. | ||||
| 2. An object exists in OSM with `amenity=restaurant`;`name=Fancy Food`;`diet:vegan=yes`;`phone=...`;... | ||||
| 3. A contributor visits the themes and will notice that _Fancy Food_ is missing | ||||
| 4. The contributor will add _Fancy Food_ | ||||
| 5. There are now **two** Fancy Food objects in OSM. | ||||
| 
 | ||||
| Instead, use the filter functionality instead. This can be used from the layer to hide some objects based on their properties. | ||||
| When the contributor wants to add a new point, they'll be notified that some features might be hidden and only be allowed to add a new point when the points are shown. | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "id": "my awesome layer", | ||||
|   "tagRenderings": "... some relevant attributes and questions ...", | ||||
|   "mapRenderings": "... display on the map ... ", | ||||
|   "filter": [ | ||||
|     { | ||||
|       "id": "vegetarian", | ||||
|       "options": [ | ||||
|         { | ||||
|           "question": { | ||||
|             "en": "Has a vegetarian menu" | ||||
|           }, | ||||
|           "osmTags": { | ||||
|             "or": [ | ||||
|               "diet:vegetarian=yes", | ||||
|               "diet:vegetarian=only", | ||||
|               "diet:vegan=yes", | ||||
|               "diet:vegan=only" | ||||
|             ] | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| If you want to show only features of a certain type, there is a workaround. | ||||
| For example, the [fritures map](https://mapcomplete.osm.be/fritures.html?z=1&welcome-control-toggle=true) will show french fries shop, aka every `amenity~fast_food|restaurant` with `cuisine=friture`. | ||||
| However, quite a few fritures are already mapped as fastfood but have their `cuisine`-tag missing (or misspelled). | ||||
| 
 | ||||
| There is a workaround for this: show **all** food related items at zoomlevel 19 (or higher), and only show the fritures when zoomed out. | ||||
| 
 | ||||
| In order to achieve this: | ||||
| 
 | ||||
| 1. The layer 'food' is defined in a separate file and reused | ||||
| 2. The layer food is imported in the theme 'fritures'. With 'override', some properties are changed, namely: | ||||
|    - The `osmTags` are overwritten: `cuisine=friture` is now required | ||||
|    - The presets are overwritten and _disabled_  | ||||
|    - The _id_ and _name_ of the layer are changed | ||||
| 3. The layer `food` is imported _a second time_, but now the minzoom is set to `19`. This will show _all_ restaurants. | ||||
| 
 | ||||
| In case of a friture which is already added as fastfood, they'll see the fastfood popup instead of adding a new item: | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "layers": [ | ||||
|     { | ||||
|       "builtin": "food", | ||||
|       "override": { | ||||
|         "id": "friture", | ||||
|         "name": { | ||||
|           "en": "Fries shop" | ||||
|         }, | ||||
|         "=presets": [], | ||||
|         "source": { | ||||
|           "=osmTags": { | ||||
|             "and": [ | ||||
|               "cuisine=friture", | ||||
|               { | ||||
|                 "or": [ | ||||
|                   "amenity=fast_food", | ||||
|                   "amenity=restaurant" | ||||
|                 ] | ||||
|               } | ||||
|             ] | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "builtin": "food", | ||||
|       "override": { | ||||
|         "minzoom": 19, | ||||
|         "filter": null, | ||||
|         "name": null | ||||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| 
 | ||||
| ### What is a good question and tagrendering? | ||||
| 
 | ||||
| A tagrendering maps an attribute onto a piece of human readable text.  | ||||
| These should be **full sentences**, e.g. `"render": "The maximum speed of this road is {maxspeed} km/h"` | ||||
| 
 | ||||
| In some cases, there might be some predifined special values as mappings, such as `"mappings": [{"if": "maxspeed=30", "then": "The maxspeed is 30km/h"}]` | ||||
| 
 | ||||
| The question then follows logically: `{"question": "What is the maximum allowed speed for this road, in km/h?"}` | ||||
| At last, you'll also want to say that the user can type an answer too and that it has to be a number: `"freeform":{"key": "maxspeed","type":"pnat"}`. | ||||
| 
 | ||||
| The entire tagRendering will thus be: | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "question": "What is the maximum allowed speed for this road, in km/h?", | ||||
|   "render": "The maximum speed of this road is {maxspeed} km/h", | ||||
|   "freeform":{"key": "maxspeed","type":"pnat"}, | ||||
|   "mappings": [{"if": "maxspeed=30", "then": "The maxspeed is 30km/h"}] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| 
 | ||||
| The template | ||||
| ------------ | ||||
| 
 | ||||
|  | @ -229,16 +437,10 @@ disregarding other properties. | |||
| 
 | ||||
| One should not make one layer for benches with a backrest and one layer for benches without. This is confusing for users | ||||
| and poses problems: what if the backrest status is unknown? What if it is some weird value? Also, it isn't possible to ' | ||||
| move' an attribute to another layer. | ||||
| move' a feature to another layer. | ||||
| 
 | ||||
| Instead, make one layer for one kind of object and change the icon based on attributes. | ||||
| 
 | ||||
| ### Using layers as filters | ||||
| 
 | ||||
| Using layers as filters - this doesn't work! | ||||
| 
 | ||||
| Use the `filter`-functionality instead. | ||||
| 
 | ||||
| ### Not reading the theme JSON specs | ||||
| 
 | ||||
| There are a few advanced features to do fancy stuff available, which are documented only in the spec above - for | ||||
|  |  | |||
|  | @ -22,9 +22,8 @@ | |||
|   "#": "For more options and configuration, see the documentation in LayoutConfig.json", | ||||
|   "#layers": "The list of layers is where most of the content will be. Either reuse an already existing layer by simply calling it's ID or define a whole new layer. An overview of builtin layers is at https://github.com/pietervdvn/MapComplete/blob/develop/Docs/BuiltinLayers.md#normal-layers", | ||||
|   "layers": [ | ||||
|     "bench", | ||||
|     { | ||||
|       "id": "a singular nound describing the feature, in english", | ||||
|       "id": "a singular noun describing the feature, in english", | ||||
|       "source": { | ||||
|         "osmTags": { | ||||
|           "#": "For a description on which tags are possible, see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Tags_format.md", | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; | |||
| import {QueryParameters} from "../Web/QueryParameters"; | ||||
| import FeatureSource from "../FeatureSource/FeatureSource"; | ||||
| import {BBox} from "../BBox"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| 
 | ||||
| export interface GeoLocationPointProperties { | ||||
|     id: "gps", | ||||
|  | @ -25,13 +26,11 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
| 
 | ||||
|     /** | ||||
|      * Wether or not the geolocation is active, aka the user requested the current location | ||||
|      * @private | ||||
|      */ | ||||
|     private readonly _isActive: UIEventSource<boolean>; | ||||
| 
 | ||||
|     /** | ||||
|      * Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user | ||||
|      * @private | ||||
|      */ | ||||
|     private readonly _isLocked: UIEventSource<boolean>; | ||||
| 
 | ||||
|  | @ -54,9 +53,8 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
| 
 | ||||
|     /** | ||||
|      * The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs | ||||
|      * @private | ||||
|      */ | ||||
|     private _lastUserRequest: Date; | ||||
|     private _lastUserRequest: UIEventSource<Date>; | ||||
| 
 | ||||
|     /** | ||||
|      * A small flag on localstorage. If the user previously granted the geolocation, it will be set. | ||||
|  | @ -80,6 +78,8 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|     ) { | ||||
|         const currentGPSLocation = new UIEventSource<Coordinates>(undefined, "GPS-coordinate") | ||||
|         const leafletMap = state.leafletMap | ||||
|         const initedAt = new Date() | ||||
|         let autozoomDone = false; | ||||
|         const hasLocation = currentGPSLocation.map( | ||||
|             (location) => location !== undefined | ||||
|         ); | ||||
|  | @ -97,13 +97,28 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|             const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000 | ||||
|             return timeDiff <= 3 | ||||
|         }) | ||||
| 
 | ||||
|         const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon") | ||||
|         const willFocus = lastClick.map(lastUserRequest => { | ||||
|             const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000 | ||||
|             if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) { | ||||
|                 return true | ||||
|             } | ||||
|             if (lastUserRequest === undefined) { | ||||
|                 return false; | ||||
|             } | ||||
|             const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000 | ||||
|             return timeDiff <= Constants.zoomToLocationTimeout | ||||
|         }) | ||||
| 
 | ||||
|         lastClick.addCallbackAndRunD(_ => { | ||||
|             window.setTimeout(() => { | ||||
|                 if (lastClickWithinThreeSecs.data) { | ||||
|                 if (lastClickWithinThreeSecs.data || willFocus.data) { | ||||
|                     lastClick.ping() | ||||
|                 } | ||||
|             }, 500) | ||||
|         }) | ||||
| 
 | ||||
|         super( | ||||
|             hasLocation.map( | ||||
|                 (hasLocationData) => { | ||||
|  | @ -116,7 +131,8 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|                     } | ||||
|                     if (!hasLocationData) { | ||||
|                         // Position not yet found but we are active: we spin to indicate activity
 | ||||
|                         const icon = Svg.location_empty_svg() | ||||
|                         // If will focus is active too, we indicate this differently
 | ||||
|                         const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg() | ||||
|                         icon.SetStyle("animation: spin 4s linear infinite;") | ||||
|                         return icon; | ||||
|                     } | ||||
|  | @ -130,7 +146,7 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|                     // We have a location, so we show a dot in the center
 | ||||
|                     return Svg.location_svg(); | ||||
|                 }, | ||||
|                 [isActive, isLocked, permission, lastClickWithinThreeSecs] | ||||
|                 [isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus] | ||||
|             ) | ||||
|         ); | ||||
|         this.SetClass("mapcontrol") | ||||
|  | @ -142,6 +158,7 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|         this._leafletMap = leafletMap; | ||||
|         this._layoutToUse = state.layoutToUse; | ||||
|         this._hasLocation = hasLocation; | ||||
|         this._lastUserRequest = lastClick | ||||
|         const self = this; | ||||
| 
 | ||||
|         const currentPointer = this._isActive.map( | ||||
|  | @ -183,7 +200,6 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|             self.init(true, true); | ||||
|         }); | ||||
| 
 | ||||
|         const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon") | ||||
| 
 | ||||
|         const doAutoZoomToLocation = !latLonGiven && state.featureSwitchGeolocation.data && state.selectedElement.data !== undefined | ||||
|         this.init(false, doAutoZoomToLocation); | ||||
|  | @ -221,8 +237,12 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|             self.currentLocation?.features?.setData([{feature, freshness: new Date()}]) | ||||
| 
 | ||||
|             const timeSinceRequest = | ||||
|                 (new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000; | ||||
|             if (timeSinceRequest < 30) { | ||||
|                 (new Date().getTime() - (self._lastUserRequest.data?.getTime() ?? 0)) / 1000; | ||||
| 
 | ||||
|             if (willFocus.data) { | ||||
|                 console.log("Zooming to user location: willFocus is set") | ||||
|                 willFocus.setData(false) | ||||
|                 autozoomDone = true; | ||||
|                 self.MoveToCurrentLocation(16); | ||||
|             } else if (self._isLocked.data) { | ||||
|                 self.MoveToCurrentLocation(); | ||||
|  | @ -239,8 +259,8 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|             self.MoveToCurrentLocation(16); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         if(typeof navigator === "undefined"){ | ||||
| 
 | ||||
|         if (typeof navigator === "undefined") { | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|  | @ -271,7 +291,7 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
| 
 | ||||
|     /** | ||||
|      * Moves to the currently loaded location. | ||||
|      *  | ||||
|      * | ||||
|      * // Should move to any location
 | ||||
|      * let resultingLocation = undefined | ||||
|      * let resultingzoom = 1 | ||||
|  | @ -321,7 +341,7 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|      */ | ||||
|     private MoveToCurrentLocation(targetZoom?: number) { | ||||
|         const location = this._currentGPSLocation.data; | ||||
|         this._lastUserRequest = undefined; | ||||
|         this._lastUserRequest.setData(undefined); | ||||
| 
 | ||||
|         if ( | ||||
|             this._currentGPSLocation.data.latitude === 0 && | ||||
|  | @ -341,14 +361,9 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|             } | ||||
|         } | ||||
|         if (!inRange) { | ||||
|             console.log( | ||||
|                 "Not zooming to GPS location: out of bounds", | ||||
|                 b, | ||||
|                 location | ||||
|             ); | ||||
|             console.log("Not zooming to GPS location: out of bounds", b, location); | ||||
|         } else { | ||||
|             const currentZoom = this._leafletMap.data.getZoom() | ||||
| 
 | ||||
|             this._leafletMap.data.setView([location.latitude, location.longitude], Math.max(targetZoom ?? 0, currentZoom)); | ||||
|         } | ||||
|     } | ||||
|  | @ -356,7 +371,7 @@ export default class GeoLocationHandler extends VariableUiElement { | |||
|     private StartGeolocating(zoomToGPS = true) { | ||||
|         const self = this; | ||||
| 
 | ||||
|         this._lastUserRequest = zoomToGPS ? new Date() : new Date(0); | ||||
|         this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0)) | ||||
|         if (self._permission.data === "denied") { | ||||
|             self._previousLocationGrant.setData(""); | ||||
|             self._isActive.setData(false) | ||||
|  |  | |||
|  | @ -76,9 +76,9 @@ export class OsmPreferences { | |||
| 
 | ||||
| 
 | ||||
|         function updateData(l: number) { | ||||
|             if (l === undefined) { | ||||
|                 source.setData(undefined); | ||||
|                 return; | ||||
|             if(Object.keys(self.preferences.data).length === 0){ | ||||
|                 // The preferences are still empty - they are not yet updated, so we delay updating for now 
 | ||||
|                 return | ||||
|             } | ||||
|             const prefsCount = Number(l); | ||||
|             if (prefsCount > 100) { | ||||
|  | @ -86,7 +86,11 @@ export class OsmPreferences { | |||
|             } | ||||
|             let str = ""; | ||||
|             for (let i = 0; i < prefsCount; i++) { | ||||
|                 str += self.GetPreference(allStartWith + "-" + i, "").data; | ||||
|                 const key = allStartWith + "-" + i | ||||
|                 if(self.preferences.data[key] === undefined){ | ||||
|                     console.warn("Detected a broken combined preference:", key, "is undefined", self.preferences) | ||||
|                 } | ||||
|                 str += self.preferences.data[key] ?? ""; | ||||
|             } | ||||
| 
 | ||||
|             source.setData(str); | ||||
|  | @ -95,7 +99,9 @@ export class OsmPreferences { | |||
|         length.addCallback(l => { | ||||
|             updateData(Number(l)); | ||||
|         }); | ||||
|         updateData(Number(length.data)); | ||||
|         this.preferences.addCallbackAndRun(_ => { | ||||
|             updateData(Number(length.data)); | ||||
|         }) | ||||
| 
 | ||||
|         return source; | ||||
|     } | ||||
|  | @ -127,7 +133,8 @@ export class OsmPreferences { | |||
|     public ClearPreferences() { | ||||
|         let isRunning = false; | ||||
|         const self = this; | ||||
|         this.preferences.addCallbackAndRun(prefs => { | ||||
|         this.preferences.addCallback(prefs => { | ||||
|             console.log("Cleaning preferences...") | ||||
|             if (Object.keys(prefs).length == 0) { | ||||
|                 return; | ||||
|             } | ||||
|  | @ -135,19 +142,17 @@ export class OsmPreferences { | |||
|                 return | ||||
|             } | ||||
|             isRunning = true | ||||
|             const prefixes = ["mapcomplete-installed-theme", "mapcomplete-installed-themes-", "mapcomplete-current-open-changeset", "mapcomplete-personal-theme-layer"] | ||||
|             const prefixes = ["mapcomplete-"] | ||||
|             for (const key in prefs) { | ||||
|                 for (const prefix of prefixes) { | ||||
|                     if (key.startsWith(prefix)) { | ||||
|                         console.log("Clearing ", key) | ||||
|                         self.GetPreference(key, "").setData("") | ||||
|                 const matches = prefixes.some(prefix => key.startsWith(prefix)) | ||||
|                 if (matches) { | ||||
|                     console.log("Clearing ", key) | ||||
|                     self.GetPreference(key, "").setData("") | ||||
| 
 | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             isRunning = false; | ||||
|             return true; | ||||
| 
 | ||||
|             return; | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|  | @ -173,7 +178,6 @@ export class OsmPreferences { | |||
|             // For differing values, the server overrides local changes
 | ||||
|             self.preferenceSources.forEach((preference, key) => { | ||||
|                 const osmValue = self.preferences.data[key] | ||||
|                     console.log("Sending value to osm:", key," osm has: ", osmValue, " local has: ", preference.data) | ||||
|                 if(osmValue === undefined && preference.data !== undefined){ | ||||
|                     // OSM doesn't know this value yet
 | ||||
|                     self.UploadPreference(key, preference.data) | ||||
|  |  | |||
|  | @ -43,29 +43,34 @@ export default class ElementsState extends FeatureSwitchState { | |||
| 
 | ||||
|     constructor(layoutToUse: LayoutConfig) { | ||||
|         super(layoutToUse); | ||||
|          | ||||
|          | ||||
|             function localStorageSynced(key: string, deflt: number, docs: string ): UIEventSource<number>{ | ||||
|                 const localStorage = LocalStorageSource.Get(key) | ||||
|                 const previousValue = localStorage.data | ||||
|                 const src = UIEventSource.asFloat( | ||||
|                     QueryParameters.GetQueryParameter( | ||||
|                         key, | ||||
|                         "" + deflt, | ||||
|                         docs | ||||
|                     ).syncWith(localStorage) | ||||
|                 ); | ||||
|                  | ||||
|                 if(src.data === deflt){ | ||||
|                     const prev = Number(previousValue) | ||||
|                     if(!isNaN(prev)){ | ||||
|                         src.setData(prev) | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 return src; | ||||
|             } | ||||
| 
 | ||||
|             // -- Location control initialization
 | ||||
|             const zoom = UIEventSource.asFloat( | ||||
|                 QueryParameters.GetQueryParameter( | ||||
|                     "z", | ||||
|                     "" + (layoutToUse?.startZoom ?? 1), | ||||
|                     "The initial/current zoom level" | ||||
|                 ).syncWith(LocalStorageSource.Get("zoom")) | ||||
|             ); | ||||
|             const lat = UIEventSource.asFloat( | ||||
|                 QueryParameters.GetQueryParameter( | ||||
|                     "lat", | ||||
|                     "" + (layoutToUse?.startLat ?? 0), | ||||
|                     "The initial/current latitude" | ||||
|                 ).syncWith(LocalStorageSource.Get("lat")) | ||||
|             ); | ||||
|             const lon = UIEventSource.asFloat( | ||||
|                 QueryParameters.GetQueryParameter( | ||||
|                     "lon", | ||||
|                     "" + (layoutToUse?.startLon ?? 0), | ||||
|                     "The initial/current longitude of the app" | ||||
|                 ).syncWith(LocalStorageSource.Get("lon")) | ||||
|             ); | ||||
|             const zoom = localStorageSynced("z",(layoutToUse?.startZoom ?? 1),"The initial/current zoom level") | ||||
|             const lat = localStorageSynced("lat",(layoutToUse?.startLat ?? 0),"The initial/current latitude") | ||||
|             const lon = localStorageSynced("lon",(layoutToUse?.startLon ?? 0),"The initial/current longitude of the app") | ||||
| 
 | ||||
| 
 | ||||
|             this.locationControl.setData({ | ||||
|                 zoom: Utils.asFloat(zoom.data), | ||||
|  | @ -73,7 +78,7 @@ export default class ElementsState extends FeatureSwitchState { | |||
|                 lon: Utils.asFloat(lon.data), | ||||
|             }) | ||||
|             this.locationControl.addCallback((latlonz) => { | ||||
|                 // Sync th location controls
 | ||||
|                 // Sync the location controls
 | ||||
|                 zoom.setData(latlonz.zoom); | ||||
|                 lat.setData(latlonz.lat); | ||||
|                 lon.setData(latlonz.lon); | ||||
|  |  | |||
|  | @ -8,6 +8,13 @@ export class And extends TagsFilter { | |||
|         super(); | ||||
|         this.and = and | ||||
|     } | ||||
|      | ||||
|     public static construct(and: TagsFilter[]): TagsFilter{ | ||||
|         if(and.length === 1){ | ||||
|             return and[0] | ||||
|         } | ||||
|         return new And(and) | ||||
|     } | ||||
| 
 | ||||
|     private static combine(filter: string, choices: string[]): string[] { | ||||
|         const values = []; | ||||
|  | @ -45,7 +52,7 @@ export class And extends TagsFilter { | |||
|      * import {RegexTag} from "./RegexTag"; | ||||
|      *  | ||||
|      * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) | ||||
|      * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]" ]
 | ||||
|      * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ]
 | ||||
|      */ | ||||
|     asOverpass(): string[] { | ||||
|         let allChoices: string[] = null; | ||||
|  | @ -87,17 +94,17 @@ export class And extends TagsFilter { | |||
|      * ]) | ||||
|      * const t1 = new And([new Tag("valves", "A")]) | ||||
|      * const t2 = new And([new Tag("valves", "B")]) | ||||
|      * t0.isEquivalent(t0) // => true
 | ||||
|      * t1.isEquivalent(t1) // => true
 | ||||
|      * t2.isEquivalent(t2) // => true
 | ||||
|      * t0.isEquivalent(t1) // => false
 | ||||
|      * t0.isEquivalent(t2) // => false
 | ||||
|      * t1.isEquivalent(t0) // => false
 | ||||
|      * t1.isEquivalent(t2) // => false
 | ||||
|      * t2.isEquivalent(t0) // => false
 | ||||
|      * t2.isEquivalent(t1) // => false
 | ||||
|      * t0.shadows(t0) // => true
 | ||||
|      * t1.shadows(t1) // => true
 | ||||
|      * t2.shadows(t2) // => true
 | ||||
|      * t0.shadows(t1) // => false
 | ||||
|      * t0.shadows(t2) // => false
 | ||||
|      * t1.shadows(t0) // => false
 | ||||
|      * t1.shadows(t2) // => false
 | ||||
|      * t2.shadows(t0) // => false
 | ||||
|      * t2.shadows(t1) // => false
 | ||||
|      */ | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         if (!(other instanceof And)) { | ||||
|             return false; | ||||
|         } | ||||
|  | @ -105,7 +112,7 @@ export class And extends TagsFilter { | |||
|         for (const selfTag of this.and) { | ||||
|             let matchFound = false; | ||||
|             for (const otherTag of other.and) { | ||||
|                 matchFound = selfTag.isEquivalent(otherTag); | ||||
|                 matchFound = selfTag.shadows(otherTag); | ||||
|                 if (matchFound) { | ||||
|                     break; | ||||
|                 } | ||||
|  | @ -118,7 +125,7 @@ export class And extends TagsFilter { | |||
|         for (const otherTag of other.and) { | ||||
|             let matchFound = false; | ||||
|             for (const selfTag of this.and) { | ||||
|                 matchFound = selfTag.isEquivalent(otherTag); | ||||
|                 matchFound = selfTag.shadows(otherTag); | ||||
|                 if (matchFound) { | ||||
|                     break; | ||||
|                 } | ||||
|  | @ -148,23 +155,90 @@ export class And extends TagsFilter { | |||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * IN some contexts, some expressions can be considered true, e.g. | ||||
|      * (X=Y | (A=B & X=Y)) | ||||
|      *        ^---------^ | ||||
|      * When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise. | ||||
|      * This means that the entire 'AND' is considered FALSE | ||||
|      *  | ||||
|      * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value")
 | ||||
|      * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false
 | ||||
|      * new And([ new RegexTag("key",/^..*$/) ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value")
 | ||||
|      * new And([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true
 | ||||
|      *  | ||||
|      * // should remove 'club~*' if we know that 'club=climbing'
 | ||||
|      * const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) | ||||
|      * expr.removePhraseConsideredKnown(new Tag("club","climbing"), true) // => new Tag("sport","climbing")
 | ||||
|      *  | ||||
|      * const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) | ||||
|      * expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr
 | ||||
|      */ | ||||
|     removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { | ||||
|         const newAnds: TagsFilter[] = [] | ||||
|         for (const tag of this.and) { | ||||
|             if(tag instanceof And){ | ||||
|                 throw "Optimize expressions before using removePhraseConsideredKnown" | ||||
|             } | ||||
|             if(tag instanceof Or){ | ||||
|                 const r = tag.removePhraseConsideredKnown(knownExpression, value) | ||||
|                 if(r === true){ | ||||
|                     continue | ||||
|                 } | ||||
|                 if(r === false){ | ||||
|                     return false; | ||||
|                 } | ||||
|                 newAnds.push(r) | ||||
|                 continue | ||||
|             } | ||||
|             if(value && knownExpression.shadows(tag)){ | ||||
|                 /** | ||||
|                  * At this point, we do know that 'knownExpression' is true in every case | ||||
|                  * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, | ||||
|                  * we can be sure that 'tag' is true as well. | ||||
|                  *  | ||||
|                  * "True" is the neutral element in an AND, so we can skip the tag | ||||
|                  */ | ||||
|                 continue | ||||
|             } | ||||
|             if(!value && tag.shadows(knownExpression)){ | ||||
| 
 | ||||
|                 /** | ||||
|                  * We know that knownExpression is unmet. | ||||
|                  * if the tag shadows 'knownExpression' (which is the case when control flows gets here), | ||||
|                  * then tag CANNOT be met too, as known expression is not met. | ||||
|                  *  | ||||
|                  * This implies that 'tag' must be false too! | ||||
|                  */ | ||||
| 
 | ||||
|                 // false is the element which absorbs all
 | ||||
|                 return false | ||||
|             } | ||||
|              | ||||
|             newAnds.push(tag) | ||||
|         } | ||||
|         if(newAnds.length === 0){ | ||||
|             return true | ||||
|         } | ||||
|         return And.construct(newAnds) | ||||
|     } | ||||
| 
 | ||||
|     optimize(): TagsFilter | boolean { | ||||
|         if(this.and.length === 0){ | ||||
|             return true | ||||
|         } | ||||
|         const optimized = this.and.map(t => t.optimize()) | ||||
|         const optimizedRaw = this.and.map(t => t.optimize()) | ||||
|             .filter(t => t !== true /* true is the neutral element in an AND, we drop them*/ ) | ||||
|         if(optimizedRaw.some(t => t === false)){ | ||||
|             // We have an AND with a contained false: this is always 'false'
 | ||||
|             return false; | ||||
|         } | ||||
|         const optimized = <TagsFilter[]> optimizedRaw; | ||||
|          | ||||
|         const newAnds : TagsFilter[] = [] | ||||
|          | ||||
|         let containedOrs : Or[] = [] | ||||
|         for (const tf of optimized) { | ||||
|             if(tf === false){ | ||||
|                 return false | ||||
|             } | ||||
|             if(tf === true){ | ||||
|                 continue | ||||
|             } | ||||
|              | ||||
|             if(tf instanceof And){ | ||||
|                 newAnds.push(...tf.and) | ||||
|             }else if(tf instanceof Or){ | ||||
|  | @ -173,27 +247,56 @@ export class And extends TagsFilter { | |||
|                 newAnds.push(tf) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         containedOrs = containedOrs.filter(ca => { | ||||
|             for (const element of ca.or) { | ||||
|                 if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ | ||||
|                     // At least one part of the 'OR' is matched by the outer or, so this means that this OR isn't needed at all
 | ||||
|                     // XY & (XY | AB) === XY
 | ||||
|                     return false | ||||
|          | ||||
|         { | ||||
|             let dirty = false; | ||||
|             do { | ||||
|                 const cleanedContainedOrs : Or[] = [] | ||||
|                 outer: for (let containedOr of containedOrs) { | ||||
|                     for (const known of newAnds) { | ||||
|                         // input for optimazation: (K=V & (X=Y | K=V))
 | ||||
|                         // containedOr: (X=Y | K=V)
 | ||||
|                         // newAnds (and thus known): (K=V) --> true
 | ||||
|                         const cleaned = containedOr.removePhraseConsideredKnown(known, true) | ||||
|                         if (cleaned === true) { | ||||
|                             // The neutral element within an AND
 | ||||
|                             continue outer // skip addition too
 | ||||
|                         } | ||||
|                         if (cleaned === false) { | ||||
|                             // zero element
 | ||||
|                             return false | ||||
|                         } | ||||
|                         if (cleaned instanceof Or) { | ||||
|                             containedOr = cleaned | ||||
|                             continue | ||||
|                         } | ||||
|                         // the 'or' dissolved into a normal tag -> it has to be added to the newAnds
 | ||||
|                         newAnds.push(cleaned) | ||||
|                         dirty = true; // rerun this algo later on
 | ||||
|                         continue outer; | ||||
|                     } | ||||
|                     cleanedContainedOrs.push(containedOr) | ||||
|                 } | ||||
|             } | ||||
|             return true; | ||||
|                 containedOrs = cleanedContainedOrs | ||||
|             } while(dirty) | ||||
|         } | ||||
|          | ||||
|          | ||||
|         containedOrs = containedOrs.filter(ca => { | ||||
|             const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or) | ||||
|             // If 'isShadowed', then at least one part of the 'OR' is matched by the outer and, so this means that this OR isn't needed at all
 | ||||
|             // XY & (XY | AB) === XY
 | ||||
|             return !isShadowed; | ||||
|         }) | ||||
| 
 | ||||
|         // Extract common keys from the OR
 | ||||
|         if(containedOrs.length === 1){ | ||||
|             newAnds.push(containedOrs[0]) | ||||
|         } | ||||
|         if(containedOrs.length > 1){ | ||||
|         }else if(containedOrs.length > 1){ | ||||
|             let commonValues : TagsFilter [] = containedOrs[0].or | ||||
|             for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++){ | ||||
|                 const containedOr = containedOrs[i]; | ||||
|                 commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.isEquivalent(cv))) | ||||
|                 commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv))) | ||||
|             } | ||||
|             if(commonValues.length === 0){ | ||||
|                 newAnds.push(...containedOrs) | ||||
|  | @ -201,19 +304,11 @@ export class And extends TagsFilter { | |||
|                 const newOrs: TagsFilter[] = [] | ||||
|                 for (const containedOr of containedOrs) { | ||||
|                     const elements = containedOr.or | ||||
|                         .filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) | ||||
|                     const or = new Or(elements).optimize() | ||||
|                     if(or === true){ | ||||
|                         // neutral element
 | ||||
|                         continue | ||||
|                     } | ||||
|                     if(or === false){ | ||||
|                         return false | ||||
|                     } | ||||
|                     newOrs.push(or) | ||||
|                         .filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) | ||||
|                     newOrs.push(Or.construct(elements)) | ||||
|                 } | ||||
|                  | ||||
|                 commonValues.push(new And(newOrs)) | ||||
|                 commonValues.push(And.construct(newOrs)) | ||||
|                 const result = new Or(commonValues).optimize() | ||||
|                 if(result === false){ | ||||
|                     return false | ||||
|  | @ -224,16 +319,22 @@ export class And extends TagsFilter { | |||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if(newAnds.length === 1){ | ||||
|             return newAnds[0] | ||||
|         if(newAnds.length === 0){ | ||||
|             return true | ||||
|         } | ||||
| 
 | ||||
|         if(TagUtils.ContainsOppositeTags(newAnds)){ | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         TagUtils.sortFilters(newAnds, true) | ||||
|          | ||||
|         return new And(newAnds) | ||||
|         return And.construct(newAnds) | ||||
|     } | ||||
|      | ||||
|     isNegative(): boolean { | ||||
|         return !this.and.some(t => !t.isNegative()); | ||||
|     } | ||||
|      | ||||
|      | ||||
| } | ||||
|  | @ -23,7 +23,7 @@ export default class ComparingTag implements TagsFilter { | |||
|         throw "A comparable tag can not be used as overpass filter" | ||||
|     } | ||||
| 
 | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         return other === this; | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										156
									
								
								Logic/Tags/Or.ts
									
										
									
									
									
								
							
							
						
						
									
										156
									
								
								Logic/Tags/Or.ts
									
										
									
									
									
								
							|  | @ -11,6 +11,14 @@ export class Or extends TagsFilter { | |||
|         this.or = or; | ||||
|     } | ||||
| 
 | ||||
|     public static construct(or: TagsFilter[]): TagsFilter{ | ||||
|         if(or.length === 1){ | ||||
|             return or[0] | ||||
|         } | ||||
|         return new Or(or) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     matchesProperties(properties: any): boolean { | ||||
|         for (const tagsFilter of this.or) { | ||||
|             if (tagsFilter.matchesProperties(properties)) { | ||||
|  | @ -28,7 +36,7 @@ export class Or extends TagsFilter { | |||
|      *  | ||||
|      * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) | ||||
|      * const or = new Or([and, new Tag("leisure", "nature_reserve"]) | ||||
|      * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]", "[\"leisure\"=\"nature_reserve\"]" ]
 | ||||
|      * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]", "[\"leisure\"=\"nature_reserve\"]" ]
 | ||||
|      *  | ||||
|      * // should fuse nested ors into a single list
 | ||||
|      * const or = new Or([new Tag("key","value"), new Or([new Tag("key1","value1"), new Tag("key2","value2")])]) | ||||
|  | @ -51,14 +59,14 @@ export class Or extends TagsFilter { | |||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         if (other instanceof Or) { | ||||
| 
 | ||||
|             for (const selfTag of this.or) { | ||||
|                 let matchFound = false; | ||||
|                 for (let i = 0; i < other.or.length && !matchFound; i++) { | ||||
|                     let otherTag = other.or[i]; | ||||
|                     matchFound = selfTag.isEquivalent(otherTag); | ||||
|                     matchFound = selfTag.shadows(otherTag); | ||||
|                 } | ||||
|                 if (!matchFound) { | ||||
|                     return false; | ||||
|  | @ -85,45 +93,127 @@ export class Or extends TagsFilter { | |||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * IN some contexts, some expressions can be considered true, e.g. | ||||
|      * (X=Y & (A=B | X=Y)) | ||||
|      *        ^---------^ | ||||
|      * When the evaluation hits (A=B | X=Y), we know _for sure_ that X=Y _does match, as it would have failed the first clause otherwise. | ||||
|      * This means we can safely ignore this in the OR | ||||
|      *  | ||||
|      * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // =>true
 | ||||
|      * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => new Tag("other_key","value")
 | ||||
|      * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true
 | ||||
|      * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false
 | ||||
|      * new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]).removePhraseConsideredKnown(new Tag("foo","bar"), false) // => new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")])
 | ||||
|      */ | ||||
|     removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { | ||||
|         const newOrs: TagsFilter[] = [] | ||||
|         for (const tag of this.or) { | ||||
|             if(tag instanceof Or){ | ||||
|                 throw "Optimize expressions before using removePhraseConsideredKnown" | ||||
|             } | ||||
|             if(tag instanceof And){ | ||||
|                 const r = tag.removePhraseConsideredKnown(knownExpression, value) | ||||
|                 if(r === false){ | ||||
|                     continue | ||||
|                 } | ||||
|                 if(r === true){ | ||||
|                     return true; | ||||
|                 } | ||||
|                 newOrs.push(r) | ||||
|                 continue | ||||
|             } | ||||
|             if(value && knownExpression.shadows(tag)){ | ||||
|                 /** | ||||
|                  * At this point, we do know that 'knownExpression' is true in every case | ||||
|                  * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, | ||||
|                  * we can be sure that 'tag' is true as well. | ||||
|                  * | ||||
|                  * "True" is the absorbing element in an OR, so we can return true | ||||
|                  */ | ||||
|                 return true; | ||||
|             } | ||||
|             if(!value && tag.shadows(knownExpression)){ | ||||
| 
 | ||||
|                 /** | ||||
|                  * We know that knownExpression is unmet. | ||||
|                  * if the tag shadows 'knownExpression' (which is the case when control flows gets here), | ||||
|                  * then tag CANNOT be met too, as known expression is not met. | ||||
|                  * | ||||
|                  * This implies that 'tag' must be false too! | ||||
|                  * false is the neutral element in an OR | ||||
|                  */ | ||||
|                continue | ||||
|             } | ||||
|             newOrs.push(tag) | ||||
|         } | ||||
|         if(newOrs.length === 0){ | ||||
|             return false | ||||
|         } | ||||
|         return Or.construct(newOrs) | ||||
|     } | ||||
|      | ||||
|     optimize(): TagsFilter | boolean { | ||||
|          | ||||
|         if(this.or.length === 0){ | ||||
|             return false; | ||||
|         } | ||||
|          | ||||
|         const optimized = this.or.map(t => t.optimize()) | ||||
|         const optimizedRaw = this.or.map(t => t.optimize()) | ||||
|             .filter(t => t !== false /* false is the neutral element in an OR, we drop them*/ ) | ||||
|         if(optimizedRaw.some(t => t === true)){ | ||||
|             // We have an OR with a contained true: this is always 'true'
 | ||||
|             return true; | ||||
|         } | ||||
|         const optimized = <TagsFilter[]> optimizedRaw; | ||||
|          | ||||
| 
 | ||||
|         const newOrs : TagsFilter[] = [] | ||||
| 
 | ||||
|         let containedAnds : And[] = [] | ||||
|         for (const tf of optimized) { | ||||
|             if(tf === true){ | ||||
|                 return true | ||||
|             } | ||||
|             if(tf === false){ | ||||
|                 continue | ||||
|             } | ||||
| 
 | ||||
|             if(tf instanceof Or){ | ||||
|                 // expand all the nested ors...
 | ||||
|                 newOrs.push(...tf.or) | ||||
|             }else if(tf instanceof And){ | ||||
|                 // partition of all the ands
 | ||||
|                 containedAnds.push(tf) | ||||
|             } else { | ||||
|                 newOrs.push(tf) | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         containedAnds = containedAnds.filter(ca => { | ||||
|             for (const element of ca.and) { | ||||
|                 if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ | ||||
|                     // At least one part of the 'AND' is matched by the outer or, so this means that this OR isn't needed at all
 | ||||
|                     // XY | (XY & AB) === XY
 | ||||
|                     return false | ||||
|         { | ||||
|             let dirty = false; | ||||
|             do { | ||||
|                 const cleanedContainedANds : And[] = [] | ||||
|                 outer: for (let containedAnd of containedAnds) { | ||||
|                     for (const known of newOrs) { | ||||
|                         // input for optimazation: (K=V | (X=Y & K=V))
 | ||||
|                         // containedAnd: (X=Y & K=V)
 | ||||
|                         // newOrs (and thus known): (K=V) --> false
 | ||||
|                         const cleaned = containedAnd.removePhraseConsideredKnown(known, false) | ||||
|                         if (cleaned === false) { | ||||
|                             // The neutral element within an OR
 | ||||
|                             continue outer // skip addition too
 | ||||
|                         } | ||||
|                         if (cleaned === true) { | ||||
|                             // zero element
 | ||||
|                             return true | ||||
|                         } | ||||
|                         if (cleaned instanceof And) { | ||||
|                             containedAnd = cleaned | ||||
|                             continue // clean up with the other known values
 | ||||
|                         } | ||||
|                         // the 'and' dissolved into a normal tag -> it has to be added to the newOrs
 | ||||
|                         newOrs.push(cleaned) | ||||
|                         dirty = true; // rerun this algo later on
 | ||||
|                         continue outer; | ||||
|                     } | ||||
|                     cleanedContainedANds.push(containedAnd) | ||||
|                 } | ||||
|             } | ||||
|             return true; | ||||
|         }) | ||||
| 
 | ||||
|                 containedAnds = cleanedContainedANds | ||||
|             } while(dirty) | ||||
|         } | ||||
|         // Extract common keys from the ANDS
 | ||||
|         if(containedAnds.length === 1){ | ||||
|             newOrs.push(containedAnds[0]) | ||||
|  | @ -131,40 +221,46 @@ export class Or extends TagsFilter { | |||
|             let commonValues : TagsFilter [] = containedAnds[0].and | ||||
|             for (let i = 1; i < containedAnds.length && commonValues.length > 0; i++){ | ||||
|                 const containedAnd = containedAnds[i]; | ||||
|                 commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.isEquivalent(cv))) | ||||
|                 commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.shadows(cv))) | ||||
|             } | ||||
|             if(commonValues.length === 0){ | ||||
|                 newOrs.push(...containedAnds) | ||||
|             }else{ | ||||
|                 const newAnds: TagsFilter[] = [] | ||||
|                 for (const containedAnd of containedAnds) { | ||||
|                     const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) | ||||
|                     newAnds.push(new And(elements)) | ||||
|                     const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) | ||||
|                     newAnds.push(And.construct(elements)) | ||||
|                 } | ||||
| 
 | ||||
|                 commonValues.push(new Or(newAnds)) | ||||
|                 commonValues.push(Or.construct(newAnds)) | ||||
|                 const result = new And(commonValues).optimize() | ||||
|                 if(result === true){ | ||||
|                     return true | ||||
|                 }else if(result === false){ | ||||
|                     // neutral element: skip
 | ||||
|                 }else{ | ||||
|                     newOrs.push(new And(commonValues)) | ||||
|                     newOrs.push(And.construct(commonValues)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if(newOrs.length === 1){ | ||||
|             return newOrs[0] | ||||
|         if(newOrs.length === 0){ | ||||
|             return false | ||||
|         } | ||||
| 
 | ||||
|         if(TagUtils.ContainsOppositeTags(newOrs)){ | ||||
|             return true | ||||
|         } | ||||
|          | ||||
|         TagUtils.sortFilters(newOrs, false) | ||||
| 
 | ||||
|         return new Or(newOrs) | ||||
|         return Or.construct(newOrs) | ||||
|     } | ||||
|      | ||||
|     isNegative(): boolean { | ||||
|         return this.or.some(t => t.isNegative()); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,13 +10,6 @@ export class RegexTag extends TagsFilter { | |||
|     constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) { | ||||
|         super(); | ||||
|         this.key = key; | ||||
|         if (typeof value === "string") { | ||||
|             if (value.indexOf("^") < 0 && value.indexOf("$") < 0) { | ||||
|                 value = "^" + value + "$" | ||||
|             } | ||||
|             value = new RegExp(value) | ||||
|         } | ||||
| 
 | ||||
|         this.value = value; | ||||
|         this.invert = invert; | ||||
|         this.matchesEmpty = RegexTag.doesMatch("", this.value); | ||||
|  | @ -79,14 +72,14 @@ export class RegexTag extends TagsFilter { | |||
|     /**  | ||||
|      * Checks if this tag matches the given properties | ||||
|      *  | ||||
|      * const isNotEmpty = new RegexTag("key","^$", true); | ||||
|      * const isNotEmpty = new RegexTag("key",/^$/, true); | ||||
|      * isNotEmpty.matchesProperties({"key": "value"}) // => true
 | ||||
|      * isNotEmpty.matchesProperties({"key": "other_value"}) // => true
 | ||||
|      * isNotEmpty.matchesProperties({"key": ""}) // => false
 | ||||
|      * isNotEmpty.matchesProperties({"other_key": ""}) // => false
 | ||||
|      * isNotEmpty.matchesProperties({"other_key": "value"}) // => false
 | ||||
|      *  | ||||
|      * const isNotEmpty = new RegexTag("key","^..*$", true); | ||||
|      * const isNotEmpty = new RegexTag("key",/^..*$/, true); | ||||
|      * isNotEmpty.matchesProperties({"key": "value"}) // => false
 | ||||
|      * isNotEmpty.matchesProperties({"key": "other_value"}) // => false
 | ||||
|      * isNotEmpty.matchesProperties({"key": ""}) // => true
 | ||||
|  | @ -121,6 +114,9 @@ export class RegexTag extends TagsFilter { | |||
|      * importMatch.matchesProperties({"tags": "amenity=public_bookcase"}) // =>true
 | ||||
|      * importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}) // =>true
 | ||||
|      * importMatch.matchesProperties({"tags": "amenity=bench"}) // =>false
 | ||||
|      *  | ||||
|      * new RegexTag("key","value").matchesProperties({"otherkey":"value"}) // => false
 | ||||
|      * new RegexTag("key","value",true).matchesProperties({"otherkey":"something"}) // => true
 | ||||
|      */ | ||||
|     matchesProperties(tags: any): boolean { | ||||
|         if (typeof this.key === "string") { | ||||
|  | @ -147,17 +143,87 @@ export class RegexTag extends TagsFilter { | |||
| 
 | ||||
|     asHumanString() { | ||||
|         if (typeof this.key === "string") { | ||||
|             return `${this.key}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`; | ||||
|             const oper = typeof this.value === "string" ? "=" : "~" | ||||
|             return `${this.key}${this.invert ? "!" : ""}${oper}${RegexTag.source(this.value)}`; | ||||
|         } | ||||
|         return `${this.key.source}${this.invert ? "!" : ""}~~${RegexTag.source(this.value)}` | ||||
|     } | ||||
| 
 | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|     /** | ||||
|      *  | ||||
|      * new RegexTag("key","value").shadows(new Tag("key","value")) // => true
 | ||||
|      * new RegexTag("key",/value/).shadows(new RegexTag("key","value")) // => true
 | ||||
|      * new RegexTag("key",/^..*$/).shadows(new Tag("key","value")) // => false
 | ||||
|      * new RegexTag("key",/^..*$/).shadows(new Tag("other_key","value")) // => false
 | ||||
|      * new RegexTag("key", /^a+$/).shadows(new Tag("key", "a")) // => false
 | ||||
|      *  | ||||
|      *  | ||||
|      * // should not shadow too eagerly: the first tag might match 'key=abc', the second won't
 | ||||
|      *  new RegexTag("key", /^..*$/).shadows(new Tag("key", "some_value")) // => false
 | ||||
|      *  | ||||
|      * // should handle 'invert'
 | ||||
|      * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","value")) // => false
 | ||||
|      * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","")) // => true
 | ||||
|      * new RegexTag("key","value", true).shadows(new Tag("key","value")) // => false
 | ||||
|      * new RegexTag("key","value", true).shadows(new Tag("key","some_other_value")) // => false
 | ||||
|      */ | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         if (other instanceof RegexTag) { | ||||
|             return other.asHumanString() == this.asHumanString(); | ||||
|             if((other.key["source"] ?? other.key) !== (this.key["source"] ?? this.key) ){ | ||||
|                 // Keys don't match, never shadowing
 | ||||
|                 return false | ||||
|             } | ||||
|             if((other.value["source"] ?? other.key) === (this.value["source"] ?? this.key) && this.invert  == other.invert ){ | ||||
|                 // Values (and inverts) match
 | ||||
|                 return true | ||||
|             } | ||||
|             if(typeof other.value ==="string"){ | ||||
|                 const valuesMatch = RegexTag.doesMatch(other.value, this.value) | ||||
|                 if(!this.invert && !other.invert){ | ||||
|                     // this: key~value, other: key=value
 | ||||
|                     return valuesMatch | ||||
|                 } | ||||
|                 if(this.invert && !other.invert){ | ||||
|                     // this: key!~value, other: key=value
 | ||||
|                     return !valuesMatch | ||||
|                 } | ||||
|                 if(!this.invert && other.invert){ | ||||
|                     // this: key~value, other: key!=value
 | ||||
|                     return !valuesMatch | ||||
|                 } | ||||
|                 if(!this.invert && !other.invert){ | ||||
|                     // this: key!~value, other: key!=value
 | ||||
|                     return valuesMatch | ||||
|                 } | ||||
|                  | ||||
|             } | ||||
|             return false; | ||||
|         } | ||||
|         if (other instanceof Tag) { | ||||
|             return RegexTag.doesMatch(other.key, this.key) && RegexTag.doesMatch(other.value, this.value); | ||||
|             if(!RegexTag.doesMatch(other.key, this.key)){ | ||||
|                 // Keys don't match
 | ||||
|                 return false; | ||||
|             } | ||||
|              | ||||
|              | ||||
|             if(this.value["source"] === "^..*$") { | ||||
|                 if(this.invert){ | ||||
|                     return other.value === "" | ||||
|                 } | ||||
|                 return false | ||||
|             } | ||||
|              | ||||
|             if (this.invert) { | ||||
|                 /* | ||||
|                  * this: "a!=b" | ||||
|                  * other: "a=c" | ||||
|                  * actual property: a=x | ||||
|                  * In other words: shadowing will never occur here | ||||
|                  */ | ||||
|                 return false; | ||||
|             } | ||||
|             // Unless the values are the same, it is pretty hard to figure out if they are shadowing. This is future work
 | ||||
|             return (this.value["source"] ?? this.value) === other.value; | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  |  | |||
|  | @ -35,7 +35,7 @@ export default class SubstitutingTag implements TagsFilter { | |||
|         throw "A variable with substitution can not be used to query overpass" | ||||
|     } | ||||
| 
 | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         if (!(other instanceof SubstitutingTag)) { | ||||
|             return false; | ||||
|         } | ||||
|  |  | |||
|  | @ -88,14 +88,23 @@ export class Tag extends TagsFilter { | |||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     isEquivalent(other: TagsFilter): boolean { | ||||
|         if (other instanceof Tag) { | ||||
|             return this.key === other.key && this.value === other.value; | ||||
|     /** | ||||
|      * // should handle advanced regexes
 | ||||
|      * new Tag("key", "aaa").shadows(new RegexTag("key", /a+/)) // => true
 | ||||
|      * new Tag("key","value").shadows(new RegexTag("key", /^..*$/, true)) // => false
 | ||||
|      * new Tag("key","value").shadows(new Tag("key","value")) // => true
 | ||||
|      * new Tag("key","some_other_value").shadows(new RegexTag("key", "value", true)) // => true
 | ||||
|      * new Tag("key","value").shadows(new RegexTag("key", "value", true)) // => false
 | ||||
|      * new Tag("key","value").shadows(new RegexTag("otherkey", "value", true)) // => false
 | ||||
|      * new Tag("key","value").shadows(new RegexTag("otherkey", "value", false)) // => false
 | ||||
|      */ | ||||
|     shadows(other: TagsFilter): boolean { | ||||
|         if(other["key"] !== undefined){ | ||||
|             if(other["key"] !== this.key){ | ||||
|                 return false | ||||
|             } | ||||
|         } | ||||
|         if (other instanceof RegexTag) { | ||||
|             other.isEquivalent(this); | ||||
|         } | ||||
|         return false; | ||||
|         return other.matchesProperties({[this.key]: this.value}); | ||||
|     } | ||||
| 
 | ||||
|     usedKeys(): string[] { | ||||
|  |  | |||
|  | @ -200,15 +200,16 @@ export class TagUtils { | |||
|      * | ||||
|      * TagUtils.Tag("key=value") // => new Tag("key", "value")
 | ||||
|      * TagUtils.Tag("key=") // => new Tag("key", "")
 | ||||
|      * TagUtils.Tag("key!=") // => new RegexTag("key", "^..*$")
 | ||||
|      * TagUtils.Tag("key!=value") // => new RegexTag("key", /^value$/, true)
 | ||||
|      * TagUtils.Tag("key!=") // => new RegexTag("key", /^..*$/)
 | ||||
|      * TagUtils.Tag("key~*") // => new RegexTag("key", /^..*$/)
 | ||||
|      * TagUtils.Tag("key!=value") // => new RegexTag("key", "value", true)
 | ||||
|      * TagUtils.Tag("vending~.*bicycle_tube.*") // => new RegexTag("vending", /^.*bicycle_tube.*$/)
 | ||||
|      * TagUtils.Tag("x!~y") // => new RegexTag("x", /^y$/, true)
 | ||||
|      * TagUtils.Tag({"and": ["key=value", "x=y"]}) // => new And([new Tag("key","value"), new Tag("x","y")])
 | ||||
|      * TagUtils.Tag("name~[sS]peelbos.*") // => new RegexTag("name", /^[sS]peelbos.*$/)
 | ||||
|      * TagUtils.Tag("survey:date:={_date:now}") // => new SubstitutingTag("survey:date", "{_date:now}")
 | ||||
|      * TagUtils.Tag("xyz!~\\[\\]") // => new RegexTag("xyz", /^\[\]$/, true)
 | ||||
|      * TagUtils.Tag("tags~(^|.*;)amenity=public_bookcase($|;.*)") // => new RegexTag("tags", /(^|.*;)amenity=public_bookcase($|;.*)/)
 | ||||
|      * TagUtils.Tag("tags~(.*;)?amenity=public_bookcase(;.*)?") // => new RegexTag("tags", /^(.*;)?amenity=public_bookcase(;.*)?$/)
 | ||||
|      * TagUtils.Tag("service:bicycle:.*~~*") // => new RegexTag(/^service:bicycle:.*$/, /^..*$/)
 | ||||
|      * | ||||
|      * TagUtils.Tag("xyz<5").matchesProperties({xyz: 4}) // => true
 | ||||
|  | @ -306,7 +307,7 @@ export class TagUtils { | |||
|             } | ||||
|             return new RegexTag( | ||||
|                 split[0], | ||||
|                 split[1], | ||||
|                 new RegExp("^"+ split[1]+"$"), | ||||
|                 true | ||||
|             ); | ||||
|         } | ||||
|  | @ -338,17 +339,6 @@ export class TagUtils { | |||
|                 split[1] = "..*" | ||||
|                 return new RegexTag(split[0], /^..*$/) | ||||
|             } | ||||
|             return new RegexTag( | ||||
|                 split[0], | ||||
|                 new RegExp("^" + split[1] + "$"), | ||||
|                 true | ||||
|             ); | ||||
|         } | ||||
|         if (tag.indexOf("!~") >= 0) { | ||||
|             const split = Utils.SplitFirst(tag, "!~"); | ||||
|             if (split[1] === "*") { | ||||
|                 split[1] = "..*" | ||||
|             } | ||||
|             return new RegexTag( | ||||
|                 split[0], | ||||
|                 split[1], | ||||
|  | @ -357,15 +347,18 @@ export class TagUtils { | |||
|         } | ||||
|         if (tag.indexOf("~") >= 0) { | ||||
|             const split = Utils.SplitFirst(tag, "~"); | ||||
|             let value : string | RegExp = split[1] | ||||
|             if (split[1] === "") { | ||||
|                 throw "Detected a regextag with an empty regex; this is not allowed. Use '" + split[0] + "='instead (at " + context + ")" | ||||
|             } | ||||
|             if (split[1] === "*") { | ||||
|                 split[1] = "..*" | ||||
|             if (value === "*") { | ||||
|                 value = /^..*$/ | ||||
|             }else { | ||||
|                 value = new RegExp("^"+value+"$") | ||||
|             } | ||||
|             return new RegexTag( | ||||
|                 split[0], | ||||
|                 split[1] | ||||
|                 value | ||||
|             ); | ||||
|         } | ||||
|         if (tag.indexOf("=") >= 0) { | ||||
|  | @ -431,4 +424,94 @@ export class TagUtils { | |||
|         return " (" + joined + ") " | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns 'true' is opposite tags are detected. | ||||
|      * Note that this method will never work perfectly | ||||
|      *  | ||||
|      * // should be false for some simple cases
 | ||||
|      * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key0", "value")]) // => false
 | ||||
|      * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key", "value0")]) // => false
 | ||||
|      *  | ||||
|      * // should detect simple cases
 | ||||
|      * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", "value", true)]) // => true
 | ||||
|      * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", /value/, true)]) // => true
 | ||||
|      */ | ||||
|     public static ContainsOppositeTags(tags: (TagsFilter)[]) : boolean{ | ||||
|         for (let i = 0; i < tags.length; i++){ | ||||
|             const tag = tags[i]; | ||||
|             if(!(tag instanceof Tag || tag instanceof RegexTag)){ | ||||
|                 continue | ||||
|             } | ||||
|             for (let j = i + 1; j < tags.length; j++){ | ||||
|                 const guard = tags[j]; | ||||
|                 if(!(guard instanceof Tag || guard instanceof RegexTag)){ | ||||
|                     continue | ||||
|                 } | ||||
|                 if(guard.key !== tag.key) { | ||||
|                     // Different keys: they can _never_ be opposites
 | ||||
|                     continue | ||||
|                 } | ||||
|                 if((guard.value["source"] ?? guard.value) !== (tag.value["source"] ?? tag.value)){ | ||||
|                     // different values: the can _never_ be opposites
 | ||||
|                     continue | ||||
|                 } | ||||
|                 if( (guard["invert"] ?? false) !== (tag["invert"] ?? false) ) { | ||||
|                     // The 'invert' flags are opposite, the key and value is the same for both
 | ||||
|                     // This means we have found opposite tags!
 | ||||
|                     return true | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return false | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns a filtered version of 'listToFilter'. | ||||
|      * For a list [t0, t1, t2], If `blackList` contains an equivalent (or broader) match of any `t`, this respective `t` is dropped from the returned list | ||||
|      * Ignores nested ORS and ANDS | ||||
|      *  | ||||
|      * TagUtils.removeShadowedElementsFrom([new Tag("key","value")],  [new Tag("key","value"), new Tag("other_key","value")]) // => [new Tag("other_key","value")]
 | ||||
|      */ | ||||
|     public static removeShadowedElementsFrom(blacklist: TagsFilter[], listToFilter: TagsFilter[] ) : TagsFilter[] { | ||||
|         return listToFilter.filter(tf => !blacklist.some(guard => guard.shadows(tf))) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns a filtered version of 'listToFilter', where no duplicates and no equivalents exists. | ||||
|      *  | ||||
|      * TagUtils.removeEquivalents([new RegexTag("key", /^..*$/), new Tag("key","value")]) // => [new Tag("key", "value")]
 | ||||
|      */ | ||||
|     public static removeEquivalents( listToFilter: (Tag | RegexTag)[]) : TagsFilter[] { | ||||
|         const result: TagsFilter[] = [] | ||||
|         outer: for (let i = 0; i < listToFilter.length; i++){ | ||||
|             const tag = listToFilter[i]; | ||||
|             for (let j = 0; j < listToFilter.length; j++){ | ||||
|                 if(i === j){ | ||||
|                     continue | ||||
|                 } | ||||
|                 const guard = listToFilter[j]; | ||||
|                 if(guard.shadows(tag)) { | ||||
|                     // the guard 'kills' the tag: we continue the outer loop without adding the tag
 | ||||
|                     continue outer; | ||||
|                 } | ||||
|             } | ||||
|             result.push(tag) | ||||
|         } | ||||
|         return result | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Returns `true` if at least one element of the 'guards' shadows one element of the 'listToFilter'. | ||||
|      * | ||||
|      * TagUtils.containsEquivalents([new Tag("key","value")],  [new Tag("key","value"), new Tag("other_key","value")]) // => true
 | ||||
|      * TagUtils.containsEquivalents([new Tag("key","value")],  [ new Tag("other_key","value")]) // => false
 | ||||
|      * TagUtils.containsEquivalents([new Tag("key","value")],  [ new Tag("key","other_value")]) // => false
 | ||||
|      */ | ||||
|     public static containsEquivalents( guards: TagsFilter[], listToFilter: TagsFilter[] ) : boolean { | ||||
|         return listToFilter.some(tf => guards.some(guard => guard.shadows(tf))) | ||||
|     } | ||||
|      | ||||
|    | ||||
| 
 | ||||
| } | ||||
|  | @ -4,7 +4,11 @@ export abstract class TagsFilter { | |||
| 
 | ||||
|     abstract isUsableAsAnswer(): boolean; | ||||
| 
 | ||||
|     abstract isEquivalent(other: TagsFilter): boolean; | ||||
|     /** | ||||
|      * Indicates some form of equivalency: | ||||
|      * if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties | ||||
|      */ | ||||
|     abstract shadows(other: TagsFilter): boolean; | ||||
| 
 | ||||
|     abstract matchesProperties(properties: any): boolean; | ||||
| 
 | ||||
|  | @ -30,7 +34,7 @@ export abstract class TagsFilter { | |||
|      * Returns an optimized version (or self) of this tagsFilter | ||||
|      */ | ||||
|     abstract optimize(): TagsFilter | boolean; | ||||
| 
 | ||||
|      | ||||
|     /** | ||||
|      * Returns 'true' if the tagsfilter might select all features (i.e. the filter will return everything from OSM, except a few entries). | ||||
|      *  | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import {Utils} from "../Utils"; | |||
| 
 | ||||
| export default class Constants { | ||||
| 
 | ||||
|     public static vNumber = "0.18.1"; | ||||
|     public static vNumber = "0.18.2"; | ||||
|      | ||||
|     public static ImgurApiKey = '7070e7167f0a25a' | ||||
|     public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" | ||||
|  | @ -62,6 +62,13 @@ export default class Constants { | |||
|      */ | ||||
|     static distanceToChangeObjectBins = [25, 50, 100, 500, 1000, 5000, Number.MAX_VALUE] | ||||
|     static themeOrder = ["personal", "cyclofix", "waste" , "etymology",  "food","cafes_and_pubs", "playgrounds", "hailhydrant", "toilets", "aed", "bookcases"]; | ||||
|     /** | ||||
|      * Upon initialization, the GPS will search the location. | ||||
|      * If the location is found within the given timout, it'll automatically fly to it. | ||||
|      *  | ||||
|      * In seconds | ||||
|      */ | ||||
|     static zoomToLocationTimeout = 60; | ||||
| 
 | ||||
|     private static isRetina(): boolean { | ||||
|         if (Utils.runningFromConsole) { | ||||
|  |  | |||
|  | @ -127,6 +127,7 @@ export default class LayerConfig extends WithContextLoader { | |||
|                 idKey: json.source["idKey"] | ||||
| 
 | ||||
|             }, | ||||
|             Constants.priviliged_layers.indexOf(this.id) > 0, | ||||
|             json.id | ||||
|         ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import {TagsFilter} from "../../Logic/Tags/TagsFilter"; | ||||
| import {RegexTag} from "../../Logic/Tags/RegexTag"; | ||||
| import {param} from "jquery"; | ||||
| 
 | ||||
| export default class SourceConfig { | ||||
| 
 | ||||
|  | @ -19,7 +20,7 @@ export default class SourceConfig { | |||
|         isOsmCache?: boolean, | ||||
|         geojsonSourceLevel?: number, | ||||
|         idKey?: string | ||||
|     }, context?: string) { | ||||
|     }, isSpecialLayer: boolean, context?: string) { | ||||
| 
 | ||||
|         let defined = 0; | ||||
|         if (params.osmTags) { | ||||
|  | @ -43,6 +44,15 @@ export default class SourceConfig { | |||
|                 throw `Source defines a geojson-zoomLevel, but does not specify {x} nor {y} (or equivalent), this is probably a bug (in context ${context})` | ||||
|             } | ||||
|         } | ||||
|         if(params.osmTags !== undefined && !isSpecialLayer){ | ||||
|             const optimized = params.osmTags.optimize() | ||||
|             if(optimized === false){ | ||||
|                 throw "Error at "+context+": the specified tags are conflicting with each other: they will never match anything at all" | ||||
|             } | ||||
|             if(optimized === true){ | ||||
|                        throw "Error at "+context+": the specified tags are very wide: they will always match everything" | ||||
|             } | ||||
|         } | ||||
|         this.osmTags = params.osmTags ?? new RegexTag("id", /.*/); | ||||
|         this.overpassScript = params.overpassScript; | ||||
|         this.geojsonSource = params.geojsonSource; | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import {Translation} from "../../UI/i18n/Translation"; | ||||
| import {Translation, TypedTranslation} from "../../UI/i18n/Translation"; | ||||
| import {TagsFilter} from "../../Logic/Tags/TagsFilter"; | ||||
| import Translations from "../../UI/i18n/Translations"; | ||||
| import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||
|  | @ -22,8 +22,8 @@ export default class TagRenderingConfig { | |||
| 
 | ||||
|     public readonly id: string; | ||||
|     public readonly group: string; | ||||
|     public readonly render?: Translation; | ||||
|     public readonly question?: Translation; | ||||
|     public readonly render?: TypedTranslation<object>; | ||||
|     public readonly question?: TypedTranslation<object>; | ||||
|     public readonly condition?: TagsFilter; | ||||
| 
 | ||||
|     public readonly configuration_warnings: string[] = [] | ||||
|  | @ -43,7 +43,7 @@ export default class TagRenderingConfig { | |||
|     public readonly mappings?: { | ||||
|         readonly if: TagsFilter, | ||||
|         readonly ifnot?: TagsFilter, | ||||
|         readonly then: Translation, | ||||
|         readonly then: TypedTranslation<object>, | ||||
|         readonly icon: string, | ||||
|         readonly iconClass: string | ||||
|         readonly hideInAnswer: boolean | TagsFilter | ||||
|  | @ -110,12 +110,13 @@ export default class TagRenderingConfig { | |||
|             } | ||||
|            const type = json.freeform.type ?? "string" | ||||
| 
 | ||||
|             let placeholder = Translations.T(json.freeform.placeholder) | ||||
|             let placeholder: Translation = Translations.T(json.freeform.placeholder) | ||||
|             if (placeholder === undefined) { | ||||
|                 const typeDescription = Translations.t.validation[type]?.description | ||||
|                 placeholder = Translations.T(json.freeform.key+" ("+type+")") | ||||
|                 if(typeDescription !== undefined){ | ||||
|                     placeholder = placeholder.Subs({[type]: typeDescription}) | ||||
|                     placeholder = Translations.T(json.freeform.key+" ("+type+")").Subs({[type]: typeDescription}) | ||||
|                 }else{ | ||||
|                     placeholder = Translations.T(json.freeform.key+" ("+type+")") | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|  | @ -383,7 +384,7 @@ export default class TagRenderingConfig { | |||
|         let freeformKeyDefined = this.freeform?.key !== undefined; | ||||
|         let usedFreeformValues = new Set<string>() | ||||
|         // We run over all the mappings first, to check if the mapping matches
 | ||||
|         const applicableMappings: { then: Translation, img?: string }[] = Utils.NoNull((this.mappings ?? [])?.map(mapping => { | ||||
|         const applicableMappings: { then: TypedTranslation<any>, img?: string }[] = Utils.NoNull((this.mappings ?? [])?.map(mapping => { | ||||
|             if (mapping.if === undefined) { | ||||
|                 return mapping; | ||||
|             } | ||||
|  | @ -404,7 +405,7 @@ export default class TagRenderingConfig { | |||
|             const leftovers = freeformValues.filter(v => !usedFreeformValues.has(v)) | ||||
|             for (const leftover of leftovers) { | ||||
|                 applicableMappings.push({then:  | ||||
|                         this.render.replace("{"+this.freeform.key+"}", leftover) | ||||
|                         new TypedTranslation<object>(this.render.replace("{"+this.freeform.key+"}", leftover).translations)  | ||||
|                 }) | ||||
|             } | ||||
|         } | ||||
|  | @ -412,7 +413,7 @@ export default class TagRenderingConfig { | |||
|         return applicableMappings | ||||
|     } | ||||
| 
 | ||||
|     public GetRenderValue(tags: any, defltValue: any = undefined): Translation { | ||||
|     public GetRenderValue(tags: any, defltValue: any = undefined): TypedTranslation<any> { | ||||
|         return this.GetRenderValueWithImage(tags, defltValue).then | ||||
|     } | ||||
| 
 | ||||
|  | @ -421,7 +422,7 @@ export default class TagRenderingConfig { | |||
|      * Not compatible with multiAnswer - use GetRenderValueS instead in that case | ||||
|      * @constructor | ||||
|      */ | ||||
|     public GetRenderValueWithImage(tags: any, defltValue: any = undefined): { then: Translation, icon?: string } { | ||||
|     public GetRenderValueWithImage(tags: any, defltValue: any = undefined): { then: TypedTranslation<any>, icon?: string } { | ||||
|         if (this.mappings !== undefined && !this.multiAnswer) { | ||||
|             for (const mapping of this.mappings) { | ||||
|                 if (mapping.if === undefined) { | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import Link from "./Link"; | |||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| export default class LinkToWeblate extends VariableUiElement { | ||||
|     private static URI: any; | ||||
|     constructor(context: string, availableTranslations: object) { | ||||
|         super( Locale.language.map(ln => { | ||||
|             if (Locale.showLinkToWeblate.data === false) { | ||||
|  | @ -36,4 +37,10 @@ export default class LinkToWeblate extends VariableUiElement { | |||
|         const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/" | ||||
|         return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22" | ||||
|     } | ||||
| 
 | ||||
|     public static hrefToWeblateZen(language: string, category: string, searchKey: string): string{ | ||||
|         const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/" | ||||
|         // ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum=
 | ||||
|         return baseUrl + category + "/" + language + "?offset=1&q=+state%3A%3Ctranslated+context%3A"+encodeURIComponent(searchKey)+"&sort_by=-priority%2Cposition&checksum=" | ||||
|     } | ||||
| } | ||||
|  | @ -49,7 +49,7 @@ export default abstract class BaseUIElement { | |||
|      */ | ||||
|     public SetClass(clss: string) { | ||||
|         if (clss == undefined) { | ||||
|             return | ||||
|             return this | ||||
|         } | ||||
|         const all = clss.split(" ").map(clsName => clsName.trim()); | ||||
|         let recordedChange = false; | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export default class AddNewMarker extends Combine { | |||
|             let last = undefined; | ||||
|             for (const filteredLayer of filteredLayers) { | ||||
|                 const layer = filteredLayer.layerDef; | ||||
|                 if(layer.name === undefined){ | ||||
|                 if(layer.name === undefined && !filteredLayer.isDisplayed.data){ | ||||
|                     continue | ||||
|                 } | ||||
|                 for (const preset of filteredLayer.layerDef.presets) { | ||||
|  |  | |||
|  | @ -22,7 +22,7 @@ import {OsmConnection} from "../../Logic/Osm/OsmConnection"; | |||
| import Constants from "../../Models/Constants"; | ||||
| import ContributorCount from "../../Logic/ContributorCount"; | ||||
| import Img from "../Base/Img"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| import {TypedTranslation} from "../i18n/Translation"; | ||||
| import TranslatorsPanel from "./TranslatorsPanel"; | ||||
| 
 | ||||
| export class OpenIdEditor extends VariableUiElement { | ||||
|  | @ -198,7 +198,7 @@ export default class CopyrightPanel extends Combine { | |||
|         this.SetStyle("max-width:100%; width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem") | ||||
|     } | ||||
| 
 | ||||
|     private static CodeContributors(contributors, translation: Translation): BaseUIElement { | ||||
|     private static CodeContributors(contributors, translation: TypedTranslation<{contributors, hiddenCount}>): BaseUIElement { | ||||
| 
 | ||||
|         const total = contributors.contributors.length; | ||||
|         let filtered = [...contributors.contributors] | ||||
|  |  | |||
|  | @ -90,10 +90,10 @@ export default class MoreScreen extends Combine { | |||
|         } | ||||
| 
 | ||||
|         let hash = "" | ||||
|         if(layout.definition !== undefined){ | ||||
|             hash = "#"+btoa(JSON.stringify(layout.definition)) | ||||
|         if (layout.definition !== undefined) { | ||||
|             hash = "#" + btoa(JSON.stringify(layout.definition)) | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const linkText = currentLocation?.map(currentLocation => { | ||||
|             const params = [ | ||||
|                 ["z", currentLocation?.zoom], | ||||
|  | @ -106,11 +106,10 @@ export default class MoreScreen extends Combine { | |||
|         }) ?? new UIEventSource<string>(`${linkPrefix}`) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|         return new SubtleButton(layout.icon, | ||||
|             new Combine([ | ||||
|                 `<dt class='text-lg leading-6 font-medium text-gray-900 group-hover:text-blue-800'>`, | ||||
|                 new Translation(layout.title, !isCustom && !layout.mustHaveLanguage ? "themes:"+layout.id+".title" : undefined), | ||||
|                 new Translation(layout.title, !isCustom && !layout.mustHaveLanguage ? "themes:" + layout.id + ".title" : undefined), | ||||
|                 `</dt>`, | ||||
|                 `<dd class='mt-1 text-base text-gray-500 group-hover:text-blue-900 overflow-ellipsis'>`, | ||||
|                 new Translation(layout.shortDescription)?.SetClass("subtle") ?? "", | ||||
|  | @ -128,15 +127,13 @@ export default class MoreScreen extends Combine { | |||
|     } | ||||
| 
 | ||||
|     private static createUnofficialButtonFor(state: UserRelatedState, id: string): BaseUIElement { | ||||
|         const allPreferences = state.osmConnection.preferencesHandler.preferences.data; | ||||
|         const length = Number(allPreferences[id + "-length"]) | ||||
|         let str = ""; | ||||
|         for (let i = 0; i < length; i++) { | ||||
|             str += allPreferences[id + "-" + i] | ||||
|         } | ||||
|         if(str === undefined || str === "undefined"){ | ||||
|         const pref = state.osmConnection.GetLongPreference(id) | ||||
|         const str = pref.data | ||||
|         if (str === undefined || str === "undefined" || str === "") { | ||||
|             pref.setData(null) | ||||
|             return undefined | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const value: { | ||||
|                 id: string | ||||
|  | @ -149,7 +146,8 @@ export default class MoreScreen extends Combine { | |||
|             value.isOfficial = false | ||||
|             return MoreScreen.createLinkButton(state, value, true) | ||||
|         } catch (e) { | ||||
|             console.debug("Could not parse unofficial theme information for " + id, "The json is: ", str, e) | ||||
|             console.warn("Removing theme " + id + " as it could not be parsed from the preferences") | ||||
|             pref.setData(null) | ||||
|             return undefined | ||||
|         } | ||||
|     } | ||||
|  | @ -163,16 +161,14 @@ export default class MoreScreen extends Combine { | |||
| 
 | ||||
|                 for (const key in allPreferences) { | ||||
|                     if (key.startsWith(prefix) && key.endsWith("-combined-length")) { | ||||
|                         const id = key.substring(0, key.length - "-length".length) | ||||
|                         const id = key.substring("mapcomplete-".length, key.length - "-combined-length".length) | ||||
|                         ids.push(id) | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 return ids | ||||
|             }); | ||||
| 
 | ||||
|         var stableIds = UIEventSource.ListStabilized<string>(currentIds) | ||||
| 
 | ||||
|         return new VariableUiElement( | ||||
|             stableIds.map(ids => { | ||||
|                 const allThemes: BaseUIElement[] = [] | ||||
|  | @ -182,12 +178,11 @@ export default class MoreScreen extends Combine { | |||
|                         allThemes.push(link.SetClass(buttonClass)) | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 if (allThemes.length <= 0) { | ||||
|                     return undefined; | ||||
|                 } | ||||
|                 return new Combine([ | ||||
|                     Translations.t.general.customThemeIntro.Clone(), | ||||
|                     Translations.t.general.customThemeIntro, | ||||
|                     new Combine(allThemes).SetClass(themeListClasses) | ||||
|                 ]); | ||||
|             })); | ||||
|  |  | |||
|  | @ -208,15 +208,20 @@ export default class SimpleAddUI extends Toggle { | |||
|         const allButtons = []; | ||||
|         for (const layer of state.filteredLayers.data) { | ||||
| 
 | ||||
|             if (layer.isDisplayed.data === false && !state.featureSwitchFilter.data) { | ||||
|                 // The layer is not displayed and we cannot enable the layer control -> we skip
 | ||||
|                 continue; | ||||
|             if (layer.isDisplayed.data === false) { | ||||
|                 // The layer is not displayed...
 | ||||
|                 if(!state.featureSwitchFilter.data){ | ||||
|                     // ...and we cannot enable the layer control -> we skip, as these presets can never be shown anyway
 | ||||
|                     continue; | ||||
|                 } | ||||
| 
 | ||||
|                 if (layer.layerDef.name === undefined) { | ||||
|                     // this layer can never be toggled on in any case, so we skip the presets
 | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (layer.layerDef.name === undefined) { | ||||
|                 // this is a parlty hidden layer
 | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             const presets = layer.layerDef.presets; | ||||
|             for (const preset of presets) { | ||||
|  |  | |||
|  | @ -14,7 +14,9 @@ import Title from "../Base/Title"; | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| import * as native_languages from "../../assets/language_native.json" | ||||
| import * as used_languages from "../../assets/generated/used_languages.json" | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| class TranslatorsPanelContent extends Combine { | ||||
|     constructor(layout: LayoutConfig, isTranslator: UIEventSource<boolean>) { | ||||
|  | @ -24,36 +26,53 @@ class TranslatorsPanelContent extends Combine { | |||
| 
 | ||||
|         const seed = t.completeness | ||||
|         for (const ln of Array.from(completeness.keys())) { | ||||
|             if(ln === "*"){ | ||||
|             if (ln === "*") { | ||||
|                 continue | ||||
|             } | ||||
|             if (seed.translations[ln] === undefined) { | ||||
|                 seed.translations[ln] = seed.translations["en"] | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         const completenessTr = {} | ||||
|         const completenessPercentage = {} | ||||
|         seed.SupportedLanguages().forEach(ln => { | ||||
|             completenessTr[ln] = ""+(completeness.get(ln) ?? 0) | ||||
|             completenessPercentage[ln] = ""+Math.round(100 * (completeness.get(ln) ?? 0) / total) | ||||
|             completenessTr[ln] = "" + (completeness.get(ln) ?? 0) | ||||
|             completenessPercentage[ln] = "" + Math.round(100 * (completeness.get(ln) ?? 0) / total) | ||||
|         }) | ||||
| 
 | ||||
|         const missingTranslationsFor = (ln: string) => Utils.NoNull(untranslated.get(ln) ?? []) | ||||
|             .filter(ctx => ctx.indexOf(":") >= 0) | ||||
|             .map(ctx => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import")) | ||||
|             .map(context => new Link(context, LinkToWeblate.hrefToWeblate(ln, context), true)) | ||||
|         function missingTranslationsFor(language: string): BaseUIElement[] { | ||||
|             // e.g. "themes:<themename>.layers.0.tagRenderings..., or "layers:<layername>.description
 | ||||
|             const missingKeys = Utils.NoNull(untranslated.get(language) ?? []) | ||||
|                 .filter(ctx => ctx.indexOf(":") >= 0) | ||||
|                 .map(ctx => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import")) | ||||
| 
 | ||||
|             const hasMissingTheme = missingKeys.some(k => k.startsWith("themes:")) | ||||
|             const missingLayers = Utils.Dedup( missingKeys.filter(k => k.startsWith("layers:")) | ||||
|                 .map(k => k.slice("layers:".length).split(".")[0])) | ||||
| 
 | ||||
|             console.log("Getting untranslated string for",language,"raw:",missingKeys,"hasMissingTheme:",hasMissingTheme,"missingLayers:",missingLayers) | ||||
|             return [ | ||||
|                 hasMissingTheme ? new Link("themes:" + layout.id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "themes", layout.id), true) : undefined, | ||||
|                 ...missingLayers.map(id => new Link("layer:" + id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "layers", id), true)), | ||||
|                 ...missingKeys.map(context => new Link(context, LinkToWeblate.hrefToWeblate(language, context), true)) | ||||
|             ] | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         //
 | ||||
|         // 
 | ||||
|         // "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
 | ||||
|         const translated = seed.Subs({total, theme: layout.title, | ||||
|         const translated = seed.Subs({ | ||||
|             total, theme: layout.title, | ||||
|             percentage: new Translation(completenessPercentage), | ||||
|             translated: new Translation(completenessTr) | ||||
|             translated: new Translation(completenessTr), | ||||
|             language: seed.OnEveryLanguage((_, lng) => native_languages[lng] ?? lng) | ||||
|         }) | ||||
|          | ||||
| 
 | ||||
|         super([ | ||||
|             new Title( | ||||
|             Translations.t.translations.activateButton, | ||||
|                 Translations.t.translations.activateButton, | ||||
|             ), | ||||
|             new Toggle(t.isTranslator.SetClass("thanks block"), undefined, isTranslator), | ||||
|             t.help, | ||||
|  | @ -63,15 +82,18 @@ class TranslatorsPanelContent extends Combine { | |||
|                 .onClick(() => { | ||||
|                     Locale.showLinkToWeblate.setData(false) | ||||
|                 }), | ||||
|              | ||||
|             new VariableUiElement(Locale.language.map(ln => { | ||||
| 
 | ||||
|             new VariableUiElement(Locale.language.map(ln => { | ||||
|                 const missing = missingTranslationsFor(ln) | ||||
|                 if (missing.length === 0) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 let title = Translations.t.translations.allMissing; | ||||
|                 if(untranslated.get(ln) !== undefined){ | ||||
|                     title = Translations.t.translations.missing.Subs({count: untranslated.get(ln).length}) | ||||
|                 } | ||||
|                 return new Toggleable( | ||||
|                     new Title(Translations.t.translations.missing.Subs({count: missing.length})), | ||||
|                     new Title(title), | ||||
|                     new Combine(missing).SetClass("flex flex-col") | ||||
|                 ) | ||||
|             })) | ||||
|  | @ -83,38 +105,37 @@ class TranslatorsPanelContent extends Combine { | |||
| 
 | ||||
| export default class TranslatorsPanel extends Toggle { | ||||
| 
 | ||||
|      | ||||
| 
 | ||||
|     constructor(state: { layoutToUse: LayoutConfig, isTranslator: UIEventSource<boolean> }, iconStyle?: string) { | ||||
|         const t = Translations.t.translations | ||||
|         super( | ||||
|                 new Lazy(() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator) | ||||
|             new Lazy(() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator) | ||||
|             ).SetClass("flex flex-col"), | ||||
|             new SubtleButton(Svg.translate_ui().SetStyle(iconStyle), t.activateButton).onClick(() => Locale.showLinkToWeblate.setData(true)), | ||||
|             Locale.showLinkToWeblate  | ||||
|             Locale.showLinkToWeblate | ||||
|         ) | ||||
|         this.SetClass("hidden-on-mobile") | ||||
|          | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     public static MissingTranslationsFor(layout: LayoutConfig) : {completeness: Map<string, number>, untranslated: Map<string, string[]>, total: number} { | ||||
|     public static MissingTranslationsFor(layout: LayoutConfig): { completeness: Map<string, number>, untranslated: Map<string, string[]>, total: number } { | ||||
|         let total = 0 | ||||
|         const completeness = new Map<string, number>() | ||||
|         const untranslated = new Map<string, string[]>() | ||||
| 
 | ||||
|         Utils.WalkObject(layout, (o, path) => { | ||||
|             const translation = <Translation><any>o; | ||||
|             if(translation.translations["*"] !== undefined){ | ||||
|             if (translation.translations["*"] !== undefined) { | ||||
|                 return | ||||
|             } | ||||
|             if(translation.context === undefined || translation.context.indexOf(":") < 0){ | ||||
|             if (translation.context === undefined || translation.context.indexOf(":") < 0) { | ||||
|                 // no source given - lets ignore
 | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             for (const lang of translation.SupportedLanguages()) { | ||||
|                 completeness.set(lang, 1 + (completeness.get(lang) ?? 0)) | ||||
|             } | ||||
|             layout.title.SupportedLanguages().forEach(ln => { | ||||
|              | ||||
|             total ++ | ||||
|             used_languages.languages.forEach(ln => { | ||||
|                 const trans = translation.translations | ||||
|                 if (trans["*"] !== undefined) { | ||||
|                     return; | ||||
|  | @ -124,11 +145,11 @@ export default class TranslatorsPanel extends Toggle { | |||
|                         untranslated.set(ln, []) | ||||
|                     } | ||||
|                     untranslated.get(ln).push(translation.context) | ||||
|                 }else{ | ||||
|                     completeness.set(ln, 1 + (completeness.get(ln) ?? 0)) | ||||
|                 } | ||||
|             }) | ||||
|             if(translation.translations["*"] === undefined){ | ||||
|                 total++ | ||||
|             } | ||||
|             | ||||
|         }, o => { | ||||
|             if (o === undefined || o === null) { | ||||
|                 return false; | ||||
|  |  | |||
|  | @ -248,7 +248,7 @@ export default class TagRenderingQuestion extends Combine { | |||
|         const inputEl = new InputElementMap<number[], TagsFilter>( | ||||
|             checkBoxes, | ||||
|             (t0, t1) => { | ||||
|                 return t0?.isEquivalent(t1) ?? false | ||||
|                 return t0?.shadows(t1) ?? false | ||||
|             }, | ||||
|             (indices) => { | ||||
|                 if (indices.length === 0) { | ||||
|  | @ -370,7 +370,7 @@ export default class TagRenderingQuestion extends Combine { | |||
|         return new FixedInputElement( | ||||
|             TagRenderingQuestion.GenerateMappingContent(mapping, tagsSource, state), | ||||
|             tagging, | ||||
|             (t0, t1) => t1.isEquivalent(t0)); | ||||
|             (t0, t1) => t1.shadows(t0)); | ||||
|     } | ||||
| 
 | ||||
|     private static GenerateMappingContent(mapping: { | ||||
|  | @ -450,7 +450,7 @@ export default class TagRenderingQuestion extends Combine { | |||
|         }) | ||||
| 
 | ||||
|         let inputTagsFilter: InputElement<TagsFilter> = new InputElementMap( | ||||
|             input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), | ||||
|             input, (a, b) => a === b || (a?.shadows(b) ?? false), | ||||
|             pickString, toString | ||||
|         ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ export default class ReviewElement extends VariableUiElement { | |||
|                         SingleReview.GenStars(avg), | ||||
|                         new Link( | ||||
|                             revs.length === 1 ? Translations.t.reviews.title_singular.Clone() : | ||||
|                                 Translations.t.reviews.title.Clone() | ||||
|                                 Translations.t.reviews.title | ||||
|                                     .Subs({count: "" + revs.length}), | ||||
|                             `https://mangrove.reviews/search?sub=${encodeURIComponent(subject)}`, | ||||
|                             true | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| import {Translation, TypedTranslation} from "../i18n/Translation"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Loading from "../Base/Loading"; | ||||
| import Translations from "../i18n/Translations"; | ||||
|  | @ -22,7 +22,7 @@ export default class WikidataPreviewBox extends VariableUiElement { | |||
|     private static extraProperties: { | ||||
|         requires?: { p: number, q?: number }[], | ||||
|         property: string, | ||||
|         display: Translation | Map<string, string | (() => BaseUIElement) /*If translation: Subs({value: * })  */> | ||||
|         display: TypedTranslation<{value}> | Map<string, string | (() => BaseUIElement) /*If translation: Subs({value: * })  */> | ||||
|     }[] = [ | ||||
|         { | ||||
|             requires: WikidataPreviewBox.isHuman, | ||||
|  |  | |||
|  | @ -1,9 +1,6 @@ | |||
| import Locale from "./Locale"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Link from "../Base/Link"; | ||||
| import Svg from "../../Svg"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import LinkToWeblate from "../Base/LinkToWeblate"; | ||||
| 
 | ||||
| export class Translation extends BaseUIElement { | ||||
|  | @ -164,25 +161,7 @@ export class Translation extends BaseUIElement { | |||
|     public AllValues(): string[] { | ||||
|         return this.SupportedLanguages().map(lng => this.translations[lng]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Substitutes text in a translation. | ||||
|      * If a translation is passed, it'll be fused | ||||
|      *  | ||||
|      * // Should replace simple keys
 | ||||
|      * new Translation({"en": "Some text {key}"}).Subs({key: "xyz"}).textFor("en") // => "Some text xyz"
 | ||||
|      *  | ||||
|      * // Should fuse translations
 | ||||
|      * const subpart = new Translation({"en": "subpart","nl":"onderdeel"}) | ||||
|      * const tr = new Translation({"en": "Full sentence with {part}", nl: "Volledige zin met {part}"}) | ||||
|      * const subbed = tr.Subs({part: subpart}) | ||||
|      * subbed.textFor("en") // => "Full sentence with subpart"
 | ||||
|      * subbed.textFor("nl") // => "Volledige zin met onderdeel"
 | ||||
|      */ | ||||
|     public Subs(text: any, context?: string): Translation { | ||||
|         return this.OnEveryLanguage((template, lang) => Utils.SubstituteKeys(template, text, lang), context) | ||||
|     } | ||||
| 
 | ||||
|      | ||||
|     public OnEveryLanguage(f: (s: string, language: string) => string, context?: string): Translation { | ||||
|         const newTranslations = {}; | ||||
|         for (const lang in this.translations) { | ||||
|  | @ -278,5 +257,28 @@ export class Translation extends BaseUIElement { | |||
|         return this.txt | ||||
|     } | ||||
|      | ||||
| } | ||||
| 
 | ||||
| export class TypedTranslation<T> extends Translation { | ||||
|     constructor(translations: object, context?: string) { | ||||
|         super(translations, context); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Substitutes text in a translation. | ||||
|      * If a translation is passed, it'll be fused | ||||
|      * | ||||
|      * // Should replace simple keys
 | ||||
|      * new TypedTranslation<object>({"en": "Some text {key}"}).Subs({key: "xyz"}).textFor("en") // => "Some text xyz"
 | ||||
|      * | ||||
|      * // Should fuse translations
 | ||||
|      * const subpart = new Translation({"en": "subpart","nl":"onderdeel"}) | ||||
|      * const tr = new TypedTranslation<object>({"en": "Full sentence with {part}", nl: "Volledige zin met {part}"}) | ||||
|      * const subbed = tr.Subs({part: subpart}) | ||||
|      * subbed.textFor("en") // => "Full sentence with subpart"
 | ||||
|      * subbed.textFor("nl") // => "Volledige zin met onderdeel"
 | ||||
|      */ | ||||
|     Subs(text: T, context?: string): Translation { | ||||
|         return this.OnEveryLanguage((template, lang) => Utils.SubstituteKeys(template, text, lang), context) | ||||
|     } | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {Translation} from "./Translation"; | ||||
| import {Translation, TypedTranslation} from "./Translation"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import * as known_languages from "../../assets/generated/used_languages.json" | ||||
| import CompiledTranslations from "../../assets/generated/CompiledTranslations"; | ||||
|  | @ -22,7 +22,7 @@ export default class Translations { | |||
|         return s; | ||||
|     } | ||||
| 
 | ||||
|     static T(t: string | any, context = undefined): Translation { | ||||
|     static T(t: string | any, context = undefined): TypedTranslation<object> { | ||||
|         if (t === undefined || t === null) { | ||||
|             return undefined; | ||||
|         } | ||||
|  | @ -30,17 +30,17 @@ export default class Translations { | |||
|             t = "" + t | ||||
|         } | ||||
|         if (typeof t === "string") { | ||||
|             return new Translation({"*": t}, context); | ||||
|             return new TypedTranslation({"*": t}, context); | ||||
|         } | ||||
|         if (t.render !== undefined) { | ||||
|             const msg = "Creating a translation, but this object contains a 'render'-field. Use the translation directly" | ||||
|             console.error(msg, t); | ||||
|             throw msg | ||||
|         } | ||||
|         if (t instanceof Translation) { | ||||
|         if (t instanceof TypedTranslation) { | ||||
|             return t; | ||||
|         } | ||||
|         return new Translation(t, context); | ||||
|         return new TypedTranslation(t, context); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| import {readFileSync, writeFileSync} from "fs"; | ||||
| import {Utils} from "../../../Utils"; | ||||
| import {TagRenderingConfigJson} from "../../../Models/ThemeConfig/Json/TagRenderingConfigJson"; | ||||
| import ScriptUtils from "../../../scripts/ScriptUtils"; | ||||
| import {LayerConfigJson} from "../../../Models/ThemeConfig/Json/LayerConfigJson"; | ||||
| import FilterConfigJson from "../../../Models/ThemeConfig/Json/FilterConfigJson"; | ||||
|  | @ -8,7 +7,7 @@ import {QuestionableTagRenderingConfigJson} from "../../../Models/ThemeConfig/Js | |||
| 
 | ||||
| 
 | ||||
| function colonSplit(value: string): string[] { | ||||
|     return value.split(";").map(v => v.replace(/"/g, '').trim().toLowerCase()).filter(s => s !== ""); | ||||
|     return value.split(";").map(v => v.replace(/"/g, '').trim()).filter(s => s !== ""); | ||||
| } | ||||
| 
 | ||||
| function loadCsv(file): { | ||||
|  |  | |||
|  | @ -51,7 +51,8 @@ | |||
|   ], | ||||
|   "overrideAll": { | ||||
|     "allowMove": { | ||||
|       "improveAccuracy": true | ||||
|       "enableRelocation": false, | ||||
|       "enableImproveAccuracy": true | ||||
|     }, | ||||
|     "+titleIcons": [ | ||||
|       { | ||||
|  |  | |||
|  | @ -276,17 +276,17 @@ | |||
|         "willBePublished": "La teva foto serà publicada: " | ||||
|     }, | ||||
|     "importHelper": { | ||||
|         "allAttributesSame": "Totes les funcions a importar tenen aquesta etiqueta", | ||||
|         "description": "L'ajudant d'importació converteix un conjunt de dades extern en notes. El conjunt de dades extern ha de coincidir amb una de les capes MapComplete existents. Per a cada article que introdueixes a l'importador, es crearà una nota única. Aquestes notes es mostraran juntament amb les característiques rellevants en aquests mapes per afegir-les fàcilment.", | ||||
|         "importFormat": "Un text d'una nota ha de tenir el format següent per poder ser recollit: <br><div class=\"literal-code\">[Una petita introducció]<br>https://mapcomplete.osm.be /[themename].html?[paràmetres com ara lat i lon]#import<br>[totes les etiquetes de la funció] </div>", | ||||
|         "inspectDataTitle": "Inspecciona les dades de {count} funcions per importar", | ||||
|         "inspectDidAutoDected": "La capa es va seleccionar automàticament", | ||||
|         "inspectLooksCorrect": "Aquests valors semblen correctes", | ||||
|         "lockNotice": "Aquesta pàgina està bloquejada. Necessites {importHelperUnlock} conjunts de canvis per poder accedir aquí.", | ||||
|         "locked": "Necessites almenys {importHelperUnlock} per utilitzar l'ajudant d'importació", | ||||
|         "loggedInWith": "Actualment has entrat com a <b>{name}</b> i has fet {csCount} conjunts de canvis", | ||||
|         "loginIsCorrect": "<b>{name}</b> és el compte correcte per crear les notes d'importació.", | ||||
|         "loginRequired": "Has d'entrar per continuar", | ||||
|         "introduction": { | ||||
|             "description": "L'ajudant d'importació converteix un conjunt de dades extern en notes. El conjunt de dades extern ha de coincidir amb una de les capes MapComplete existents. Per a cada article que introdueixes a l'importador, es crearà una nota única. Aquestes notes es mostraran juntament amb les característiques rellevants en aquests mapes per afegir-les fàcilment.", | ||||
|             "importFormat": "Un text d'una nota ha de tenir el format següent per poder ser recollit: <br><div class=\"literal-code\">[Una petita introducció]<br>https://mapcomplete.osm.be /[themename].html?[paràmetres com ara lat i lon]#import<br>[totes les etiquetes de la funció] </div>" | ||||
|         }, | ||||
|         "login": { | ||||
|             "lockNotice": "Aquesta pàgina està bloquejada. Necessites {importHelperUnlock} conjunts de canvis per poder accedir aquí.", | ||||
|             "loggedInWith": "Actualment has entrat com a <b>{name}</b> i has fet {csCount} conjunts de canvis", | ||||
|             "loginIsCorrect": "<b>{name}</b> és el compte correcte per crear les notes d'importació.", | ||||
|             "loginRequired": "Has d'entrar per continuar", | ||||
|             "userAccountTitle": "Seleccionar compte d'usuari" | ||||
|         }, | ||||
|         "mapPreview": { | ||||
|             "autodetected": "La capa es va deduir automàticament en funció de les propietats", | ||||
|             "confirm": "Les característiques es troben a la ubicació correcta del mapa", | ||||
|  | @ -294,6 +294,12 @@ | |||
|             "selectLayer": "Amb quina capa coincideix aquesta importació?", | ||||
|             "title": "Vista prèvia del mapa" | ||||
|         }, | ||||
|         "previewAttributes": { | ||||
|             "allAttributesSame": "Totes les funcions a importar tenen aquesta etiqueta", | ||||
|             "inspectDataTitle": "Inspecciona les dades de {count} funcions per importar", | ||||
|             "inspectLooksCorrect": "Aquests valors semblen correctes", | ||||
|             "someHaveSame": "{count} característiques per importar tenen aquesta etiqueta, això és un {percentage}% del total" | ||||
|         }, | ||||
|         "selectFile": { | ||||
|             "description": "Seleccionar un fitxer .csv o .geojson per començar", | ||||
|             "errDuplicate": "Algunes columnes tenen el mateix nom", | ||||
|  | @ -308,11 +314,7 @@ | |||
|             "noFilesLoaded": "No s'ha carregat cap arxiu", | ||||
|             "title": "Seleccionar arxiu" | ||||
|         }, | ||||
|         "selectLayer": "Seleccionar capa...", | ||||
|         "someHaveSame": "{count} característiques per importar tenen aquesta etiqueta, això és un {percentage}% del total", | ||||
|         "title": "Ajuda de l'importador", | ||||
|         "userAccountTitle": "Seleccionar compte d'usuari", | ||||
|         "validateDataTitle": "Validar dades" | ||||
|         "title": "Ajuda de l'importador" | ||||
|     }, | ||||
|     "importInspector": { | ||||
|         "title": "Inspeccionar i controlar notes d'importació" | ||||
|  |  | |||
|  | @ -408,11 +408,10 @@ | |||
|             "title": "Thema auswählen", | ||||
|             "unmatchedTitle": "Die folgenden Elemente stimmen mit keiner Voreinstellung überein" | ||||
|         }, | ||||
|         "someHaveSame": "{count} der zu importierenden Objekte haben dieses Tag, das sind {percentage}% der Gesamtzahl", | ||||
|         "testMode": "Testmodus - Notizen werden nicht importiert", | ||||
|         "title": "Import-Helfer", | ||||
|         "userAccountTitle": "Wähle einen Benutzeraccount", | ||||
|         "validateDataTitle": "Bestätige Daten" | ||||
|         "validateDataTitle": "Bestätige Daten", | ||||
|         "title": "Import-Helfer" | ||||
|     }, | ||||
|     "importInspector": { | ||||
|         "title": "Importhinweise überprüfen und verwalten" | ||||
|  |  | |||
|  | @ -619,6 +619,7 @@ | |||
|     }, | ||||
|     "translations": { | ||||
|         "activateButton": "Help to translate MapComplete", | ||||
|         "allMissing": "No translations yet", | ||||
|         "completeness": "Translations for {theme} in {language} are at {percentage}%: {translated} strings out of {total} are translated", | ||||
|         "deactivate": "Disable translation buttons", | ||||
|         "help": "Click the 'translate'-icon next to a string to enter or update a piece of text. You need a Weblate-account for this. Create one with your OSM-username to automatically unlock translation mode.", | ||||
|  |  | |||
|  | @ -275,8 +275,7 @@ | |||
|         "selectFile": { | ||||
|             "title": "Seleccionar archivo" | ||||
|         }, | ||||
|         "title": "Ayudante de importación", | ||||
|         "validateDataTitle": "Validar datos" | ||||
|         "title": "Ayudante de importación" | ||||
|     }, | ||||
|     "importLayer": { | ||||
|         "layerName": "Posible {title}", | ||||
|  |  | |||
|  | @ -42,11 +42,6 @@ | |||
|         "getStartedLogin": "Entra no OpenStreetMap para comezar", | ||||
|         "getStartedNewAccount": " ou <a href='https://www.openstreetmap.org/user/new' target='_blank'>crea unha nova conta</a>", | ||||
|         "goToInbox": "Abrir mensaxes", | ||||
|         "index": { | ||||
|             "intro": "O MapComplete é un visor e editor do OpenStreetMap, que te amosa información sobre un tema específico.", | ||||
|             "pickTheme": "Escolle un tema para comezar.", | ||||
|             "title": "Benvido ao MapComplete" | ||||
|         }, | ||||
|         "layerSelection": { | ||||
|             "title": "Seleccionar capas", | ||||
|             "zoomInToSeeThisLayer": "Achégate para ver esta capa" | ||||
|  |  | |||
|  | @ -267,8 +267,12 @@ | |||
|         "willBePublished": "A képed így lesz közzétéve: " | ||||
|     }, | ||||
|     "importHelper": { | ||||
|         "allAttributesSame": "Ez a címke minden importálandó objektumon szerepel", | ||||
|         "description": "Az importálási segédprogram egy külső adatkészletet konvertál OSM-jegyzetekké. A külső adatkészletnek meg kell felelnie a MapComplete egyik meglévő rétegének. Az importálóba helyezett minden egyes elemhez egyetlen jegyzet fog létrejönni. Ezek a jegyzetek a megfelelő objektumokkal együtt fognak megjelenni ezeken a térképekben, hogy könnyen fel lehessen rajzolni őket a térképre." | ||||
|         "introduction": { | ||||
|             "description": "Az importálási segédprogram egy külső adatkészletet konvertál OSM-jegyzetekké. A külső adatkészletnek meg kell felelnie a MapComplete egyik meglévő rétegének. Az importálóba helyezett minden egyes elemhez egyetlen jegyzet fog létrejönni. Ezek a jegyzetek a megfelelő objektumokkal együtt fognak megjelenni ezeken a térképekben, hogy könnyen fel lehessen rajzolni őket a térképre." | ||||
|         }, | ||||
|         "previewAttributes": { | ||||
|             "allAttributesSame": "Ez a címke minden importálandó objektumon szerepel" | ||||
|         } | ||||
|     }, | ||||
|     "index": { | ||||
|         "#": "Ezek a szövegek akkor jelennek meg a témagombok felett, ha nincs betöltve téma", | ||||
|  |  | |||
|  | @ -97,12 +97,6 @@ | |||
|         "backToMapcomplete": "Terug naar het themaoverzicht", | ||||
|         "backgroundMap": "Achtergrondkaart", | ||||
|         "cancel": "Annuleren", | ||||
|         "centerMessage": { | ||||
|             "loadingData": "Data wordt geladen…", | ||||
|             "ready": "Klaar!", | ||||
|             "retrying": "Data inladen mislukt. Opnieuw proberen over {count} seconden…", | ||||
|             "zoomIn": "Zoom in om de data te zien en te bewerken" | ||||
|         }, | ||||
|         "confirm": "Bevestigen", | ||||
|         "customThemeIntro": "<h3>Onofficiële thema's</h3>De onderstaande thema's heb je eerder bezocht en zijn gemaakt door andere OpenStreetMappers.", | ||||
|         "download": { | ||||
|  | @ -134,12 +128,6 @@ | |||
|         "histogram": { | ||||
|             "error_loading": "Kan het histogram niet laden" | ||||
|         }, | ||||
|         "index": { | ||||
|             "#": "Deze teksten worden getoond boven de themaknoppen als er geen thema is geladen", | ||||
|             "intro": "MapComplete is een OpenStreetMap applicatie waar informatie over een specifiek thema bekeken en aangepast kan worden.", | ||||
|             "pickTheme": "Kies hieronder een thema om te beginnen.", | ||||
|             "title": "Welkom bij MapComplete" | ||||
|         }, | ||||
|         "layerSelection": { | ||||
|             "title": "Selecteer lagen", | ||||
|             "zoomInToSeeThisLayer": "Vergroot de kaart om deze laag te zien" | ||||
|  | @ -202,22 +190,6 @@ | |||
|         "readYourMessages": "Gelieve eerst je berichten op OpenStreetMap te lezen alvorens nieuwe punten toe te voegen.", | ||||
|         "removeLocationHistory": "Verwijder de geschiedenis aan locaties", | ||||
|         "returnToTheMap": "Ga terug naar de kaart", | ||||
|         "reviews": { | ||||
|             "affiliated_reviewer_warning": "(Review door betrokkene)", | ||||
|             "attribution": "De beoordelingen worden voorzien door <a href=\"https://mangrove.reviews/\" target=\"_blank\">Mangrove Reviews</a> en zijn beschikbaar onder de<a href=\"https://mangrove.reviews/terms#8-licensing-of-content\" target=\"_blank\">CC-BY 4.0-licentie</a>.", | ||||
|             "i_am_affiliated": "<span>Ik ben persoonlijk betrokken</span><br/><span class='subtle'>Vink aan indien je de oprichter, maker, werknemer, ... of dergelijke bent</span>", | ||||
|             "name_required": "De naam van dit object moet gekend zijn om een review te kunnen maken", | ||||
|             "no_rating": "Geen score bekend", | ||||
|             "no_reviews_yet": "Er zijn nog geen beoordelingen. Wees de eerste om een beoordeling te schrijven en help open data en het bedrijf!", | ||||
|             "plz_login": "Meld je aan om een beoordeling te geven", | ||||
|             "posting_as": "Ingelogd als", | ||||
|             "saved": "<span class='thanks'>Bedankt om je beoordeling te delen!</span>", | ||||
|             "saving_review": "Opslaan…", | ||||
|             "title": "{count} beoordelingen", | ||||
|             "title_singular": "Eén beoordeling", | ||||
|             "tos": "Als je je review publiceert, ga je akkoord met de <a href='https://mangrove.reviews/terms' target='_blank'>de gebruiksvoorwaarden en privacy policy van Mangrove.reviews</a>", | ||||
|             "write_a_comment": "Schrijf een beoordeling…" | ||||
|         }, | ||||
|         "save": "Opslaan", | ||||
|         "screenToSmall": "Open {theme} in een nieuw venster", | ||||
|         "search": { | ||||
|  | @ -304,17 +276,17 @@ | |||
|         "willBePublished": "Jouw foto wordt gepubliceerd " | ||||
|     }, | ||||
|     "importHelper": { | ||||
|         "allAttributesSame": "Alle kaart-objecten om te importeren hebben deze tag", | ||||
|         "description": "De importeer-helper converteert een externe dataset in OSM-kaartnotas. De externe data moet overeenkomen met een bestaande MapComplete-laag. Voor elk item wordt er een kaartnota gemaakt. Deze notas worden dan samen met de relevante POI getoond en kunnen dan (via MapComplete) snel en eenvoudig toegevoegd worden.", | ||||
|         "importFormat": "Een kaartnota moet het volgende formaat hebben om gedetecteerd te worden binnen een laag: <br><div class=\"literal-code\">[Een introductietekst]<br>https://mapcomplete.osm.be/[themename].html?[parameters waaronder lon en lat]#import<br>[alle tags van het te importeren object] </div>", | ||||
|         "inspectDataTitle": "Bekijk de data van {count} te importeren objecten", | ||||
|         "inspectDidAutoDected": "Deze laag werd automatisch gekozen", | ||||
|         "inspectLooksCorrect": "Deze waardes zien er correct uit", | ||||
|         "lockNotice": "Deze pagina is afgeschermd. Je hebt minstens {importHelperUnlock} changesets nodig voor je deze pagina mag gebruiken.", | ||||
|         "locked": "Je hebt minstens {importHelperUnlock} changesets nodig om de import helper te gebruiken", | ||||
|         "loggedInWith": "Je bent momenteel aangemeld als <b>{name}</b> and maakte {csCount} eerdere wijzigingen", | ||||
|         "loginIsCorrect": "<b>{name}</b> is de correcte account om de import-nota's mee te maken.", | ||||
|         "loginRequired": "Je moet ingelogd zijn om verder te gaan", | ||||
|         "introduction": { | ||||
|             "description": "De importeer-helper converteert een externe dataset in OSM-kaartnotas. De externe data moet overeenkomen met een bestaande MapComplete-laag. Voor elk item wordt er een kaartnota gemaakt. Deze notas worden dan samen met de relevante POI getoond en kunnen dan (via MapComplete) snel en eenvoudig toegevoegd worden.", | ||||
|             "importFormat": "Een kaartnota moet het volgende formaat hebben om gedetecteerd te worden binnen een laag: <br><div class=\"literal-code\">[Een introductietekst]<br>https://mapcomplete.osm.be/[themename].html?[parameters waaronder lon en lat]#import<br>[alle tags van het te importeren object] </div>" | ||||
|         }, | ||||
|         "login": { | ||||
|             "lockNotice": "Deze pagina is afgeschermd. Je hebt minstens {importHelperUnlock} changesets nodig voor je deze pagina mag gebruiken.", | ||||
|             "loggedInWith": "Je bent momenteel aangemeld als <b>{name}</b> and maakte {csCount} eerdere wijzigingen", | ||||
|             "loginIsCorrect": "<b>{name}</b> is de correcte account om de import-nota's mee te maken.", | ||||
|             "loginRequired": "Je moet ingelogd zijn om verder te gaan", | ||||
|             "userAccountTitle": "Selecteer een account" | ||||
|         }, | ||||
|         "mapPreview": { | ||||
|             "autodetected": "Deze laag was automatisch gekozen gebaseerd op de aanwezige eigenschappen", | ||||
|             "confirm": "De objecten bevinden zich op de juiste locatie", | ||||
|  | @ -322,6 +294,12 @@ | |||
|             "selectLayer": "Met welke laag komt deze te importeren dataset overeen?", | ||||
|             "title": "Voorbeeldkaart" | ||||
|         }, | ||||
|         "previewAttributes": { | ||||
|             "allAttributesSame": "Alle kaart-objecten om te importeren hebben deze tag", | ||||
|             "inspectDataTitle": "Bekijk de data van {count} te importeren objecten", | ||||
|             "inspectLooksCorrect": "Deze waardes zien er correct uit", | ||||
|             "someHaveSame": "{count} te importeren objecten hebben dit attribuut, dit is {percentage}% van het totaal" | ||||
|         }, | ||||
|         "selectFile": { | ||||
|             "description": "Selecteer een .csv of .geojson-bestand", | ||||
|             "errDuplicate": "Sommige kolommen hebben dezelfde naam", | ||||
|  | @ -336,11 +314,7 @@ | |||
|             "noFilesLoaded": "Geen bestand ingeladen op dit moment", | ||||
|             "title": "Selecteer bestand" | ||||
|         }, | ||||
|         "selectLayer": "Selecteer een laag...", | ||||
|         "someHaveSame": "{count} te importeren objecten hebben dit attribuut, dit is {percentage}% van het totaal", | ||||
|         "title": "Importeer-helper", | ||||
|         "userAccountTitle": "Selecteer een account", | ||||
|         "validateDataTitle": "Valideer data" | ||||
|         "title": "Importeer-helper" | ||||
|     }, | ||||
|     "importInspector": { | ||||
|         "title": "Inspecteer en beheer importeer-notas" | ||||
|  |  | |||
							
								
								
									
										112
									
								
								langs/pl.json
									
										
									
									
									
								
							
							
						
						
									
										112
									
								
								langs/pl.json
									
										
									
									
									
								
							|  | @ -35,121 +35,9 @@ | |||
|         "cancel": "Anuluj", | ||||
|         "customThemeIntro": "<h3>Motywy własne</h3>Są to wcześniej odwiedzone motywy stworzone przez użytkowników.", | ||||
|         "fewChangesBefore": "Proszę odpowiedzieć na kilka pytań dotyczących istniejących punktów przed dodaniem nowego punktu.", | ||||
|         "general": { | ||||
|             "about": "Łatwo edytuj i dodaj OpenStreetMap dla określonego motywu", | ||||
|             "aboutMapcomplete": "<h3>O MapComplete</h3><p>Dzięki MapComplete możesz wzbogacić OpenStreetMap o informacje na <b>pojedynczy temat.</b> Odpowiedz na kilka pytań, a w ciągu kilku minut Twój wkład będzie dostępny na całym świecie! Opiekun <b>tematu</b> definiuje elementy, pytania i języki dla tematu.</p><h3>Dowiedz się więcej</h3><p>MapComplete zawsze <b>oferuje następny krok</b>, by dowiedzieć się więcej o OpenStreetMap.</p><ul><li>Po osadzeniu na stronie internetowej, element iframe łączy się z pełnoekranowym MapComplete</li><li>Wersja pełnoekranowa oferuje informacje o OpenStreetMap</li><li>Przeglądanie działa bez logowania, ale edycja wymaga loginu OSM.</li><li>Jeżeli nie jesteś zalogowany, zostaniesz poproszony o zalogowanie się</li><li>Po udzieleniu odpowiedzi na jedno pytanie, możesz dodać nowe punkty do mapy</li><li>Po chwili wyświetlane są rzeczywiste tagi OSM, które później linkują do wiki</li></ul><p></p><br><p>Zauważyłeś <b>problem</b>? Czy masz <b>prośbę o dodanie jakiejś funkcji</b>? Chcesz <b>pomóc w tłumaczeniu</b>? Udaj się do <a href=\"https://github.com/pietervdvn/MapComplete\" target=\"_blank\">kodu źródłowego</a> lub <a href=\"https://github.com/pietervdvn/MapComplete/issues\" target=\"_blank\">issue trackera.</a> </p><p> Chcesz zobaczyć <b>swoje postępy</b>? Śledź liczbę edycji na <a href=\"{osmcha_link}\" target=\"_blank\">OsmCha</a>.</p>", | ||||
|             "add": { | ||||
|                 "addNew": "Dodaj nową {category} tutaj", | ||||
|                 "confirmButton": "Dodaj tutaj {category}.<br><div class=\"alert\">Twój dodatek jest widoczny dla wszystkich</div>", | ||||
|                 "confirmIntro": "<h3>Czy dodać tutaj {title}?</h3> Punkt, który tutaj utworzysz, będzie <b>widoczny dla wszystkich</b>. Proszę, dodawaj rzeczy do mapy tylko wtedy, gdy naprawdę istnieją. Wiele aplikacji korzysta z tych danych.", | ||||
|                 "intro": "Kliknąłeś gdzieś, gdzie nie są jeszcze znane żadne dane.<br>", | ||||
|                 "layerNotEnabled": "Warstwa {layer} nie jest włączona. Włącz tę warstwę, aby dodać punkt", | ||||
|                 "openLayerControl": "Otwórz okno sterowania warstwą", | ||||
|                 "pleaseLogin": "<a class=\"activate-osm-authentication\">Zaloguj się, aby dodać nowy punkt</a>", | ||||
|                 "stillLoading": "Dane wciąż się ładują. Poczekaj chwilę, zanim dodasz nowy punkt.", | ||||
|                 "title": "Czy dodać nowy punkt?", | ||||
|                 "zoomInFurther": "Powiększ jeszcze bardziej, aby dodać punkt." | ||||
|             }, | ||||
|             "backgroundMap": "Tło mapy", | ||||
|             "cancel": "Anuluj", | ||||
|             "customThemeIntro": "<h3>Motywy własne</h3>Są to wcześniej odwiedzone motywy stworzone przez użytkowników.", | ||||
|             "fewChangesBefore": "Proszę odpowiedzieć na kilka pytań dotyczących istniejących punktów przed dodaniem nowego punktu.", | ||||
|             "getStartedLogin": "Zaloguj się za pomocą OpenStreetMap, aby rozpocząć", | ||||
|             "getStartedNewAccount": " lub <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">utwórz nowe konto</a>", | ||||
|             "goToInbox": "Otwórz skrzynkę odbiorczą", | ||||
|             "layerSelection": { | ||||
|                 "title": "Wybierz warstwy", | ||||
|                 "zoomInToSeeThisLayer": "Powiększ, aby zobaczyć tę warstwę" | ||||
|             }, | ||||
|             "loginToStart": "Zaloguj się, aby odpowiedzieć na to pytanie", | ||||
|             "loginWithOpenStreetMap": "Zaloguj z OpenStreetMap", | ||||
|             "nameInlineQuestion": "Nazwa tej {category} to $$$", | ||||
|             "noNameCategory": "{category} bez nazwy", | ||||
|             "noTagsSelected": "Nie wybrano tagów", | ||||
|             "number": "numer", | ||||
|             "oneSkippedQuestion": "Jedno pytanie zostało pominięte", | ||||
|             "opening_hours": { | ||||
|                 "closed_permanently": "Zamknięte na nieokreślony czas", | ||||
|                 "closed_until": "Zamknięte do {date}", | ||||
|                 "error_loading": "Błąd: nie można zwizualizować tych godzin otwarcia.", | ||||
|                 "not_all_rules_parsed": "Godziny otwarcia tego sklepu są skomplikowane. Następujące reguły są ignorowane w elemencie wejściowym:", | ||||
|                 "openTill": "do", | ||||
|                 "open_24_7": "Otwarte przez całą dobę", | ||||
|                 "open_during_ph": "W czasie świąt państwowych udogodnienie to jest", | ||||
|                 "opensAt": "z", | ||||
|                 "ph_closed": "zamknięte", | ||||
|                 "ph_not_known": " ", | ||||
|                 "ph_open": "otwarte" | ||||
|             }, | ||||
|             "osmLinkTooltip": "Zobacz ten obiekt na OpenStreetMap, aby uzyskać historię i więcej opcji edycji", | ||||
|             "pickLanguage": "Wybierz język: ", | ||||
|             "questions": { | ||||
|                 "emailIs": "Adres e-mail {category} to <a href=\"mailto:{email}\" target=\"_blank\">{email}</a>", | ||||
|                 "emailOf": "Jaki jest adres e-mail {category}?", | ||||
|                 "phoneNumberIs": "Numer telefonu {category} to <a target=\"_blank\">{phone}</a>", | ||||
|                 "phoneNumberOf": "Jaki jest numer telefonu do {category}?", | ||||
|                 "websiteIs": "Strona internetowa: <a href=\"{website}\" target=\"_blank\">{website}</a>", | ||||
|                 "websiteOf": "Jaka jest strona internetowa {category}?" | ||||
|             }, | ||||
|             "readYourMessages": "Przeczytaj wszystkie wiadomości OpenStreetMap przed dodaniem nowego punktu.", | ||||
|             "returnToTheMap": "Wróć do mapy", | ||||
|             "save": "Zapisz", | ||||
|             "search": { | ||||
|                 "error": "Coś poszło nie tak…", | ||||
|                 "nothing": "Nic nie znaleziono…", | ||||
|                 "search": "Wyszukaj lokalizację", | ||||
|                 "searching": "Szukanie…" | ||||
|             }, | ||||
|             "sharescreen": { | ||||
|                 "addToHomeScreen": "<h3> Dodaj do ekranu głównego</h3>Możesz łatwo dodać tę stronę do ekranu głównego smartfona, aby poczuć się jak w domu. Kliknij przycisk \"Dodaj do ekranu głównego\" na pasku adresu URL, aby to zrobić.", | ||||
|                 "copiedToClipboard": "Link został skopiowany do schowka", | ||||
|                 "editThemeDescription": "Dodaj lub zmień pytania do tego motywu mapy", | ||||
|                 "editThisTheme": "Edytuj ten motyw", | ||||
|                 "embedIntro": "<h3>Umieść na swojej stronie internetowej</h3>Proszę, umieść tę mapę na swojej stronie internetowej. <br>Zachęcamy cię do tego - nie musisz nawet pytać o zgodę. <br>Jest ona darmowa i zawsze będzie. Im więcej osób jej używa, tym bardziej staje się wartościowa.", | ||||
|                 "fsAddNew": "Włącz przycisk \"Dodaj nowe POI\"", | ||||
|                 "fsGeolocation": "Włącz przycisk „Zlokalizuj mnie” (tylko na urządzeniach mobilnych)", | ||||
|                 "fsIncludeCurrentBackgroundMap": "Dołącz bieżący wybór tła <b>{name}</b>", | ||||
|                 "fsIncludeCurrentLayers": "Uwzględnij wybór bieżącej warstwy", | ||||
|                 "fsIncludeCurrentLocation": "Uwzględnij bieżącą lokalizację", | ||||
|                 "fsLayerControlToggle": "Zacznij od rozwiniętej kontroli warstw", | ||||
|                 "fsLayers": "Włącz kontrolę warstw", | ||||
|                 "fsSearch": "Włącz pasek wyszukiwania", | ||||
|                 "fsUserbadge": "Włącz przycisk logowania", | ||||
|                 "fsWelcomeMessage": "Pokaż wyskakujące okienko wiadomości powitalnej i powiązane zakładki", | ||||
|                 "intro": "<h3> Udostępnij tę mapę</h3> Udostępnij tę mapę, kopiując poniższy link i wysyłając ją do przyjaciół i rodziny:", | ||||
|                 "thanksForSharing": "Dzięki za udostępnienie!" | ||||
|             }, | ||||
|             "skip": "Pomiń to pytanie", | ||||
|             "skippedQuestions": "Niektóre pytania są pominięte", | ||||
|             "weekdays": { | ||||
|                 "abbreviations": { | ||||
|                     "friday": "Pt", | ||||
|                     "monday": "Pn", | ||||
|                     "saturday": "Sob", | ||||
|                     "sunday": "Niedz", | ||||
|                     "thursday": "Czw", | ||||
|                     "tuesday": "Wt", | ||||
|                     "wednesday": "Śr" | ||||
|                 }, | ||||
|                 "friday": "Piątek", | ||||
|                 "monday": "Poniedziałek", | ||||
|                 "saturday": "Sobota", | ||||
|                 "sunday": "Niedziela", | ||||
|                 "thursday": "Czwartek", | ||||
|                 "tuesday": "Wtorek", | ||||
|                 "wednesday": "Środa" | ||||
|             }, | ||||
|             "welcomeBack": "Jesteś zalogowany, witaj z powrotem!" | ||||
|         }, | ||||
|         "getStartedLogin": "Zaloguj się za pomocą OpenStreetMap, aby rozpocząć", | ||||
|         "getStartedNewAccount": " lub <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">utwórz nowe konto</a>", | ||||
|         "goToInbox": "Otwórz skrzynkę odbiorczą", | ||||
|         "index": { | ||||
|             "#": "Te teksty są wyświetlane nad przyciskami motywu, gdy nie jest załadowany żaden motyw", | ||||
|             "intro": "MapComplete to przeglądarka i edytor OpenStreetMap, który pokazuje informacje podzielone według tematu.", | ||||
|             "pickTheme": "Wybierz temat poniżej, aby rozpocząć.", | ||||
|             "title": "Witaj w MapComplete" | ||||
|         }, | ||||
|         "layerSelection": { | ||||
|             "title": "Wybierz warstwy", | ||||
|             "zoomInToSeeThisLayer": "Powiększ, aby zobaczyć tę warstwę" | ||||
|  |  | |||
|  | @ -90,130 +90,6 @@ | |||
|             "title": "下載可視的資料" | ||||
|         }, | ||||
|         "fewChangesBefore": "請先回答有關既有節點的問題再來新增新節點。", | ||||
|         "general": { | ||||
|             "about": "相當容易編輯,而且能為開放街圖新增特定主題", | ||||
|             "aboutMapcomplete": "<h3>關於 MapComplete</h3><p>使用 MapComplete 你可以藉由<b>單一主題</b>豐富開放街圖的圖資。回答幾個問題,然後幾分鐘之內你的貢獻立刻就傳遍全球!<b>主題維護者</b>定議主題的元素、問題與語言。</p><h3>發現更多</h3><p>MapComplete 總是提供學習更多開放街圖<b>下一步的知識</b>。</p><ul><li>當你內嵌網站,網頁內嵌會連結到全螢幕的 MapComplete</li><li>全螢幕的版本提供關於開放街圖的資訊</li><li>不登入檢視成果,但是要編輯則需登入 OSM。</li><li>如果你沒有登入,你會被要求先登入</li><li>當你回答單一問題時,你可以在地圖新增新的節點</li><li>過了一陣子,實際的 OSM-標籤會顯示,之後會連結到 wiki</li></ul><p></p><br><p>你有注意到<b>問題</b>嗎?你想請求<b>功能</b>嗎?想要<b>幫忙翻譯</b>嗎?來到<a href=\"https://github.com/pietervdvn/MapComplete\" target=\"_blank\">原始碼</a>或是<a href=\"https://github.com/pietervdvn/MapComplete/issues\" target=\"_blank\">問題追蹤器。</a></p><p>想要看到<b>你的進度</b>嗎?到<a href=\"{osmcha_link}\" target=\"_blank\">OsmCha</a>追蹤編輯數。</p>", | ||||
|             "add": { | ||||
|                 "addNew": "在這裡新增新的 {category}", | ||||
|                 "confirmButton": "在此新增 {category}。<br><div class=\"alert\">大家都可以看到您新增的內容</div>", | ||||
|                 "confirmIntro": "<h3>在這裡新增 {title} ?</h3>你在這裡新增的節點<b>所有人都看得到</b>。請只有在確定有物件存在的情形下才新增上去,許多應用程式都使用這份資料。", | ||||
|                 "intro": "您點擊處目前未有已知的資料。<br>", | ||||
|                 "layerNotEnabled": "圖層 {layer} 目前無法使用,請先啟用這圖層再加新的節點", | ||||
|                 "openLayerControl": "開啟圖層控制框", | ||||
|                 "pleaseLogin": "<a class=\"activate-osm-authentication\">請先登入來新增節點</a>", | ||||
|                 "stillLoading": "目前仍在載入資料,請稍後再來新增節點。", | ||||
|                 "title": "新增新的節點?", | ||||
|                 "zoomInFurther": "放大來新增新的節點。" | ||||
|             }, | ||||
|             "attribution": { | ||||
|                 "attributionContent": "<p>所有資料由<a href=\"https://osm.org\" target=\"_blank\">開放街圖</a>提供,在<a href=\"https://osm.org/copyright\" target=\"_blank\">開放資料庫授權條款</a>之下自由再利用。</p>", | ||||
|                 "attributionTitle": "署名通知", | ||||
|                 "codeContributionsBy": "MapComplete 是由 {contributors} 和其他 <a href=\"https://github.com/pietervdvn/MapComplete/graphs/contributors\" target=\"_blank\">{hiddenCount} 位貢獻者</a>構建而成", | ||||
|                 "iconAttribution": { | ||||
|                     "title": "使用的圖示" | ||||
|                 }, | ||||
|                 "mapContributionsBy": "目前檢視的資料由 {contributors} 貢獻編輯", | ||||
|                 "mapContributionsByAndHidden": "目前顯到的資料是由 {contributors} 和其他 {hiddenCount} 位貢獻者編輯貢獻", | ||||
|                 "themeBy": "由 {author} 維護主題" | ||||
|             }, | ||||
|             "backgroundMap": "背景地圖", | ||||
|             "cancel": "取消", | ||||
|             "customThemeIntro": "<h3>客製化主題</h3>觀看這些先前使用者創造的主題。", | ||||
|             "fewChangesBefore": "請先回答有關既有節點的問題再來新增新節點。", | ||||
|             "getStartedLogin": "登入開放街圖帳號來開始", | ||||
|             "getStartedNewAccount": " 或是 <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">註冊新帳號</a>", | ||||
|             "goToInbox": "開啟訊息框", | ||||
|             "layerSelection": { | ||||
|                 "title": "選擇圖層", | ||||
|                 "zoomInToSeeThisLayer": "放大來看這個圖層" | ||||
|             }, | ||||
|             "loginToStart": "登入之後來回答這問題", | ||||
|             "loginWithOpenStreetMap": "用開放街圖帳號登入", | ||||
|             "morescreen": { | ||||
|                 "createYourOwnTheme": "從零開始建立你的 MapComplete 主題", | ||||
|                 "intro": "<h3>看更多主題地圖?</h3>您喜歡蒐集地理資料嗎?<br>還有更多主題。", | ||||
|                 "requestATheme": "如果你有客製化要求,請到問題追踪器那邊提出要求", | ||||
|                 "streetcomplete": "行動裝置另有類似的應用程式 <a class=\"underline hover:text-blue-800\" href=\"https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete\" target=\"_blank\">StreetComplete</a>。" | ||||
|             }, | ||||
|             "nameInlineQuestion": "這個 {category} 的名稱是 $$$", | ||||
|             "noNameCategory": "{category} 沒有名稱", | ||||
|             "noTagsSelected": "沒有選取標籤", | ||||
|             "number": "號碼", | ||||
|             "oneSkippedQuestion": "跳過一個問題", | ||||
|             "openStreetMapIntro": "<h3>開放的地圖</h3><p>如果有一份地圖,任何人都能自由使用與編輯,單一的地圖能夠儲存所有地理相關資訊?這樣不就很酷嗎?接著,所有的網站使用不同的、範圍小的,不相容的地圖 (通常也都過時了),也就不再需要了。</p><p><b><a href=\"https://OpenStreetMap.org\" target=\"_blank\">開放街圖</a></b>就是這樣的地圖,人人都能免費這些圖資 (只要<a href=\"https://osm.org/copyright\" target=\"_blank\">署名與公開變動這資料</a>)。只要遵循這些,任何人都能自由新增新資料與修正錯誤,這些網站也都使用開放街圖,資料也都來自開放街圖,你的答案與修正也會加到開放街圖上面。</p><p>許多人與應用程式已經採用開放街圖了:<a href=\"https://organicmaps.app//\" target=\"_blank\">Organic Maps</a>、<a href=\"https://osmAnd.net\" target=\"_blank\">OsmAnd</a>,還有 Facebook、Instagram,蘋果地圖、Bing 地圖(部分)採用開放街圖。如果你在開放街圖上變動資料,也會同時影響這些應用 - 在他們下次更新資料之後!</p>", | ||||
|             "opening_hours": { | ||||
|                 "closed_permanently": "不清楚關閉多久了", | ||||
|                 "closed_until": "{date} 起關閉", | ||||
|                 "error_loading": "錯誤:無法視覺化開放時間。", | ||||
|                 "not_all_rules_parsed": "這間店的開放時間相當複雜,在輸入元素時忽略接下來的規則:", | ||||
|                 "openTill": "結束時間", | ||||
|                 "open_24_7": "24小時營業", | ||||
|                 "open_during_ph": "國定假日的時候,這個場所是", | ||||
|                 "opensAt": "開始時間", | ||||
|                 "ph_closed": "無營業", | ||||
|                 "ph_not_known": " ", | ||||
|                 "ph_open": "有營業" | ||||
|             }, | ||||
|             "osmLinkTooltip": "在開放街圖歷史和更多編輯選項下面來檢視這物件", | ||||
|             "pickLanguage": "選擇語言: ", | ||||
|             "questions": { | ||||
|                 "emailIs": "{category} 的電子郵件地址是<a href=\"mailto:{email}\" target=\"_blank\">{email}</a>", | ||||
|                 "emailOf": "{category} 的電子郵件地址是?", | ||||
|                 "phoneNumberIs": "此 {category} 的電話號碼為 <a target=\"_blank\">{phone}</a>", | ||||
|                 "phoneNumberOf": "{category} 的電話號碼是?", | ||||
|                 "websiteIs": "網站:<a href=\"{website}\" target=\"_blank\">{website}</a>", | ||||
|                 "websiteOf": "{category} 的網站網址是?" | ||||
|             }, | ||||
|             "readYourMessages": "請先閱讀開放街圖訊息之前再來新增新節點。", | ||||
|             "returnToTheMap": "回到地圖", | ||||
|             "save": "儲存", | ||||
|             "search": { | ||||
|                 "error": "有狀況發生了…", | ||||
|                 "nothing": "沒有找到…", | ||||
|                 "search": "搜尋地點", | ||||
|                 "searching": "搜尋中…" | ||||
|             }, | ||||
|             "sharescreen": { | ||||
|                 "addToHomeScreen": "<h3>新增到您的主畫面</h3>您可以輕易將這網站新增到您智慧型手機的主畫面,在網址列點選「新增到主畫面按鈕」來做這件事情。", | ||||
|                 "copiedToClipboard": "複製連結到簡貼簿", | ||||
|                 "editThemeDescription": "新增或改變這個地圖的問題", | ||||
|                 "editThisTheme": "編輯這個主題", | ||||
|                 "embedIntro": "<h3>嵌入到你的網站</h3>請考慮將這份地圖嵌入您的網站。<br>地圖毋須額外授權,非常歡迎您多加利用。<br>一切都是免費的,而且之後也是免費的,越有更多人使用,則越顯得它的價值。", | ||||
|                 "fsAddNew": "啟用'新增新的興趣點'按鈕", | ||||
|                 "fsGeolocation": "啟用'地理定位自身'按鈕 (只有行動版本)", | ||||
|                 "fsIncludeCurrentBackgroundMap": "包含目前背景選擇<b>{name}</b>", | ||||
|                 "fsIncludeCurrentLayers": "包含目前選擇圖層", | ||||
|                 "fsIncludeCurrentLocation": "包含目前位置", | ||||
|                 "fsLayerControlToggle": "開始時擴展圖層控制", | ||||
|                 "fsLayers": "啟用圖層控制", | ||||
|                 "fsSearch": "啟用搜尋列", | ||||
|                 "fsUserbadge": "啟用登入按鈕", | ||||
|                 "fsWelcomeMessage": "顯示歡迎訊息以及相關頁籤", | ||||
|                 "intro": "<h3>分享這地圖</h3>複製下面的連結來向朋友與家人分享這份地圖:", | ||||
|                 "thanksForSharing": "感謝分享!" | ||||
|             }, | ||||
|             "skip": "跳過這問題", | ||||
|             "skippedQuestions": "有些問題已經跳過了", | ||||
|             "weekdays": { | ||||
|                 "abbreviations": { | ||||
|                     "friday": "星期五", | ||||
|                     "monday": "星期一", | ||||
|                     "saturday": "星期六", | ||||
|                     "sunday": "星期日", | ||||
|                     "thursday": "星期四", | ||||
|                     "tuesday": "星期二", | ||||
|                     "wednesday": "星期三" | ||||
|                 }, | ||||
|                 "friday": "星期五", | ||||
|                 "monday": "星期一", | ||||
|                 "saturday": "星期六", | ||||
|                 "sunday": "星期日", | ||||
|                 "thursday": "星期四", | ||||
|                 "tuesday": "星期二", | ||||
|                 "wednesday": "星期三" | ||||
|             }, | ||||
|             "welcomeBack": "你已經登入了,歡迎回來!" | ||||
|         }, | ||||
|         "getStartedLogin": "登入開放街圖帳號來開始", | ||||
|         "getStartedNewAccount": " 或是 <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">註冊新帳號</a>", | ||||
|         "goToInbox": "開啟訊息框", | ||||
|  |  | |||
							
								
								
									
										14
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							|  | @ -43,7 +43,7 @@ | |||
|         "libphonenumber-js": "^1.7.55", | ||||
|         "lz-string": "^1.4.4", | ||||
|         "mangrove-reviews": "^0.1.3", | ||||
|         "moment": "^2.29.0", | ||||
|         "moment": "^2.29.2", | ||||
|         "opening_hours": "^3.6.0", | ||||
|         "osm-auth": "^1.0.2", | ||||
|         "osmtogeojson": "^3.0.0-beta.4", | ||||
|  | @ -9738,9 +9738,9 @@ | |||
|       } | ||||
|     }, | ||||
|     "node_modules/moment": { | ||||
|       "version": "2.29.1", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", | ||||
|       "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", | ||||
|       "version": "2.29.2", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", | ||||
|       "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==", | ||||
|       "engines": { | ||||
|         "node": "*" | ||||
|       } | ||||
|  | @ -24294,9 +24294,9 @@ | |||
|       "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==" | ||||
|     }, | ||||
|     "moment": { | ||||
|       "version": "2.29.1", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", | ||||
|       "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" | ||||
|       "version": "2.29.2", | ||||
|       "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz", | ||||
|       "integrity": "sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==" | ||||
|     }, | ||||
|     "monotone-convex-hull-2d": { | ||||
|       "version": "1.0.1", | ||||
|  |  | |||
|  | @ -89,7 +89,7 @@ | |||
|     "libphonenumber-js": "^1.7.55", | ||||
|     "lz-string": "^1.4.4", | ||||
|     "mangrove-reviews": "^0.1.3", | ||||
|     "moment": "^2.29.0", | ||||
|     "moment": "^2.29.2", | ||||
|     "opening_hours": "^3.6.0", | ||||
|     "osm-auth": "^1.0.2", | ||||
|     "osmtogeojson": "^3.0.0-beta.4", | ||||
|  |  | |||
							
								
								
									
										61
									
								
								scripts/automoveTranslations.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								scripts/automoveTranslations.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| import * as languages from "../assets/generated/used_languages.json" | ||||
| import {readFileSync, writeFileSync} from "fs"; | ||||
| 
 | ||||
| /** | ||||
|  * Moves values around in 'section'. Section will be changed | ||||
|  * @param section | ||||
|  * @param referenceSection | ||||
|  * @param language | ||||
|  */ | ||||
| function fixSection(section, referenceSection, language: string) { | ||||
|     if(section === undefined){ | ||||
|         return | ||||
|     } | ||||
|     outer: for (const key of Object.keys(section)) { | ||||
|         const v = section[key] | ||||
|         if(typeof v ==="string" && referenceSection[key] === undefined){ | ||||
|             // Not found in reference, search for a subsection with this key
 | ||||
|             for (const subkey of Object.keys(referenceSection)) { | ||||
|                 const subreference = referenceSection[subkey] | ||||
|                 if(subreference[key] !== undefined){ | ||||
|                     if(section[subkey] !== undefined &&  section[subkey][key] !== undefined) { | ||||
|                         console.log(`${subkey}${key} is already defined... Looking furhter`) | ||||
|                         continue | ||||
|                     } | ||||
|                     if(typeof section[subkey] === "string"){ | ||||
|                         console.log(`NOT overwriting '${section[subkey]}' for ${subkey} (needed for ${key})`) | ||||
|                     }else{ | ||||
|                         // apply fix
 | ||||
|                         if(section[subkey] === undefined){ | ||||
|                             section[subkey] = {} | ||||
|                         } | ||||
|                         section[subkey][key] = section[key] | ||||
|                         delete  section[key] | ||||
|                         console.log(`Rewritten key: ${key} --> ${subkey}.${key} in language ${language}`) | ||||
|                         continue outer | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             console.log("No solution found for "+key) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| function main(args:string[]):void{ | ||||
|     const sectionName = args[0] | ||||
|     const l = args[1] | ||||
|     if(sectionName === undefined){ | ||||
|         console.log("Tries to automatically move translations to a new subsegment. Usage: 'sectionToCheck' 'language'") | ||||
|         return | ||||
|     } | ||||
|     const reference = JSON.parse( readFileSync("./langs/en.json","UTF8")) | ||||
|     const path = `./langs/${l}.json` | ||||
|     const file = JSON.parse( readFileSync(path,"UTF8")) | ||||
|     fixSection(file[sectionName], reference[sectionName], l) | ||||
|     writeFileSync(path, JSON.stringify(file, null, "    ")+"\n") | ||||
|      | ||||
|      | ||||
| } | ||||
| 
 | ||||
| main(process.argv.slice(2)) | ||||
|  | @ -297,7 +297,20 @@ function transformTranslation(obj: any, path: string[] = [], languageWhitelist : | |||
|                 } | ||||
|                 value = nv; | ||||
|             } | ||||
|             values += `${Utils.Times((_) => "  ", path.length + 1)}get ${key}() { return new Translation(${JSON.stringify(value)}, "core:${path.join(".")}.${key}") },
 | ||||
|              | ||||
| 
 | ||||
|             if(value["en"] === undefined){ | ||||
|                 throw `At ${path.join(".")}: Missing 'en' translation at path ${path.join(".")}.${key}\n\tThe translations in other languages are ${JSON.stringify(value)}` | ||||
|             } | ||||
|             const subParts : string[] = value["en"].match(/{[^}]*}/g) | ||||
|             let expr = `return new Translation(${JSON.stringify(value)}, "core:${path.join(".")}.${key}")` | ||||
|             if(subParts !== null){ | ||||
|                 // convert '{to_substitute}' into 'to_substitute'
 | ||||
|                 const types = Utils.Dedup( subParts.map(tp => tp.substring(1, tp.length - 1))) | ||||
|                 expr = `return new TypedTranslation<{ ${types.join(", ")} }>(${JSON.stringify(value)}, "core:${path.join(".")}.${key}")` | ||||
|             } | ||||
|              | ||||
|             values += `${Utils.Times((_) => "  ", path.length + 1)}get ${key}() { ${expr} },
 | ||||
| ` | ||||
|         } else { | ||||
|             values += (Utils.Times((_) => "  ", path.length + 1)) + key + ": " + transformTranslation(value, [...path, key], languageWhitelist) + ",\n" | ||||
|  | @ -340,7 +353,7 @@ function genTranslations() { | |||
|     const translations = JSON.parse(fs.readFileSync("./assets/generated/translations.json", "utf-8")) | ||||
|     const transformed =  transformTranslation(translations); | ||||
| 
 | ||||
|     let module = `import {Translation} from "../../UI/i18n/Translation"\n\nexport default class CompiledTranslations {\n\n`; | ||||
|     let module = `import {Translation, TypedTranslation} from "../../UI/i18n/Translation"\n\nexport default class CompiledTranslations {\n\n`; | ||||
|     module += " public static t = " + transformed; | ||||
|     module += "\n    }" | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										347
									
								
								test/Logic/Tags/OptimizeTags.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										347
									
								
								test/Logic/Tags/OptimizeTags.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,347 @@ | |||
| import {describe} from 'mocha' | ||||
| import {expect} from 'chai' | ||||
| import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; | ||||
| import {And} from "../../../Logic/Tags/And"; | ||||
| import {Tag} from "../../../Logic/Tags/Tag"; | ||||
| import {TagUtils} from "../../../Logic/Tags/TagUtils"; | ||||
| import {Or} from "../../../Logic/Tags/Or"; | ||||
| import {RegexTag} from "../../../Logic/Tags/RegexTag"; | ||||
| 
 | ||||
| describe("Tag optimalization", () => { | ||||
| 
 | ||||
|     describe("And", () => { | ||||
|         it("with condition and nested and should be flattened", () => { | ||||
|             const t = new And( | ||||
|                 [ | ||||
|                     new And([ | ||||
|                         new Tag("x", "y") | ||||
|                     ]), | ||||
|                     new Tag("a", "b") | ||||
|                 ] | ||||
|             ) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq(`a=b&x=y`) | ||||
|         }) | ||||
| 
 | ||||
|         it("should be 'true' if no conditions are given", () => { | ||||
|             const t = new And( | ||||
|                 [] | ||||
|             ) | ||||
|             const opt = t.optimize() | ||||
|             expect(opt).eq(true) | ||||
|         }) | ||||
| 
 | ||||
|         it("with nested ors and common property should be extracted", () => { | ||||
| 
 | ||||
|             // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d))
 | ||||
|             const t = new And([ | ||||
|                 new Tag("foo", "bar"), | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y"), | ||||
|                     new Tag("a", "b") | ||||
|                 ]), | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y"), | ||||
|                     new Tag("c", "d") | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") | ||||
|         }) | ||||
| 
 | ||||
|         it("with nested ors and common regextag should be extracted", () => { | ||||
| 
 | ||||
|             // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d))
 | ||||
|             const t = new And([ | ||||
|                 new Tag("foo", "bar"), | ||||
|                 new Or([ | ||||
|                     new RegexTag("x", "y"), | ||||
|                     new RegexTag("a", "b") | ||||
|                 ]), | ||||
|                 new Or([ | ||||
|                     new RegexTag("x", "y"), | ||||
|                     new RegexTag("c", "d") | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar& ( (a=b&c=d) |x=y)") | ||||
|         }) | ||||
| 
 | ||||
|         it("with nested ors and inverted regextags should _not_ be extracted", () => { | ||||
| 
 | ||||
|             // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d))
 | ||||
|             const t = new And([ | ||||
|                 new Tag("foo", "bar"), | ||||
|                 new Or([ | ||||
|                     new RegexTag("x", "y"), | ||||
|                     new RegexTag("a", "b") | ||||
|                 ]), | ||||
|                 new Or([ | ||||
|                     new RegexTag("x", "y", true), | ||||
|                     new RegexTag("c", "d") | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar& (a=b|x=y) & (c=d|x!=y)") | ||||
|         }) | ||||
| 
 | ||||
|         it("should move regextag to the end", () => { | ||||
|             const t = new And([ | ||||
|                 new RegexTag("x", "y"), | ||||
|                 new Tag("a", "b") | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("a=b&x=y") | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|         it("should sort tags by their popularity (least popular first)", () => { | ||||
|             const t = new And([ | ||||
|                 new Tag("bicycle", "yes"), | ||||
|                 new Tag("amenity", "binoculars") | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|         it("should optimize nested ORs", () => { | ||||
|             const filter = TagUtils.Tag({ | ||||
|                 or: [ | ||||
|                     "X=Y", "FOO=BAR", | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": ["X=Y", "FOO=BAR"] | ||||
|                             }, | ||||
|                             "bicycle=yes" | ||||
|                         ] | ||||
|                     } | ||||
|                 ] | ||||
|             }) | ||||
|             // (X=Y | FOO=BAR | (bicycle=yes & (X=Y | FOO=BAR)) )
 | ||||
|             // This is equivalent to (X=Y | FOO=BAR)
 | ||||
|             const opt = filter.optimize() | ||||
|             console.log(opt) | ||||
|         }) | ||||
| 
 | ||||
|         it("should optimize an advanced, real world case", () => { | ||||
|             const filter = TagUtils.Tag({ | ||||
|                 or: [ | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] | ||||
|                             }, | ||||
|                             "bicycle=yes" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] | ||||
|                             }, | ||||
|                         ] | ||||
|                     }, | ||||
|                     "amenity=toilets", | ||||
|                     "amenity=bench", | ||||
|                     "leisure=picnic_table", | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "tower:type=observation" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "amenity=bicycle_repair_station" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": [ | ||||
|                                     "amenity=bicycle_rental", | ||||
|                                     "bicycle_rental~*", | ||||
|                                     "service:bicycle:rental=yes", | ||||
|                                     "rental~.*bicycle.*" | ||||
|                                 ] | ||||
|                             }, | ||||
|                             "bicycle_rental!=docking_station" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "leisure=playground", | ||||
|                             "playground!=forest" | ||||
|                         ] | ||||
|                     } | ||||
|                 ] | ||||
|             }); | ||||
|             const opt = <TagsFilter>filter.optimize() | ||||
|             const expected = ["amenity=charging_station", | ||||
|                 "amenity=toilets", | ||||
|                 "amenity=bench", | ||||
|                 "amenity=bicycle_repair_station", | ||||
|                 "construction:amenity=charging_station", | ||||
|                 "disused:amenity=charging_station", | ||||
|                 "leisure=picnic_table", | ||||
|                 "planned:amenity=charging_station", | ||||
|                 "tower:type=observation", | ||||
|                 "(amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!=docking_station", | ||||
|                 "leisure=playground&playground!=forest"] | ||||
| 
 | ||||
|             expect((<Or>opt).or.map(f => TagUtils.toString(f))).deep.eq( | ||||
|                 expected | ||||
|             ) | ||||
|         }) | ||||
| 
 | ||||
|         it("should detect conflicting tags", () => { | ||||
|             const q = new And([new Tag("key", "value"), new RegexTag("key", "value", true)]) | ||||
|             expect(q.optimize()).eq(false) | ||||
|         }) | ||||
| 
 | ||||
|         it("should detect conflicting tags with a regex", () => { | ||||
|             const q = new And([new Tag("key", "value"), new RegexTag("key", /value/, true)]) | ||||
|             expect(q.optimize()).eq(false) | ||||
|         }) | ||||
| 
 | ||||
|     }) | ||||
| 
 | ||||
|     describe("Or", () => { | ||||
| 
 | ||||
| 
 | ||||
|         it("with nested And which has a common property should be dropped", () => { | ||||
| 
 | ||||
|             const t = new Or([ | ||||
|                 new Tag("foo", "bar"), | ||||
|                 new And([ | ||||
|                     new Tag("foo", "bar"), | ||||
|                     new Tag("x", "y"), | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt = <TagsFilter>t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar") | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|         it("should flatten nested ors", () => { | ||||
|             const t = new Or([ | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y") | ||||
|                 ]) | ||||
|             ]).optimize() | ||||
|             expect(t).deep.eq(new Tag("x", "y")) | ||||
|         }) | ||||
| 
 | ||||
|         it("should flatten nested ors", () => { | ||||
|             const t = new Or([ | ||||
|                 new Tag("a", "b"), | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y") | ||||
|                 ]) | ||||
|             ]).optimize() | ||||
|             expect(t).deep.eq(new Or([new Tag("a", "b"), new Tag("x", "y")])) | ||||
|         }) | ||||
| 
 | ||||
|     }) | ||||
| 
 | ||||
|     it("should not generate a conflict for climbing tags", () => { | ||||
|         const club_tags = TagUtils.Tag( | ||||
|             { | ||||
|                 "or": [ | ||||
|                     "club=climbing", | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "sport=climbing", | ||||
|                             { | ||||
|                                 "or": [ | ||||
|                                     "office~*", | ||||
|                                     "club~*" | ||||
|                                 ] | ||||
|                             } | ||||
|                         ] | ||||
|                     } | ||||
|                 ] | ||||
|             }) | ||||
|         const gym_tags = TagUtils.Tag({ | ||||
|             "and": [ | ||||
|                 "sport=climbing", | ||||
|                 "leisure=sports_centre" | ||||
|             ] | ||||
|         }) | ||||
|         const other_climbing = TagUtils.Tag({ | ||||
|             "and": [ | ||||
|                 "sport=climbing", | ||||
|                 "climbing!~route", | ||||
|                 "leisure!~sports_centre", | ||||
|                 "climbing!=route_top", | ||||
|                 "climbing!=route_bottom" | ||||
|             ] | ||||
|         }) | ||||
|         const together = new Or([club_tags, gym_tags, other_climbing]) | ||||
|         const opt = together.optimize() | ||||
| 
 | ||||
|         /* | ||||
|          club=climbing | (sport=climbing&(office~* | club~*)) | ||||
|          OR | ||||
|          sport=climbing & leisure=sports_centre | ||||
|          OR | ||||
|          sport=climbing & climbing!~route & leisure!~sports_centre | ||||
|         */ | ||||
| 
 | ||||
|         /* | ||||
|          > When the first OR is written out, this becomes | ||||
|          club=climbing  | ||||
|          OR  | ||||
|          (sport=climbing&(office~* | club~*)) | ||||
|          OR | ||||
|          (sport=climbing & leisure=sports_centre) | ||||
|          OR | ||||
|          (sport=climbing & climbing!~route & leisure!~sports_centre & ...) | ||||
|          */ | ||||
| 
 | ||||
|         /* | ||||
|          > We can join the 'sport=climbing' in the last 3 phrases | ||||
|          club=climbing  | ||||
|          OR  | ||||
|          (sport=climbing AND  | ||||
|              (office~* | club~*)) | ||||
|              OR | ||||
|              (leisure=sports_centre) | ||||
|              OR | ||||
|              (climbing!~route & leisure!~sports_centre & ...) | ||||
|          ) | ||||
|          */ | ||||
| 
 | ||||
| 
 | ||||
|         expect(opt).deep.eq( | ||||
|             TagUtils.Tag({ | ||||
|                 or: [ | ||||
|                     "club=climbing", | ||||
|                     { | ||||
|                         and: ["sport=climbing", | ||||
|                             {or: ["club~*", "office~*"]}] | ||||
|                     }, | ||||
|                     { | ||||
|                         and: ["sport=climbing", | ||||
|                             { | ||||
|                                 or: [ | ||||
|                                     "leisure=sports_centre", | ||||
|                                     { | ||||
|                                         and: [ | ||||
|                                             "climbing!~route", | ||||
|                                             "climbing!=route_top", | ||||
|                                             "climbing!=route_bottom", | ||||
|                                             "leisure!~sports_centre" | ||||
|                                         ] | ||||
|                                     } | ||||
|                                 ] | ||||
|                             }] | ||||
|                     } | ||||
| 
 | ||||
|                 ], | ||||
| 
 | ||||
|             }) | ||||
|         ) | ||||
|     }) | ||||
| }) | ||||
|  | @ -1,150 +0,0 @@ | |||
| import {describe} from 'mocha' | ||||
| import {expect} from 'chai' | ||||
| import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; | ||||
| import {And} from "../../../Logic/Tags/And"; | ||||
| import {Tag} from "../../../Logic/Tags/Tag"; | ||||
| import {TagUtils} from "../../../Logic/Tags/TagUtils"; | ||||
| import {Or} from "../../../Logic/Tags/Or"; | ||||
| import {RegexTag} from "../../../Logic/Tags/RegexTag"; | ||||
| 
 | ||||
| describe("Tag optimalization", () => { | ||||
|      | ||||
|     describe("And", () => { | ||||
|         it("with condition and nested and should be flattened", () => { | ||||
|             const t = new And( | ||||
|                 [ | ||||
|                     new And([ | ||||
|                         new Tag("x", "y") | ||||
|                     ]), | ||||
|                     new Tag("a", "b") | ||||
|                 ] | ||||
|             ) | ||||
|             const opt =<TagsFilter> t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq(`a=b&x=y`) | ||||
|         }) | ||||
|          | ||||
|         it("with nested ors and commons property should be extracted", () => { | ||||
| 
 | ||||
|             // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d))
 | ||||
|             const t = new And([ | ||||
|                 new Tag("foo","bar"), | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y"), | ||||
|                     new Tag("a", "b") | ||||
|                 ]), | ||||
|                 new Or([ | ||||
|                     new Tag("x", "y"), | ||||
|                     new Tag("c", "d") | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt =<TagsFilter> t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") | ||||
|         }) | ||||
|      | ||||
|         it("should move regextag to the end", () => { | ||||
|             const t = new And([ | ||||
|                 new RegexTag("x","y"), | ||||
|                 new Tag("a","b") | ||||
|             ]) | ||||
|             const opt =<TagsFilter> t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("a=b&x~^y$") | ||||
| 
 | ||||
|         }) | ||||
|          | ||||
|         it("should sort tags by their popularity (least popular first)", () => { | ||||
|             const t = new And([ | ||||
|                 new Tag("bicycle","yes"), | ||||
|                 new Tag("amenity","binoculars") | ||||
|             ]) | ||||
|             const opt =<TagsFilter> t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") | ||||
| 
 | ||||
|         }) | ||||
|      | ||||
|         it("should optimize an advanced, real world case", () => { | ||||
|             const filter = TagUtils.Tag(  {or:   [ | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] | ||||
|                             }, | ||||
|                             "bicycle=yes" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] | ||||
|                             }, | ||||
|                         ] | ||||
|                     }, | ||||
|                     "amenity=toilets", | ||||
|                     "amenity=bench", | ||||
|                     "leisure=picnic_table", | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "tower:type=observation" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "amenity=bicycle_repair_station" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             { | ||||
|                                 "or": [ | ||||
|                                     "amenity=bicycle_rental", | ||||
|                                     "bicycle_rental~*", | ||||
|                                     "service:bicycle:rental=yes", | ||||
|                                     "rental~.*bicycle.*" | ||||
|                                 ] | ||||
|                             }, | ||||
|                             "bicycle_rental!=docking_station" | ||||
|                         ] | ||||
|                     }, | ||||
|                     { | ||||
|                         "and": [ | ||||
|                             "leisure=playground", | ||||
|                             "playground!=forest" | ||||
|                         ] | ||||
|                     } | ||||
|                 ]}); | ||||
|             const opt = <TagsFilter> filter.optimize() | ||||
|             const expected = "amenity=charging_station|" + | ||||
|                 "amenity=toilets|" + | ||||
|                 "amenity=bench|" + | ||||
|                 "amenity=bicycle_repair_station" + | ||||
|                 "|construction:amenity=charging_station|" + | ||||
|                 "disused:amenity=charging_station|" + | ||||
|                 "leisure=picnic_table|" + | ||||
|                 "planned:amenity=charging_station|" + | ||||
|                 "tower:type=observation| " + | ||||
|                 "( (amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!~^docking_station$) |" + | ||||
|                 " (leisure=playground&playground!~^forest$)" | ||||
|              | ||||
|             expect(TagUtils.toString(opt).replace(/ /g, "")) | ||||
|                 .eq(expected.replace(/ /g, "")) | ||||
| 
 | ||||
|         }) | ||||
|      | ||||
|     }) | ||||
|      | ||||
|     describe("Or", () => { | ||||
|         it("with nested And which has a common property should be dropped", () => { | ||||
| 
 | ||||
|             const t = new Or([ | ||||
|                 new Tag("foo","bar"), | ||||
|                 new And([ | ||||
|                     new Tag("foo", "bar"), | ||||
|                     new Tag("x", "y"), | ||||
|                 ]) | ||||
|             ]) | ||||
|             const opt =<TagsFilter> t.optimize() | ||||
|             expect(TagUtils.toString(opt)).eq("foo=bar") | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|         }) | ||||
| }) | ||||
							
								
								
									
										19
									
								
								test/Models/ThemeConfig/SourceConfig.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								test/Models/ThemeConfig/SourceConfig.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import {describe} from 'mocha' | ||||
| import {expect} from 'chai' | ||||
| import SourceConfig from "../../../Models/ThemeConfig/SourceConfig"; | ||||
| import {TagUtils} from "../../../Logic/Tags/TagUtils"; | ||||
| 
 | ||||
| describe("SourceConfig", () => { | ||||
| 
 | ||||
|     it("should throw an error on conflicting tags", () => { | ||||
|         expect(() => { | ||||
|             new SourceConfig( | ||||
|                 { | ||||
|                     osmTags: TagUtils.Tag({ | ||||
|                         and: ["x=y", "a=b", "x!=y"] | ||||
|                     }) | ||||
|                 }, false | ||||
|             ) | ||||
|         }).to.throw(/tags are conflicting/) | ||||
|     }) | ||||
| }) | ||||
|  | @ -34,7 +34,7 @@ describe("GenerateCache", () => { | |||
|             } | ||||
|             mkdirSync("/tmp/np-cache") | ||||
|             initDownloads( | ||||
|                 "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!~%22%5E98%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!~%22%5Epermissive%24%22%5D%5B%22access%22!~%22%5Eprivate%24%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" | ||||
|                 "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!%3D%2298%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!%3D%22permissive%22%5D%5B%22access%22!%3D%22private%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" | ||||
|             ); | ||||
|             await main([ | ||||
|                 "natuurpunt", | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue