Merge develop

This commit is contained in:
Pieter Vander Vennet 2022-04-19 01:39:03 +02:00
commit ccf9c4b5f6
50 changed files with 1427 additions and 766 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
Docs/FilteredByDepth.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

View file

@ -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.
![](./FilterFunctionality.gif)
```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:
![](./FilteredByDepth.gif)
```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

View file

@ -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",

View file

@ -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();
@ -240,7 +260,7 @@ export default class GeoLocationHandler extends VariableUiElement {
return;
}
if(typeof navigator === "undefined"){
if (typeof navigator === "undefined") {
return
}
@ -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)

View file

@ -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));
});
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)) {
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)

View file

@ -44,28 +44,33 @@ 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);

View file

@ -9,6 +9,13 @@ export class And extends TagsFilter {
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 = [];
for (const or of choices) {
@ -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){
@ -174,26 +248,55 @@ export class And extends TagsFilter {
}
}
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
{
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
}
return true;
// 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)
}
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());
}
}

View file

@ -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;
}

View file

@ -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)
}
containedAnds = cleanedContainedANds
} while(dirty)
}
return true;
})
// 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());
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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[] {

View file

@ -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)))
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -127,6 +127,7 @@ export default class LayerConfig extends WithContextLoader {
idKey: json.source["idKey"]
},
Constants.priviliged_layers.indexOf(this.id) > 0,
json.id
);

View file

@ -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;

View file

@ -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) {

View file

@ -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="
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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]

View file

@ -90,8 +90,8 @@ 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 => {
@ -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)
]);
}));

View file

@ -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
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 is a parlty hidden layer
// this layer can never be toggled on in any case, so we skip the presets
continue;
}
}
const presets = layer.layerDef.presets;
for (const preset of presets) {

View file

@ -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,7 +26,7 @@ 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) {
@ -35,20 +37,37 @@ class TranslatorsPanelContent extends Combine {
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) ?? [])
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"))
.map(context => new Link(context, LinkToWeblate.hrefToWeblate(ln, context), true))
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([
@ -65,13 +84,16 @@ class TranslatorsPanelContent extends Combine {
}),
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")
)
}))
@ -97,24 +119,23 @@ export default class TranslatorsPanel extends Toggle {
}
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;

View file

@ -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
);

View file

@ -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

View file

@ -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,

View file

@ -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 {
@ -165,24 +162,6 @@ export class Translation extends BaseUIElement {
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)
}
}

View file

@ -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);
}
/**

View file

@ -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): {

View file

@ -51,7 +51,8 @@
],
"overrideAll": {
"allowMove": {
"improveAccuracy": true
"enableRelocation": false,
"enableImproveAccuracy": true
},
"+titleIcons": [
{

View file

@ -276,17 +276,17 @@
"willBePublished": "La teva foto serà publicada: "
},
"importHelper": {
"allAttributesSame": "Totes les funcions a importar tenen aquesta etiqueta",
"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>",
"inspectDataTitle": "Inspecciona les dades de {count} funcions per importar",
"inspectDidAutoDected": "La capa es va seleccionar automàticament",
"inspectLooksCorrect": "Aquests valors semblen correctes",
"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í.",
"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",
"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ó"

View file

@ -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"

View file

@ -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.",

View file

@ -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}",

View file

@ -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"

View file

@ -267,9 +267,13 @@
"willBePublished": "A képed így lesz közzétéve: "
},
"importHelper": {
"allAttributesSame": "Ez a címke minden importálandó objektumon szerepel",
"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",
"featuredThemeTitle": "Kiemelt ezen a héten",

View file

@ -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",
"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>",
"inspectDataTitle": "Bekijk de data van {count} te importeren objecten",
"inspectDidAutoDected": "Deze laag werd automatisch gekozen",
"inspectLooksCorrect": "Deze waardes zien er correct uit",
"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.",
"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",
"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"

View file

@ -35,25 +35,6 @@
"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ą",
@ -141,99 +122,6 @@
},
"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ę"
},
"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!"
},
"image": {
"addPicture": "Dodaj zdjęcie",
"ccb": "na licencji CC-BY",

View file

@ -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
View file

@ -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",

View file

@ -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",

View 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))

View file

@ -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 }"

View 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"
]
}
]
}]
}
],
})
)
})
})

View file

@ -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")
})
})
})

View 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/)
})
})

View file

@ -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",