diff --git a/.gitignore b/.gitignore index 4b310f095..ed8cb6f15 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ dist/* node_modules .cache/* .idea/* -.vscode/* scratch assets/editor-layer-index.json assets/generated/* @@ -24,3 +23,16 @@ index_*.ts .~lock.* *.doctest.ts service-worker.js + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 000000000..d53368529 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,14 @@ +tasks: + - init: npm run init + command: npm run start + +ports: + - name: MapComplete Website + port: 1234 + onOpen: open-browser + +vscode: + extensions: + - "esbenp.prettier-vscode" + - "eamodio.gitlens", + - "GitHub.vscode-pull-request-github" \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..68d524f21 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "eamodio.gitlens", + "GitHub.vscode-pull-request-github" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..25b1f4dbe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "json.schemas": [ + { + "fileMatch": [ + "/assets/layers/*/*.json", + "!/assets/layers/*/license_info.json" + ], + "url": "./Docs/Schemas/LayerConfigJson.schema.json" + }, + { + "fileMatch": [ + "/assets/themes/*/*.json", + "!/assets/themes/*/license_info.json" + ], + "url": "./Docs/Schemas/LayoutConfigJson.schema.json" + } + ], + "editor.tabSize": 2, + "files.autoSave": "onFocusChange", + "search.useIgnoreFiles": true + } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..2dfa42cd3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "path": "/", + "group": "build", + "problemMatcher": [], + "label": "MapComplete Dev", + "detail": "Run MapComplete Dev Server" + } + ] +} diff --git a/Docs/Development_deployment.md b/Docs/Development_deployment.md index 09313fd7d..64b3c9563 100644 --- a/Docs/Development_deployment.md +++ b/Docs/Development_deployment.md @@ -73,6 +73,11 @@ To use the WSL in Visual Studio Code: To use WSL without Visual Studio Code you can replace steps 7 and 8 by opening up a WSL terminal +On mac +------ + +Install the `Command line tools for XCode which you can find [here](https://developer.apple.com/download/all/). You might need an apple dev account for this. + Automatic deployment -------------------- diff --git a/Models/ThemeConfig/Conversion/Conversion.ts b/Models/ThemeConfig/Conversion/Conversion.ts index 5fe52ebb0..2b7880c1f 100644 --- a/Models/ThemeConfig/Conversion/Conversion.ts +++ b/Models/ThemeConfig/Conversion/Conversion.ts @@ -39,6 +39,14 @@ export abstract class Conversion { return DesugaringStep.strict(fixed) } + public convertJoin(json: TIn, context: string, errors: string[], warnings?: string[], information?: string[]): TOut { + const fixed = this.convert(json, context) + errors?.push(...(fixed.errors ?? [])) + warnings?.push(...(fixed.warnings ?? [])) + information?.push(...(fixed.information ?? [])) + return fixed.result + } + public andThenF(f: (tout:TOut) => X ): Conversion{ return new Pipe( this, diff --git a/Models/ThemeConfig/Conversion/Validation.ts b/Models/ThemeConfig/Conversion/Validation.ts index 79e4785f3..36da25536 100644 --- a/Models/ThemeConfig/Conversion/Validation.ts +++ b/Models/ThemeConfig/Conversion/Validation.ts @@ -45,19 +45,67 @@ class ValidateLanguageCompleteness extends DesugaringStep { } } +export class DoesImageExist extends DesugaringStep { + + private readonly _knownImagePaths: Set; + private readonly doesPathExist: (path: string) => boolean = undefined; + + constructor(knownImagePaths: Set, checkExistsSync: (path: string) => boolean = undefined) { + super("Checks if an image exists", [], "DoesImageExist"); + this._knownImagePaths = knownImagePaths; + this.doesPathExist = checkExistsSync; + } + + convert(image: string, context: string): { result: string; errors?: string[]; warnings?: string[]; information?: string[] } { + const errors = [] + const warnings = [] + const information = [] + if (image.indexOf("{") >= 0) { + information.push("Ignoring image with { in the path: " + image) + return {result: image} + } + + if (image === "assets/SocialImage.png") { + return {result: image} + } + if (image.match(/[a-z]*/)) { + + if (Svg.All[image + ".svg"] !== undefined) { + // This is a builtin img, e.g. 'checkmark' or 'crosshair' + return {result: image}; + } + } + + if (this._knownImagePaths !== undefined && !this._knownImagePaths.has(image)) { + if (this.doesPathExist === undefined) { + errors.push(`Image with path ${image} not found or not attributed; it is used in ${context}`) + } else if (!this.doesPathExist(image)) { + errors.push(`Image with path ${image} does not exist; it is used in ${context}.\n Check for typo's and missing directories in the path.`) + } else { + errors.push(`Image with path ${image} is not attributed (but it exists); execute 'npm run query:licenses' to add the license information and/or run 'npm run generate:licenses' to compile all the license info`) + } + } + return { + result: image, + errors, warnings, information + } + } + +} + class ValidateTheme extends DesugaringStep { /** * The paths where this layer is originally saved. Triggers some extra checks * @private */ private readonly _path?: string; - private readonly knownImagePaths: Set; private readonly _isBuiltin: boolean; private _sharedTagRenderings: Map; + private readonly _validateImage: DesugaringStep; - constructor(knownImagePaths: Set, path: string, isBuiltin: boolean, sharedTagRenderings: Map) { + constructor(doesImageExist: DoesImageExist, path: string, isBuiltin: boolean, sharedTagRenderings: Map) { super("Doesn't change anything, but emits warnings and errors", [], "ValidateTheme"); - this.knownImagePaths = knownImagePaths; + this._validateImage = doesImageExist; this._path = path; this._isBuiltin = isBuiltin; this._sharedTagRenderings = sharedTagRenderings; @@ -89,26 +137,7 @@ class ValidateTheme extends DesugaringStep { errors.push("Found a remote image: " + remoteImage + " in theme " + json.id + ", please download it.") } for (const image of images) { - if (image.indexOf("{") >= 0) { - information.push("Ignoring image with { in the path: " + image) - continue - } - - if (image === "assets/SocialImage.png") { - continue - } - if (image.match(/[a-z]*/)) { - - if (Svg.All[image + ".svg"] !== undefined) { - // This is a builtin img, e.g. 'checkmark' or 'crosshair' - continue;// => - } - } - - if (this.knownImagePaths !== undefined && !this.knownImagePaths.has(image)) { - const ctx = context === undefined ? "" : ` in a layer defined in the theme ${context}` - errors.push(`Image with path ${image} not found or not attributed; it is used in ${json.id}${ctx}`) - } + this._validateImage.convertJoin(image, context === undefined ? "" : ` in a layer defined in the theme ${context}`, errors, warnings, information) } if (json.icon.endsWith(".svg")) { @@ -150,9 +179,7 @@ class ValidateTheme extends DesugaringStep { if (theme.id !== filename) { errors.push("Theme ids should be the same as the name.json, but we got id: " + theme.id + " and filename " + filename + " (" + this._path + ")") } - if (!this.knownImagePaths.has(theme.icon)) { - errors.push("The theme image " + theme.icon + " is not attributed or not saved locally") - } + this._validateImage.convertJoin(theme.icon, context + ".icon", errors, warnings, information); const dups = Utils.Dupiclates(json.layers.map(layer => layer["id"])) if (dups.length > 0) { errors.push(`The theme ${json.id} defines multiple layers with id ${dups.join(", ")}`) @@ -166,16 +193,16 @@ class ValidateTheme extends DesugaringStep { // The first key in the the title-field must be english, otherwise the title in the loading page will be the different language const targetLanguage = theme.title.SupportedLanguages()[0] - if(targetLanguage !== "en"){ + if (targetLanguage !== "en") { warnings.push(`TargetLanguage is not 'en' for public theme ${theme.id}, it is ${targetLanguage}. Move 'en' up in the title of the theme and set it as the first key`) } - + // Official, public themes must have a full english translation const checked = new ValidateLanguageCompleteness("en") .convert(theme, theme.id) errors.push(...checked.errors) - - + + } } catch (e) { @@ -192,10 +219,10 @@ class ValidateTheme extends DesugaringStep { } export class ValidateThemeAndLayers extends Fuse { - constructor(knownImagePaths: Set, path: string, isBuiltin: boolean, sharedTagRenderings: Map) { + constructor(doesImageExist: DoesImageExist, path: string, isBuiltin: boolean, sharedTagRenderings: Map) { super("Validates a theme and the contained layers", - new ValidateTheme(knownImagePaths, path, isBuiltin, sharedTagRenderings), - new On("layers", new Each(new ValidateLayer(undefined, false, knownImagePaths))) + new ValidateTheme(doesImageExist, path, isBuiltin, sharedTagRenderings), + new On("layers", new Each(new ValidateLayer(undefined, false, doesImageExist))) ); } } @@ -354,7 +381,7 @@ export class DetectShadowedMappings extends DesugaringStep { - private knownImagePaths: Set; - constructor(knownImagePaths: Set) { + private readonly _doesImageExist: DoesImageExist; + + constructor(doesImageExist: DoesImageExist) { super("Checks that 'then'clauses in mappings don't have images, but use 'icon' instead", [], "DetectMappingsWithImages"); - this.knownImagePaths = knownImagePaths; + this._doesImageExist = doesImageExist; } /** - * const r = new DetectMappingsWithImages(new Set()).convert({ + * const r = new DetectMappingsWithImages(new DoesImageExist(new Set())).convert({ * "mappings": [ * { * "if": "bicycle_parking=stands", @@ -412,9 +440,9 @@ export class DetectMappingsWithImages extends DesugaringStep msg.indexOf("./assets/layers/bike_parking/staple.svg") >= 0) // => true */ convert(json: TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson; errors?: string[]; warnings?: string[], information?: string[] } { - const errors = [] - const warnings = [] - const information = [] + const errors: string[] = [] + const warnings: string[] = [] + const information: string[] = [] if (json.mappings === undefined || json.mappings.length === 0) { return {result: json} } @@ -432,12 +460,10 @@ export class DetectMappingsWithImages extends DesugaringStep { - constructor(layerConfig?: LayerConfigJson, knownImagePaths?: Set) { + constructor(layerConfig?: LayerConfigJson, doesImageExist?: DoesImageExist) { super("Various validation on tagRenderingConfigs", new DetectShadowedMappings(layerConfig), - new DetectMappingsWithImages(knownImagePaths) + new DetectMappingsWithImages(doesImageExist) ); } } @@ -469,13 +495,13 @@ export class ValidateLayer extends DesugaringStep { */ private readonly _path?: string; private readonly _isBuiltin: boolean; - private knownImagePaths: Set | undefined; + private readonly _doesImageExist: DoesImageExist; - constructor(path: string, isBuiltin: boolean, knownImagePaths: Set) { + constructor(path: string, isBuiltin: boolean, doesImageExist: DoesImageExist) { super("Doesn't change anything, but emits warnings and errors", [], "ValidateLayer"); this._path = path; this._isBuiltin = isBuiltin; - this.knownImagePaths = knownImagePaths + this._doesImageExist = doesImageExist } convert(json: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings?: string[], information?: string[] } { @@ -563,7 +589,7 @@ export class ValidateLayer extends DesugaringStep { } } if (json.tagRenderings !== undefined) { - const r = new On("tagRenderings", new Each(new ValidateTagRenderings(json, this.knownImagePaths))).convert(json, context) + const r = new On("tagRenderings", new Each(new ValidateTagRenderings(json, this._doesImageExist))).convert(json, context) warnings.push(...(r.warnings ?? [])) errors.push(...(r.errors ?? [])) information.push(...(r.information ?? [])) diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index 635691fcd..4779f5ebc 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -31,6 +31,7 @@ import {FixedUiElement} from "../../UI/Base/FixedUiElement"; export default class LayerConfig extends WithContextLoader { + public static readonly syncSelectionAllowed = ["no", "local", "theme-only", "global"] as const; public readonly id: string; public readonly name: Translation; public readonly description: Translation; @@ -44,10 +45,8 @@ export default class LayerConfig extends WithContextLoader { public readonly maxzoom: number; public readonly title?: TagRenderingConfig; public readonly titleIcons: TagRenderingConfig[]; - public readonly mapRendering: PointRenderingConfig[] public readonly lineRendering: LineRenderingConfig[] - public readonly units: Unit[]; public readonly deletion: DeleteConfig | null; public readonly allowMove: MoveConfig | null @@ -57,15 +56,11 @@ export default class LayerConfig extends WithContextLoader { * In seconds */ public readonly maxAgeOfCache: number - public readonly presets: PresetConfig[]; - public readonly tagRenderings: TagRenderingConfig[]; public readonly filters: FilterConfig[]; public readonly filterIsSameAs: string; public readonly forceLoad: boolean; - - public static readonly syncSelectionAllowed = ["no" , "local" , "theme-only" , "global"] as const; public readonly syncSelection: (typeof LayerConfig.syncSelectionAllowed)[number] // this is a trick to conver a constant array of strings into a type union of these values constructor( @@ -74,18 +69,24 @@ export default class LayerConfig extends WithContextLoader { official: boolean = true ) { context = context + "." + json.id; - const translationContext = "layers:"+json.id + const translationContext = "layers:" + json.id super(json, context) this.id = json.id; + if (typeof json === "string") { + throw `Not a valid layer: the layerConfig is a string. 'npm run generate:layeroverview' might be needed (at ${context})` + } + + if (json.id === undefined) { - throw "Not a valid layer: id is undefined: " + JSON.stringify(json) + throw `Not a valid layer: id is undefined: ${JSON.stringify(json)} (At ${context})` } if (json.source === undefined) { throw "Layer " + this.id + " does not define a source section (" + context + ")" } + if (json.source.osmTags === undefined) { throw "Layer " + this.id + " does not define a osmTags in the source section - these should always be present, even for geojson layers (" + context + ")" } @@ -98,8 +99,8 @@ export default class LayerConfig extends WithContextLoader { } this.maxAgeOfCache = json.source.maxCacheAge ?? 24 * 60 * 60 * 30 - if(json.syncSelection !== undefined && LayerConfig.syncSelectionAllowed.indexOf(json.syncSelection) < 0){ - throw context+ " Invalid sync-selection: must be one of "+LayerConfig.syncSelectionAllowed.map(v => `'${v}'`).join(", ")+" but got '"+json.syncSelection+"'" + if (json.syncSelection !== undefined && LayerConfig.syncSelectionAllowed.indexOf(json.syncSelection) < 0) { + throw context + " Invalid sync-selection: must be one of " + LayerConfig.syncSelectionAllowed.map(v => `'${v}'`).join(", ") + " but got '" + json.syncSelection + "'" } this.syncSelection = json.syncSelection ?? "no"; const osmTags = TagUtils.Tag( @@ -107,10 +108,10 @@ export default class LayerConfig extends WithContextLoader { context + "source.osmTags" ); - if(Constants.priviliged_layers.indexOf(this.id) < 0 && osmTags.isNegative()){ - throw context + "The source states tags which give a very wide selection: it only uses negative expressions, which will result in too much and unexpected data. Add at least one required tag. The tags are:\n\t"+osmTags.asHumanString(false, false, {}); + if (Constants.priviliged_layers.indexOf(this.id) < 0 && osmTags.isNegative()) { + throw context + "The source states tags which give a very wide selection: it only uses negative expressions, which will result in too much and unexpected data. Add at least one required tag. The tags are:\n\t" + osmTags.asHumanString(false, false, {}); } - + if (json.source["geoJsonSource"] !== undefined) { throw context + "Use 'geoJson' instead of 'geoJsonSource'"; } @@ -118,7 +119,7 @@ export default class LayerConfig extends WithContextLoader { if (json.source["geojson"] !== undefined) { throw context + "Use 'geoJson' instead of 'geojson' (the J is a capital letter)"; } - + this.source = new SourceConfig( { @@ -138,8 +139,8 @@ export default class LayerConfig extends WithContextLoader { this.allowSplit = json.allowSplit ?? false; this.name = Translations.T(json.name, translationContext + ".name"); - if(json.units!==undefined && !Array.isArray(json.units)){ - throw "At "+context+".units: the 'units'-section should be a list; you probably have an object there" + if (json.units !== undefined && !Array.isArray(json.units)) { + throw "At " + context + ".units: the 'units'-section should be a list; you probably have an object there" } this.units = (json.units ?? []).map(((unitJson, i) => Unit.fromJson(unitJson, `${context}.unit[${i}]`))) @@ -167,8 +168,8 @@ export default class LayerConfig extends WithContextLoader { const index = kv.indexOf("="); let key = kv.substring(0, index).trim(); const r = "[a-z_][a-z0-9:]*" - if(key.match(r) === null){ - throw "At "+context+" invalid key for calculated tag: "+key+"; it should match "+r + if (key.match(r) === null) { + throw "At " + context + " invalid key for calculated tag: " + key + "; it should match " + r } const isStrict = key.endsWith(':') if (isStrict) { @@ -343,14 +344,14 @@ export default class LayerConfig extends WithContextLoader { } public GenerateDocumentation(usedInThemes: string[], layerIsNeededBy?: Map, dependencies: { - context?: string; - reason: string; - neededLayer: string; - }[] = [] - , addedByDefault = false, canBeIncluded = true): BaseUIElement { + context?: string; + reason: string; + neededLayer: string; + }[] = [] + , addedByDefault = false, canBeIncluded = true): BaseUIElement { const extraProps = [] - - extraProps.push("This layer is shown at zoomlevel **"+this.minzoom+"** and higher") + + extraProps.push("This layer is shown at zoomlevel **" + this.minzoom + "** and higher") if (canBeIncluded) { if (addedByDefault) { @@ -440,7 +441,7 @@ export default class LayerConfig extends WithContextLoader { let overpassLink: BaseUIElement = undefined; if (Constants.priviliged_layers.indexOf(this.id) < 0) { try { - overpassLink = new Link("Execute on overpass", Overpass.AsOverpassTurboLink( new And(neededTags).optimize())) + overpassLink = new Link("Execute on overpass", Overpass.AsOverpassTurboLink(new And(neededTags).optimize())) } catch (e) { console.error("Could not generate overpasslink for " + this.id) } diff --git a/Models/ThemeConfig/TagRenderingConfig.ts b/Models/ThemeConfig/TagRenderingConfig.ts index e0a482f8e..619e28c2f 100644 --- a/Models/ThemeConfig/TagRenderingConfig.ts +++ b/Models/ThemeConfig/TagRenderingConfig.ts @@ -352,7 +352,7 @@ export default class TagRenderingConfig { } if (hideInAnswer !== true && !(mp.ifnot?.isUsableAsAnswer() ?? true)) { - throw `${context}.mapping[${i}].ifnot: This value cannot be used to answer a question, probably because it contains a regex or an OR. Either change it or set 'hideInAnswer'` + throw `${context}.mapping[${i}].ifnot: This value cannot be used to answer a question, probably because it contains a regex or an OR. If a contributor were to pick this as an option, MapComplete wouldn't be able to determine which tags to add.\n Either change it or set 'hideInAnswer'` } } diff --git a/assets/layers/shelter/license_info.json b/assets/layers/shelter/license_info.json new file mode 100644 index 000000000..6dab5c0d0 --- /dev/null +++ b/assets/layers/shelter/license_info.json @@ -0,0 +1,12 @@ +[ + { + "path": "shelter.svg", + "license": "MIT", + "authors": [ + "Diemen Design" + ], + "sources": [ + "https://icon-icons.com/icon/map-shelter/158301" + ] + } +] \ No newline at end of file diff --git a/assets/layers/shelter/shelter.json b/assets/layers/shelter/shelter.json new file mode 100644 index 000000000..803b6ef42 --- /dev/null +++ b/assets/layers/shelter/shelter.json @@ -0,0 +1,88 @@ +{ + "id": "shelter", + "name": { + "en": "Shelter" + }, + "description": { + "en": "Layer showing shelter structures" + }, + "source": { + "osmTags": { + "and": [ + "amenity=shelter" + ] + } + }, + "minzoom": 13, + "title": { + "render": { + "en": "Shelter" + } + }, + "mapRendering": [ + { + "location": [ + "point", + "centroid" + ], + "icon": "./assets/layers/shelter/shelter.svg" + } + ], + "tagRenderings": [ + { + "id": "shelter-type", + "mappings": [ + { + "if": "shelter_type=public_transport", + "then": { + "en": "This is a shelter at a public transport stop." + } + }, + { + "if": "shelter_type=picnic_shelter", + "then": { + "en": "This is a shelter protecting from rain at a picnic site." + } + }, + { + "if": "shelter_type=gazebo", + "then": { + "en": "This is a gazebo." + } + }, + { + "if": "shelter_type=weather_shelter", + "then": { + "en": "This is a small shelter, primarily intended for short breaks. Usually found in the mountains or alongside roads." + } + }, + { + "if": "shelter_type=lean_to", + "then": { + "en": "This is a shed with 3 walls, primarily intended for camping." + } + }, + { + "if": "shelter_type=pavilion", + "then": { + "en": "This is a pavilion" + } + }, + { + "if": "shelter_type=basic_hut", + "then": "This is a basic hut, providing basic shelter and sleeping facilities." + } + ], + "question": { + "en": "What kind of shelter is this?" + }, + "render": { + "en": "Shelter type: {shelter_type}" + }, + "freeform": { + "key": "shelter_type", + "type": "string" + } + } + ] +} \ No newline at end of file diff --git a/assets/layers/shelter/shelter.svg b/assets/layers/shelter/shelter.svg new file mode 100644 index 000000000..266a9eb7c --- /dev/null +++ b/assets/layers/shelter/shelter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/layers/transit_routes/transit_routes.json b/assets/layers/transit_routes/transit_routes.json new file mode 100644 index 000000000..b29464746 --- /dev/null +++ b/assets/layers/transit_routes/transit_routes.json @@ -0,0 +1,141 @@ +{ + "id": "transit_routes", + "name": { + "en": "Bus lines" + }, + "description": { + "en": "Layer showing bus lines" + }, + "source": { + "osmTags": { + "and": [ + "type=route", + "route=bus" + ] + } + }, + "minzoom": 15, + "title": { + "render": { + "en": "Bus line" + }, + "mappings": [ + { + "if": "name~*", + "then": { + "en": "{name}" + } + } + ] + }, + "mapRendering": [ + { + "color": { + "render": { + "en": "#ff0000" + }, + "mappings": [ + { + "if": "colour~*", + "then": "{colour}" + } + ] + } + } + ], + "tagRenderings": [ + { + "id": "name", + "freeform": { + "key": "name", + "type": "string", + "placeholder": "Bus XX: From => Via => To" + }, + "render": "{name}", + "question": { + "en": "What is the name for this bus line? (i.e. Bus XX: From => Via => To)" + } + }, + { + "id": "from", + "freeform": { + "key": "from", + "type": "string", + "placeholder": "City, Stop Name" + }, + "render": { + "en": "This bus line begins at {from}" + }, + "question": { + "en": "What is the starting point for this bus line?" + } + }, + { + "id": "via", + "freeform": { + "key": "via", + "type": "string", + "placeholder": "City, Stop Name" + }, + "render": { + "en": "This bus line goes via {via}" + }, + "question": { + "en": "What is the via point for this bus line?" + } + }, + { + "id": "to", + "freeform": { + "key": "to", + "type": "string", + "placeholder": "City, Stop Name" + }, + "render": { + "en": "This bus line ends at {to}" + }, + "question": { + "en": "What is the ending point for this bus line?" + } + }, + { + "id": "colour", + "freeform": { + "key": "colour", + "type": "color" + }, + "render": { + "en": "This bus line has the color {colour}" + }, + "question": { + "en": "What is the colour for this bus line?" + } + }, + { + "id": "network", + "freeform": { + "key": "network", + "type": "string" + }, + "render": { + "en": "This bus line is part of the {network} network" + }, + "question": { + "en": "What network does this bus line belong to?" + } + }, + { + "id": "operator", + "freeform": { + "key": "operator", + "type": "string" + }, + "render": { + "en": "This bus line is operated by {operator}" + }, + "question": { + "en": "What company operates this bus line?" + } + } + ] +} \ No newline at end of file diff --git a/assets/layers/transit_stops/bus_stop.svg b/assets/layers/transit_stops/bus_stop.svg new file mode 100644 index 000000000..8d9c4d13b --- /dev/null +++ b/assets/layers/transit_stops/bus_stop.svg @@ -0,0 +1,55 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/layers/transit_stops/license_info.json b/assets/layers/transit_stops/license_info.json new file mode 100644 index 000000000..47a736deb --- /dev/null +++ b/assets/layers/transit_stops/license_info.json @@ -0,0 +1,15 @@ +[ + { + "path": "bus_stop.svg", + "license": "CC0", + "authors": [ + "Andy Allan", + "Michael Glanznig", + "Paul Norman", + "Paul Dicker" + ], + "sources": [ + "https://github.com/gravitystorm/openstreetmap-carto/blob/master/symbols/highway/bus_stop.svg" + ] + } +] \ No newline at end of file diff --git a/assets/layers/transit_stops/transit_stops.json b/assets/layers/transit_stops/transit_stops.json new file mode 100644 index 000000000..43daa2ada --- /dev/null +++ b/assets/layers/transit_stops/transit_stops.json @@ -0,0 +1,260 @@ +{ + "id": "transit_stops", + "name": { + "en": "Transit Stops" + }, + "description": { + "en": "Layer showing different types of transit stops." + }, + "source": { + "osmTags": { + "or": [ + "highway=bus_stop" + ] + } + }, + "minzoom": 15, + "title": { + "render": { + "en": "Transit Stop" + }, + "mappings": [ + { + "if": "name~*", + "then": { + "en": "Stop {name}" + } + } + ] + }, + "mapRendering": [ + { + "location": [ + "point", + "centroid" + ], + "icon": { + "render": "./assets/layers/transit_stops/bus_stop.svg", + "mappings": [] + }, + "label": "
{name}
" + } + ], + "calculatedTags": [ + "_routes=feat.memberships()", + "_contained_routes_properties=feat.memberships().map(p => {return {id: p.relation.id, name: p.relation.properties.name} }).filter((v,i,a)=>a.findIndex(t=>(JSON.stringify(t) === JSON.stringify(v)))===i)", + "_contained_route_ids=JSON.parse(feat.properties._contained_routes_properties ?? '[]').map(p => p.id)", + "_contained_routes=JSON.parse(feat.properties._contained_routes_properties ?? '[]').map(p => `
  • ${p.name ?? 'bus route'}
  • `).join('')", + "_contained_routes_count=JSON.parse(feat.properties._contained_routes_properties ?? '[]').length" + ], + "tagRenderings": [ + { + "id": "stop_name", + "render": { + "en": "This stop is called {name}" + }, + "freeform": { + "key": "name", + "type": "string", + "addExtraTags": [ + "noname=" + ], + "placeholder": { + "en": "Name of the stop" + } + }, + "mappings": [ + { + "if": { + "and": [ + "noname=yes", + "name=" + ] + }, + "then": { + "en": "This stop has no name" + } + } + ], + "question": { + "en": "What is the name of this stop?" + }, + "placeholder": "Name of the stop" + }, + "images", + { + "id": "shelter", + "mappings": [ + { + "if": "shelter=yes", + "then": { + "en": "This stop has a shelter" + } + }, + { + "if": "shelter=no", + "then": { + "en": "This stop does not have a shelter" + } + }, + { + "if": "shelter=separate", + "then": { + "en": "This stop has a shelter, that's separately mapped" + }, + "hideInAnswer": true + } + ], + "question": { + "en": "Does this stop have a shelter?" + } + }, + { + "id": "bench", + "mappings": [ + { + "if": "bench=yes", + "then": { + "en": "This stop has a bench" + } + }, + { + "if": "bench=no", + "then": { + "en": "This stop does not have a bench" + } + }, + { + "if": "bench=separate", + "then": { + "en": "This stop has a bench, that's separately mapped" + }, + "hideInAnswer": true + } + ], + "question": { + "en": "Does this stop have a bench?" + } + }, + { + "id": "bin", + "mappings": [ + { + "if": "bin=yes", + "then": { + "en": "This stop has a bin" + } + }, + { + "if": "bin=no", + "then": { + "en": "This stop does not have a bin" + } + }, + { + "if": "bin=separate", + "then": { + "en": "This stop has a bin, that's separately mapped" + }, + "hideInAnswer": true + } + ], + "question": { + "en": "Does this stop have a bin?" + } + }, + "wheelchair-access", + { + "id": "tactile_paving", + "mappings": [ + { + "if": "tactile_paving=yes", + "then": { + "en": "This stop has tactile paving" + } + }, + { + "if": "tactile_paving=no", + "then": { + "en": "This stop does not have tactile paving" + } + } + ], + "question": { + "en": "Does this stop have tactile paving?" + } + }, + { + "id": "lit", + "mappings": [ + { + "if": "lit=yes", + "then": { + "en": "This stop is lit" + } + }, + { + "if": "lit=no", + "then": { + "en": "This stop is not lit" + } + } + ], + "question": { + "en": "Is this stop lit?" + } + }, + { + "id": "departures_board", + "mappings": [ + { + "if": "departures_board=yes", + "then": { + "en": "This stop has a departures board of unknown type" + }, + "hideInAnswer": true + }, + { + "if": "departures_board=realtime", + "then": { + "en": "This stop has a board showing realtime departure information" + } + }, + { + "if": "passenger_information_display=yes", + "then": { + "en": "This stop has a board showing realtime departure information" + }, + "hideInAnswer": true + }, + { + "if": "departures_board=timetable", + "then": { + "en": "This stop has a timetable showing regular departures" + } + }, + { + "if": "departures_board=interval", + "then": { + "en": "This stop has a timetable containing just the interval between departures" + } + }, + { + "if": "departures_board=no", + "then": { + "en": "This stop does not have a departures board" + } + } + ] + }, + { + "render": { + "en": "

    {_contained_routes_count} routes stop at this stop

      {_contained_routes}
    " + }, + "condition": "_contained_routes~*", + "id": "contained_routes" + } + ], + "filter": [], + "allowMove": false +} \ No newline at end of file diff --git a/assets/themes/mapcomplete-changes/mapcomplete-changes.json b/assets/themes/mapcomplete-changes/mapcomplete-changes.json index 56650d440..8fc630f67 100644 --- a/assets/themes/mapcomplete-changes/mapcomplete-changes.json +++ b/assets/themes/mapcomplete-changes/mapcomplete-changes.json @@ -311,6 +311,10 @@ "if": "theme=toilets", "then": "./assets/themes/toilets/toilets.svg" }, + { + "if": "theme=transit", + "then": "./assets/layers/transit_stops/bus_stop.svg" + }, { "if": "theme=trees", "then": "./assets/themes/trees/logo.svg" diff --git a/assets/themes/transit/transit.json b/assets/themes/transit/transit.json new file mode 100644 index 000000000..271510c2f --- /dev/null +++ b/assets/themes/transit/transit.json @@ -0,0 +1,51 @@ +{ + "id": "transit", + "maintainer": "Robin van der Linde", + "version": "20220406", + "title": { + "en": "Bus routes" + }, + "description": { + "en": "Plan your trip with the help of the public transport system." + }, + "icon": "./assets/layers/transit_stops/bus_stop.svg", + "startZoom": 20, + "startLat": 53.21333, + "startLon": 6.56963, + "layers": [ + "transit_stops", + "transit_routes", + { + "builtin": "bike_parking", + "override": { + "minzoom": 19, + "minzoomVisible": 19 + } + }, + { + "builtin": "parking", + "override": { + "minzoom": 19, + "minzoomVisible": 19 + } + }, + { + "builtin": "shelter", + "override": { + "minzoom": 19, + "minzoomVisible": 19, + "source": { + "osmTags": { + "and": [ + "amenity=shelter", + "shelter_type=public_transport" + ] + } + } + }, + "hideTagRenderingsWithLabels": [ + "shelter-type" + ] + } + ] +} \ No newline at end of file diff --git a/langs/layers/en.json b/langs/layers/en.json index 01aa1fabb..e077d30cd 100644 --- a/langs/layers/en.json +++ b/langs/layers/en.json @@ -3568,6 +3568,11 @@ "0": { "explanation": "{title()} has closed down permanently" } + }, + "nonDeleteMappings": { + "0": { + "then": "This is actually a pub" + } } }, "description": "A layer showing restaurants and fast-food amenities (with a special rendering for friteries)", @@ -5232,6 +5237,39 @@ "render": "School {name}" } }, + "shelter": { + "description": "Layer showing shelter structures", + "name": "Shelter", + "tagRenderings": { + "shelter-type": { + "mappings": { + "0": { + "then": "This is a shelter at a public transport stop." + }, + "1": { + "then": "This is a shelter protecting from rain at a picnic site." + }, + "2": { + "then": "This is a gazebo." + }, + "3": { + "then": "This is a small shelter, primarily intended for short breaks. Usually found in the mountains or alongside roads." + }, + "4": { + "then": "This is a shed with 3 walls, primarily intended for camping." + }, + "5": { + "then": "This is a pavilion" + } + }, + "question": "What kind of shelter is this?", + "render": "Shelter type: {shelter_type}" + } + }, + "title": { + "render": "Shelter" + } + }, "shops": { "deletion": { "extraDeleteReasons": { @@ -5279,11 +5317,13 @@ } }, "tagRenderings": { + "2": { + "override": { + "question": "What kind of shop is this?" + } + }, "shops-name": { "question": "What is the name of this shop?" - }, - "shops-type-from-id": { - "question": "What kind of shop is this?" } }, "title": { @@ -5962,6 +6002,169 @@ "render": "Trail" } }, + "transit_routes": { + "description": "Layer showing bus lines", + "mapRendering": { + "0": { + "color": { + "render": "#ff0000" + } + } + }, + "name": "Bus lines", + "tagRenderings": { + "colour": { + "question": "What is the colour for this bus line?", + "render": "This bus line has the color {colour}" + }, + "from": { + "question": "What is the starting point for this bus line?", + "render": "This bus line begins at {from}" + }, + "name": { + "question": "What is the name for this bus line? (i.e. Bus XX: From => Via => To)" + }, + "network": { + "question": "What network does this bus line belong to?", + "render": "This bus line is part of the {network} network" + }, + "operator": { + "question": "What company operates this bus line?", + "render": "This bus line is operated by {operator}" + }, + "to": { + "question": "What is the ending point for this bus line?", + "render": "This bus line ends at {to}" + }, + "via": { + "question": "What is the via point for this bus line?", + "render": "This bus line goes via {via}" + } + }, + "title": { + "mappings": { + "0": { + "then": "{name}" + } + }, + "render": "Bus line" + } + }, + "transit_stops": { + "description": "Layer showing different types of transit stops.", + "name": "Transit Stops", + "tagRenderings": { + "bench": { + "mappings": { + "0": { + "then": "This stop has a bench" + }, + "1": { + "then": "This stop does not have a bench" + }, + "2": { + "then": "This stop has a bench, that's separately mapped" + } + }, + "question": "Does this stop have a bench?" + }, + "bin": { + "mappings": { + "0": { + "then": "This stop has a bin" + }, + "1": { + "then": "This stop does not have a bin" + }, + "2": { + "then": "This stop has a bin, that's separately mapped" + } + }, + "question": "Does this stop have a bin?" + }, + "contained_routes": { + "render": "

    {_contained_routes_count} routes stop at this stop

      {_contained_routes}
    " + }, + "departures_board": { + "mappings": { + "0": { + "then": "This stop has a departures board of unknown type" + }, + "1": { + "then": "This stop has a board showing realtime departure information" + }, + "2": { + "then": "This stop has a board showing realtime departure information" + }, + "3": { + "then": "This stop has a timetable showing regular departures" + }, + "4": { + "then": "This stop has a timetable containing just the interval between departures" + }, + "5": { + "then": "This stop does not have a departures board" + } + } + }, + "lit": { + "mappings": { + "0": { + "then": "This stop is lit" + }, + "1": { + "then": "This stop is not lit" + } + }, + "question": "Is this stop lit?" + }, + "shelter": { + "mappings": { + "0": { + "then": "This stop has a shelter" + }, + "1": { + "then": "This stop does not have a shelter" + }, + "2": { + "then": "This stop has a shelter, that's separately mapped" + } + }, + "question": "Does this stop have a shelter?" + }, + "stop_name": { + "freeform": { + "placeholder": "Name of the stop" + }, + "mappings": { + "0": { + "then": "This stop has no name" + } + }, + "question": "What is the name of this stop?", + "render": "This stop is called {name}" + }, + "tactile_paving": { + "mappings": { + "0": { + "then": "This stop has tactile paving" + }, + "1": { + "then": "This stop does not have tactile paving" + } + }, + "question": "Does this stop have tactile paving?" + } + }, + "title": { + "mappings": { + "0": { + "then": "Stop {name}" + } + }, + "render": "Transit Stop" + } + }, "tree_node": { "description": "A layer showing trees", "name": "Tree", diff --git a/langs/layers/it.json b/langs/layers/it.json index a76a3b1d8..09efa26b6 100644 --- a/langs/layers/it.json +++ b/langs/layers/it.json @@ -1756,6 +1756,218 @@ "render": "Microbiblioteca" } }, + "recycling": { + "description": "Un livello con i contenitori e centri per la raccolta rifiuti riciclabili", + "filter": { + "0": { + "options": { + "0": { + "question": "Aperto ora" + } + } + }, + "1": { + "options": { + "0": { + "question": "Tutti i tipi di rifiuti" + }, + "1": { + "question": "Riciclo di batterie" + }, + "2": { + "question": "Riciclo di confezioni per bevande" + }, + "3": { + "question": "Riciclo di lattine" + }, + "4": { + "question": "Riciclo di abiti" + }, + "5": { + "question": "Riciclo di olio da cucina" + }, + "6": { + "question": "Riciclo di olio da motore" + }, + "7": { + "question": "Riciclo di umido" + }, + "8": { + "question": "Riciclo di bottiglie di vetro" + }, + "9": { + "question": "Riciclo di vetro" + }, + "10": { + "question": "Riciclo di giornali" + }, + "11": { + "question": "Riciclo di carta" + }, + "12": { + "question": "Riciclo di bottiglie di plastica" + }, + "13": { + "question": "Riciclo di confezioni di plastica" + }, + "14": { + "question": "Riciclo di plastica" + }, + "15": { + "question": "Riciclo di rottami metallici" + }, + "16": { + "question": "Riciclo di piccoli elettrodomestici" + }, + "17": { + "question": "Riciclo di secco" + } + } + } + }, + "name": "Riciclo", + "presets": { + "0": { + "title": "un contenitore per il riciclo" + }, + "1": { + "title": "un centro di riciclo" + } + }, + "tagRenderings": { + "container-location": { + "mappings": { + "0": { + "then": "E' un contenitore sotterraneo" + }, + "1": { + "then": "Questo contenitore è al chiuso" + }, + "2": { + "then": "Questo contenitore è all'aperto" + } + }, + "question": "Dove si trova questo contenitore?" + }, + "opening_hours": { + "mappings": { + "0": { + "then": "24/7" + } + }, + "question": "Quali sono gli orari di apertura di questo impianto di raccolta e riciclo?" + }, + "operator": { + "question": "Quale azienda gestisce questo impianto di raccolta e riciclo?", + "render": "Questa struttura di raccola e riciclo è gestita da {operator}" + }, + "recycling-accepts": { + "mappings": { + "0": { + "then": "Batterie" + }, + "1": { + "then": "Cartoni per bevande" + }, + "2": { + "then": "Lattine" + }, + "3": { + "then": "Abiti" + }, + "4": { + "then": "Olio da cucina" + }, + "5": { + "then": "Olio di motore" + }, + "6": { + "then": "Verde" + }, + "7": { + "then": "Umido" + }, + "8": { + "then": "Bottiglie di vetro" + }, + "9": { + "then": "Vetro" + }, + "10": { + "then": "Giornali" + }, + "11": { + "then": "Carta" + }, + "12": { + "then": "Bottiglie di platica" + }, + "13": { + "then": "Confezioni di plastica" + }, + "14": { + "then": "Plastica" + }, + "15": { + "then": "Rottami metallici" + }, + "16": { + "then": "Scarpe" + }, + "17": { + "then": "Piccoli elettrodomestici" + }, + "18": { + "then": "Piccoli elettrodomestici" + }, + "19": { + "then": "Aghi e oggetti appuntiti" + }, + "20": { + "then": "Secco" + } + }, + "question": "Cosa si può riciclare qui?" + }, + "recycling-centre-name": { + "mappings": { + "0": { + "then": "Questo centro raccolta e riciclo rifiuti non ha un nome specifico" + } + }, + "question": "Come si chiama questo centro raccolta e riciclo rifiuti?", + "render": "Questo centro raccolta e riciclo rifiuti si chiama {name}" + }, + "recycling-type": { + "mappings": { + "0": { + "then": "Questo è un contenitore per il riciclo di rifiuti" + }, + "1": { + "then": "Questo è un centro per la raccola e riciclo di rifiuti" + }, + "2": { + "then": "Contenitore per lo smaltimento del secco" + } + }, + "question": "Che tipo di raccolta è questo?" + } + }, + "title": { + "mappings": { + "0": { + "then": "Centro di riciclo rifiuti" + }, + "1": { + "then": "Centro di riciclo rifiuti" + }, + "2": { + "then": "Contenitore per il riciclo" + } + }, + "render": "Impianti di riciclo" + } + }, "slow_roads": { "tagRenderings": { "slow_roads-surface": { @@ -2259,6 +2471,158 @@ "render": "Punto panoramico" } }, + "waste_basket": { + "description": "Questo è un cestino dei rifiuti pubblico, un bidone della spazzatura, dove puoi buttare via la tua spazzatura", + "filter": { + "0": { + "options": { + "0": { + "question": "Tutti i tipi" + }, + "1": { + "question": "Cestino per sigarette" + }, + "2": { + "question": "Cestino per medicinali" + }, + "3": { + "question": "Cestino per escrementi dei cani" + }, + "4": { + "question": "Cestino per la spazzatura" + }, + "5": { + "question": "Cestino dei rifiuti per oggetti taglienti" + }, + "6": { + "question": "Cestino per la plastica" + } + } + }, + "1": { + "options": { + "0": { + "question": "Cestino per rifiuti con dispenser per sacchetti per escrementi dei cani" + } + } + } + }, + "mapRendering": { + "0": { + "iconSize": { + "mappings": { + "0": { + "then": "Cestino dei rifiuti" + } + } + } + } + }, + "name": "Cestino dei rifiuti", + "presets": { + "0": { + "title": "un cestino dei rifiuti" + } + }, + "tagRenderings": { + "dispensing_dog_bags": { + "mappings": { + "0": { + "then": "Questo cestino ha un distributore di sacchetti per escrementi dei cani" + }, + "1": { + "then": "Questo cestino non ha un distributore di sacchetti per escrementi dei cani" + }, + "2": { + "then": "Questo cestino non ha un distributore di sacchetti per escrementi dei cani" + } + }, + "question": "Questo cestino ha un distributore di sacchetti per escrementi dei cani?" + }, + "waste-basket-waste-types": { + "mappings": { + "0": { + "then": "Un cestino rifiuti per uso generico" + }, + "1": { + "then": "Un cestino rifiuti per uso generico" + }, + "2": { + "then": "Un cestino rifiuti per escrementi di cani" + }, + "3": { + "then": "Un cestino rifiuti per sigarette" + }, + "4": { + "then": "Un cestino rifiuti per medicinali" + }, + "5": { + "then": "Un cestino rifiuti per aghi e altri oggetti appuntiti" + }, + "6": { + "then": "Un cestino rifiuti per la plastica" + } + }, + "question": "Che tipo di cestino dei rifiuti è questo?" + } + }, + "title": { + "render": "Cestino dei rifiuti" + } + }, + "waste_disposal": { + "description": "Cestino per lo smaltimento dei rifiuti, contenitore di dimensioni medio grandi per lo smaltimento dei rifiuti (domestici)", + "filter": { + "0": { + "options": { + "0": { + "question": "Solo accesso pubblico" + } + } + } + }, + "name": "Contenitori per la raccolta differenziata", + "presets": { + "0": { + "description": "Cestino di dimensioni medio-grandi per lo smaltimento dei rifiuti (domestici)", + "title": "un raccoglitore per lo smaltimento rifiuti" + } + }, + "tagRenderings": { + "access": { + "mappings": { + "0": { + "then": "Questo cestino può essere usato da chiunque" + }, + "1": { + "then": "Questo cestino è privato" + }, + "2": { + "then": "Questo cestino è solo per residenti" + } + }, + "question": "Chi può utilizzare questo cestino per lo smaltimento dei rifiuti?", + "render": "Accesso: {access}" + }, + "disposal-location": { + "mappings": { + "0": { + "then": "Questo è un contenitore sotterraneo" + }, + "1": { + "then": "Questo contenitore è al chiuso" + }, + "2": { + "then": "Questo contenitore è all'aperto" + } + }, + "question": "Dove si trova questo contenitore?" + } + }, + "title": { + "render": "Smaltimento rifiuti" + } + }, "windturbine": { "name": "pala eolica", "presets": { diff --git a/langs/layers/nl.json b/langs/layers/nl.json index 96d2abc4f..3380b5390 100644 --- a/langs/layers/nl.json +++ b/langs/layers/nl.json @@ -5183,11 +5183,13 @@ } }, "tagRenderings": { + "2": { + "override": { + "question": "Wat voor soort winkel is dit?" + } + }, "shops-name": { "question": "Wat is de naam van deze winkel?" - }, - "shops-type-from-id": { - "question": "Wat voor soort winkel is dit?" } }, "title": { diff --git a/langs/themes/en.json b/langs/themes/en.json index fc2a5c198..4fb6f8226 100644 --- a/langs/themes/en.json +++ b/langs/themes/en.json @@ -953,6 +953,10 @@ "description": "A map of public toilets", "title": "Open Toilet Map" }, + "transit": { + "description": "Plan your trip with the help of the public transport system.", + "title": "Bus routes" + }, "trees": { "description": "Map all the trees!", "shortDescription": "Map all the trees", diff --git a/langs/themes/it.json b/langs/themes/it.json index f9518d0d4..fbed99743 100644 --- a/langs/themes/it.json +++ b/langs/themes/it.json @@ -577,6 +577,10 @@ "shortDescription": "Mappa tutti gli alberi", "title": "Alberi" }, + "waste": { + "description": "Mappa dei cestini per i rifiuti e i centri di raccolta e riciclo rifiuti.", + "title": "Rifiuti" + }, "waste_basket": { "description": "In questa cartina troverai i cestini dei rifiuti nei tuoi paraggi. Se manca un cestino, puoi inserirlo tu stesso", "shortDescription": "Una cartina dei cestini dei rifiuti", diff --git a/package.json b/package.json index 58c8dabfa..1d97a1908 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "generate:cache:speelplekken": "npm run generate:layeroverview && ts-node scripts/generateCache.ts speelplekken 14 ../MapComplete-data/speelplekken_cache/ 51.20 4.35 51.09 4.56", "generate:cache:natuurpunt": "npm run generate:layeroverview && ts-node scripts/generateCache.ts natuurpunt 12 ../MapComplete-data/natuurpunt_cache/ 50.40 2.1 51.54 6.4 --generate-point-overview nature_reserve,visitor_information_centre", "generate:cache:natuurpunt:mini": "ts-node scripts/generateCache.ts natuurpunt 12 ../../git/MapComplete-data/natuurpunt_cache_mini/ 51.00792239979105 4.497699737548828 51.0353492224462554 4.539070129394531 --generate-point-overview nature_reserve,visitor_information_centre", - "generate:layeroverview": "ts-node scripts/generateLayerOverview.ts --no-fail", + "generate:layeroverview": "ts-node scripts/generateLayerOverview.ts", "generate:licenses": "ts-node scripts/generateLicenseInfo.ts --no-fail", "query:licenses": "ts-node scripts/generateLicenseInfo.ts --query", "generate:report": "cd Docs/Tools && ./compileStats.sh && git commit . -m 'New statistics ands graphs' && git push", @@ -36,7 +36,7 @@ "generate:schemas": "ts2json-schema -p Models/ThemeConfig/Json/ -o Docs/Schemas/ -t tsconfig.json -R . -m \".*ConfigJson\" && ts-node scripts/fixSchemas.ts ", "generate:service-worker": "tsc service-worker.ts && git_hash=$(git rev-parse HEAD) && sed -i \"s/GITHUB-COMMIT/$git_hash/\" service-worker.js", "optimize-images": "cd assets/generated/ && find -name '*.png' -exec optipng '{}' \\; && echo 'PNGs are optimized'", - "reset:layeroverview": "echo {\\\"layers\\\":[], \\\"themes\\\":[]} > ./assets/generated/known_layers_and_themes.json && echo {\\\"layers\\\": []} > ./assets/generated/known_layers.json", + "reset:layeroverview": "echo {\\\"layers\\\":[], \\\"themes\\\":[]} > ./assets/generated/known_layers_and_themes.json && echo {\\\"layers\\\": []} > ./assets/generated/known_layers.json && rm ./asssets/generated/layers/* && rm ./assets/generated/themes/*", "generate": "mkdir -p ./assets/generated; npm run reset:layeroverview; npm run generate:images; npm run generate:charging-stations; npm run generate:translations; npm run generate:licenses; npm run generate:layeroverview; npm run generate:service-worker", "generate:charging-stations": "cd ./assets/layers/charging_station && ts-node csvToJson.ts && cd -", "prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh", diff --git a/scripts/ScriptUtils.ts b/scripts/ScriptUtils.ts index 651ad4351..a86f3b2d4 100644 --- a/scripts/ScriptUtils.ts +++ b/scripts/ScriptUtils.ts @@ -45,13 +45,103 @@ export default class ScriptUtils { }) } - - private static async DownloadJSON(url: string, headers?: any): Promise{ + + public static erasableLog(...text) { + process.stdout.write("\r " + text.join(" ") + " \r") + } + + public static sleep(ms) { + if (ms <= 0) { + process.stdout.write("\r \r") + return; + } + return new Promise((resolve) => { + process.stdout.write("\r Sleeping for " + (ms / 1000) + "s \r") + setTimeout(resolve, 1000); + }).then(() => ScriptUtils.sleep(ms - 1000)); + } + + public static getLayerPaths(): string[] { + return ScriptUtils.readDirRecSync("./assets/layers") + .filter(path => path.indexOf(".json") > 0) + .filter(path => path.indexOf(".proto.json") < 0) + .filter(path => path.indexOf("license_info.json") < 0) + } + + public static getLayerFiles(): { parsed: LayerConfigJson, path: string }[] { + return ScriptUtils.readDirRecSync("./assets/layers") + .filter(path => path.indexOf(".json") > 0) + .filter(path => path.indexOf(".proto.json") < 0) + .filter(path => path.indexOf("license_info.json") < 0) + .map(path => { + try { + const contents = readFileSync(path, "UTF8") + if (contents === "") { + throw "The file " + path + " is empty, did you properly save?" + } + + const parsed = JSON.parse(contents); + return {parsed, path} + } catch (e) { + console.error("Could not parse file ", "./assets/layers/" + path, "due to ", e) + throw e + } + }) + } + + public static getThemePaths(): string[] { + return ScriptUtils.readDirRecSync("./assets/themes") + .filter(path => path.endsWith(".json") && !path.endsWith(".proto.json")) + .filter(path => path.indexOf("license_info.json") < 0) + } + + public static getThemeFiles(): { parsed: LayoutConfigJson, path: string }[] { + return this.getThemePaths() + .map(path => { + try { + const contents = readFileSync(path, "UTF8"); + if (contents === "") { + throw "The file " + path + " is empty, did you properly save?" + } + const parsed = JSON.parse(contents); + return {parsed: parsed, path: path} + } catch (e) { + console.error("Could not read file ", path, "due to ", e) + throw e + } + }); + } + + public static TagInfoHistogram(key: string): Promise<{ + data: { count: number, value: string, fraction: number }[] + }> { + const url = `https://taginfo.openstreetmap.org/api/4/key/values?key=${key}&filter=all&lang=en&sortname=count&sortorder=desc&page=1&rp=17&qtype=value` + return ScriptUtils.DownloadJSON(url) + } + + public static async ReadSvg(path: string): Promise { + if (!existsSync(path)) { + throw "File not found: " + path + } + const root = await xml2js.parseStringPromise(readFileSync(path, "UTF8")) + return root.svg + } + + public static async ReadSvgSync(path: string, callback: ((svg: any) => void)): Promise { + xml2js.parseString(readFileSync(path, "UTF8"), {async: false}, (err, root) => { + if (err) { + throw err + } + callback(root["svg"]); + }) + } + + private static async DownloadJSON(url: string, headers?: any): Promise { const data = await ScriptUtils.Download(url, headers); return JSON.parse(data.content) } - private static Download(url, headers?: any): Promise<{content: string}> { + private static Download(url, headers?: any): Promise<{ content: string }> { return new Promise((resolve, reject) => { try { headers = headers ?? {} @@ -83,84 +173,4 @@ export default class ScriptUtils { } - public static erasableLog(...text) { - process.stdout.write("\r " + text.join(" ") + " \r") - } - - public static sleep(ms) { - if (ms <= 0) { - process.stdout.write("\r \r") - return; - } - return new Promise((resolve) => { - process.stdout.write("\r Sleeping for " + (ms / 1000) + "s \r") - setTimeout(resolve, 1000); - }).then(() => ScriptUtils.sleep(ms - 1000)); - } - - public static getLayerFiles(): { parsed: LayerConfigJson, path: string }[] { - return ScriptUtils.readDirRecSync("./assets/layers") - .filter(path => path.indexOf(".json") > 0) - .filter(path => path.indexOf(".proto.json") < 0) - .filter(path => path.indexOf("license_info.json") < 0) - .map(path => { - try { - const contents = readFileSync(path, "UTF8") - if (contents === "") { - throw "The file " + path + " is empty, did you properly save?" - } - - const parsed = JSON.parse(contents); - return {parsed, path} - } catch (e) { - console.error("Could not parse file ", "./assets/layers/" + path, "due to ", e) - throw e - } - }) - } - - public static getThemeFiles(): { parsed: LayoutConfigJson, path: string }[] { - return ScriptUtils.readDirRecSync("./assets/themes") - .filter(path => path.endsWith(".json") && !path.endsWith(".proto.json")) - .filter(path => path.indexOf("license_info.json") < 0) - .map(path => { - try { - const contents = readFileSync(path, "UTF8"); - if (contents === "") { - throw "The file " + path + " is empty, did you properly save?" - } - const parsed = JSON.parse(contents); - return {parsed: parsed, path: path} - } catch (e) { - console.error("Could not read file ", path, "due to ", e) - throw e - } - }); - } - - - public static TagInfoHistogram(key: string): Promise<{ - data: { count: number, value: string, fraction: number }[] - }> { - const url = `https://taginfo.openstreetmap.org/api/4/key/values?key=${key}&filter=all&lang=en&sortname=count&sortorder=desc&page=1&rp=17&qtype=value` - return ScriptUtils.DownloadJSON(url) - } - - public static async ReadSvg(path: string): Promise{ - if(!existsSync(path)){ - throw "File not found: "+path - } - const root = await xml2js.parseStringPromise(readFileSync(path, "UTF8")) - return root.svg - } - - public static async ReadSvgSync(path: string, callback: ((svg: any) => void)): Promise{ - xml2js.parseString(readFileSync(path, "UTF8"),{async: false} , (err, root) => { - if(err){ - throw err - } - callback(root["svg"]); - }) - } - } diff --git a/scripts/build.sh b/scripts/build.sh index 804ce9b52..c194cfe9d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -10,9 +10,10 @@ mkdir dist 2> /dev/null mkdir dist/assets 2> /dev/null # This script ends every line with '&&' to chain everything. A failure will thus stop the build -npm run generate:editor-layer-index +npm run generate:editor-layer-index && +npm run reset:layeroverview npm run generate && -npm run generate:layeroverview && # generate:layeroverview has to be run twice: the personal theme won't pick up all the layers otherwise +npm run generate:layeroverview --force && # generate:layeroverview has to be run twice: the personal theme won't pick up all the layers otherwise; first time happens in 'npm run generate' npm run test && npm run generate:layouts diff --git a/scripts/generateLayerOverview.ts b/scripts/generateLayerOverview.ts index 81a67a69a..0a5a43d44 100644 --- a/scripts/generateLayerOverview.ts +++ b/scripts/generateLayerOverview.ts @@ -1,10 +1,11 @@ import ScriptUtils from "./ScriptUtils"; -import {existsSync, mkdirSync, readFileSync, writeFileSync} from "fs"; +import {existsSync, mkdirSync, readFileSync, statSync, writeFileSync} from "fs"; import * as licenses from "../assets/generated/license_info.json" import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import Constants from "../Models/Constants"; import { + DoesImageExist, PrevalidateTheme, ValidateLayer, ValidateTagRenderings, @@ -25,6 +26,51 @@ import {Utils} from "../Utils"; class LayerOverviewUtils { + public static readonly layerPath = "./assets/generated/layers/" + public static readonly themePath = "./assets/generated/themes/" + + private static publicLayerIdsFrom(themefiles: LayoutConfigJson[]): Set { + const publicThemes = [].concat(...themefiles + .filter(th => !th.hideFromOverview)) + + return new Set([].concat(...publicThemes.map(th => this.extractLayerIdsFrom(th)))) + } + + private static extractLayerIdsFrom(themeFile: LayoutConfigJson, includeInlineLayers = true): string[] { + const publicLayerIds = [] + for (const publicLayer of themeFile.layers) { + if (typeof publicLayer === "string") { + publicLayerIds.push(publicLayer) + continue + } + if (publicLayer["builtin"] !== undefined) { + const bi = publicLayer["builtin"] + if (typeof bi === "string") { + publicLayerIds.push(bi) + continue + } + bi.forEach(id => publicLayerIds.push(id)) + continue + } + if (includeInlineLayers) { + publicLayerIds.push(publicLayer["id"]) + } + } + return publicLayerIds + } + + shouldBeUpdated(sourcefile: string | string[], targetfile: string): boolean { + if (!existsSync(targetfile)) { + return true; + } + const targetModified = statSync(targetfile).mtime + if (typeof sourcefile === "string") { + sourcefile = [sourcefile] + } + + return sourcefile.some(sourcefile => statSync(sourcefile).mtime > targetModified) + } + writeSmallOverview(themes: { id: string, title: any, shortDescription: any, icon: string, hideFromOverview: boolean, mustHaveLanguage: boolean, layers: (LayerConfigJson | string | { builtin })[] }[]) { const perId = new Map(); for (const theme of themes) { @@ -69,23 +115,23 @@ class LayerOverviewUtils { } writeTheme(theme: LayoutConfigJson) { - if (!existsSync("./assets/generated/themes")) { - mkdirSync("./assets/generated/themes"); + if (!existsSync(LayerOverviewUtils.themePath)) { + mkdirSync(LayerOverviewUtils.themePath); } - writeFileSync(`./assets/generated/themes/${theme.id}.json`, JSON.stringify(theme, null, " "), "UTF8"); + writeFileSync(`${LayerOverviewUtils.themePath}${theme.id}.json`, JSON.stringify(theme, null, " "), "UTF8"); } writeLayer(layer: LayerConfigJson) { - if (!existsSync("./assets/generated/layers")) { - mkdirSync("./assets/generated/layers"); + if (!existsSync(LayerOverviewUtils.layerPath)) { + mkdirSync(LayerOverviewUtils.layerPath); } - writeFileSync(`./assets/generated/layers/${layer.id}.json`, JSON.stringify(layer, null, " "), "UTF8"); + writeFileSync(`${LayerOverviewUtils.layerPath}${layer.id}.json`, JSON.stringify(layer, null, " "), "UTF8"); } - getSharedTagRenderings(knownImagePaths: Set): Map { + getSharedTagRenderings(doesImageExist: DoesImageExist): Map { const dict = new Map(); - - const validator = new ValidateTagRenderings(undefined, knownImagePaths); + + const validator = new ValidateTagRenderings(undefined, doesImageExist); for (const key in questions["default"]) { if (key === "id") { continue @@ -93,7 +139,7 @@ class LayerOverviewUtils { questions[key].id = key; questions[key]["source"] = "shared-questions" const config = questions[key] - validator.convertStrict(config, "generate-layer-overview:tagRenderings/questions.json:"+key) + validator.convertStrict(config, "generate-layer-overview:tagRenderings/questions.json:" + key) dict.set(key, config) } for (const key in icons["default"]) { @@ -104,9 +150,9 @@ class LayerOverviewUtils { continue } icons[key].id = key; - const config = icons[key] - validator.convertStrict(config, "generate-layer-overview:tagRenderings/icons.json:"+key) - dict.set(key,config) + const config = icons[key] + validator.convertStrict(config, "generate-layer-overview:tagRenderings/icons.json:" + key) + dict.set(key, config) } dict.forEach((value, key) => { @@ -149,16 +195,18 @@ class LayerOverviewUtils { } } - - main(_: string[]) { + main(args: string[]) { + + const forceReload = args.some(a => a == "--force") const licensePaths = new Set() for (const i in licenses) { licensePaths.add(licenses[i].path) } - - const sharedLayers = this.buildLayerIndex(licensePaths); - const sharedThemes = this.buildThemeIndex(licensePaths, sharedLayers) + const doesImageExist = new DoesImageExist(licensePaths, existsSync) + const sharedLayers = this.buildLayerIndex(doesImageExist, forceReload); + const recompiledThemes : string[] = [] + const sharedThemes = this.buildThemeIndex(doesImageExist, sharedLayers, recompiledThemes, forceReload) writeFileSync("./assets/generated/known_layers_and_themes.json", JSON.stringify({ "layers": Array.from(sharedLayers.values()), @@ -168,7 +216,7 @@ class LayerOverviewUtils { writeFileSync("./assets/generated/known_layers.json", JSON.stringify({layers: Array.from(sharedLayers.values())})) - { + if(recompiledThemes.length > 0) { // mapcomplete-changes shows an icon for each corresponding mapcomplete-theme const iconsPerTheme = Array.from(sharedThemes.values()).map(th => ({ @@ -188,28 +236,42 @@ class LayerOverviewUtils { console.log(green("All done!")) } - private buildLayerIndex(knownImagePaths: Set): Map { + private buildLayerIndex(doesImageExist: DoesImageExist, forceReload: boolean): Map { // First, we expand and validate all builtin layers. These are written to assets/generated/layers // At the same time, an index of available layers is built. console.log(" ---------- VALIDATING BUILTIN LAYERS ---------") - const sharedTagRenderings = this.getSharedTagRenderings(knownImagePaths); - const layerFiles = ScriptUtils.getLayerFiles(); + const sharedTagRenderings = this.getSharedTagRenderings(doesImageExist); const sharedLayers = new Map() const state: DesugaringContext = { tagRenderings: sharedTagRenderings, sharedLayers } const prepLayer = new PrepareLayer(state); - for (const sharedLayerJson of layerFiles) { - const context = "While building builtin layer " + sharedLayerJson.path - const fixed = prepLayer.convertStrict(sharedLayerJson.parsed, context) + const skippedLayers: string[] = [] + const recompiledLayers: string[] = [] + for (const sharedLayerPath of ScriptUtils.getLayerPaths()) { + + { + const targetPath = LayerOverviewUtils.layerPath + sharedLayerPath.substring(sharedLayerPath.lastIndexOf("/")) + if (!forceReload && !this.shouldBeUpdated(sharedLayerPath, targetPath)) { + const sharedLayer = JSON.parse(readFileSync(targetPath, "utf8")) + sharedLayers.set(sharedLayer.id, sharedLayer) + skippedLayers.push(sharedLayer.id) + continue; + } - if(fixed.source.osmTags["and"] === undefined){ + } + + const parsed = JSON.parse(readFileSync(sharedLayerPath, "utf8")) + const context = "While building builtin layer " + sharedLayerPath + const fixed = prepLayer.convertStrict(parsed, context) + + if (fixed.source.osmTags["and"] === undefined) { fixed.source.osmTags = {"and": [fixed.source.osmTags]} } - - const validator = new ValidateLayer(sharedLayerJson.path, true, knownImagePaths); + + const validator = new ValidateLayer(sharedLayerPath, true, doesImageExist); validator.convertStrict(fixed, context) if (sharedLayers.has(fixed.id)) { @@ -217,39 +279,18 @@ class LayerOverviewUtils { } sharedLayers.set(fixed.id, fixed) + recompiledLayers.push(fixed.id) this.writeLayer(fixed) } + + console.log("Recompiled layers " + recompiledLayers.join(", ") + " and skipped " + skippedLayers.length + " layers") + return sharedLayers; } - private static publicLayerIdsFrom(themefiles: LayoutConfigJson[]): Set { - const publicLayers = [].concat(...themefiles - .filter(th => !th.hideFromOverview) - .map(th => th.layers)) - - const publicLayerIds = new Set() - for (const publicLayer of publicLayers) { - if (typeof publicLayer === "string") { - publicLayerIds.add(publicLayer) - continue - } - if (publicLayer["builtin"] !== undefined) { - const bi = publicLayer["builtin"] - if (typeof bi === "string") { - publicLayerIds.add(bi) - continue - } - bi.forEach(id => publicLayerIds.add(id)) - continue - } - publicLayerIds.add(publicLayer.id) - } - return publicLayerIds - } - - private buildThemeIndex(knownImagePaths: Set, sharedLayers: Map): Map { + private buildThemeIndex(doesImageExist: DoesImageExist, sharedLayers: Map, recompiledThemes: string[], forceReload: boolean): Map { console.log(" ---------- VALIDATING BUILTIN THEMES ---------") const themeFiles = ScriptUtils.getThemeFiles(); const fixed = new Map(); @@ -258,23 +299,33 @@ class LayerOverviewUtils { const convertState: DesugaringContext = { sharedLayers, - tagRenderings: this.getSharedTagRenderings(knownImagePaths), + tagRenderings: this.getSharedTagRenderings(doesImageExist), publicLayers } - const nonDefaultLanguages : {theme: string, language: string}[] = [] + const skippedThemes: string[] = [] for (const themeInfo of themeFiles) { + + const themePath = themeInfo.path; let themeFile = themeInfo.parsed - const themePath = themeInfo.path + + { + const targetPath = LayerOverviewUtils.themePath + "/" + themePath.substring(themePath.lastIndexOf("/")) + const usedLayers = Array.from(LayerOverviewUtils.extractLayerIdsFrom(themeFile, false)) + .map(id => LayerOverviewUtils.layerPath + id + ".json") + if (!forceReload && !this.shouldBeUpdated([themePath, ...usedLayers], targetPath)) { + fixed.set(themeFile.id, JSON.parse(readFileSync(LayerOverviewUtils.themePath+themeFile.id+".json", 'utf8'))) + skippedThemes.push(themeFile.id) + continue; + } + recompiledThemes.push(themeFile.id) + } new PrevalidateTheme().convertStrict(themeFile, themePath) try { themeFile = new PrepareTheme(convertState).convertStrict(themeFile, themePath) - if (knownImagePaths === undefined) { - throw "Could not load known images/licenses" - } - new ValidateThemeAndLayers(knownImagePaths, themePath, true, convertState.tagRenderings) + new ValidateThemeAndLayers(doesImageExist, themePath, true, convertState.tagRenderings) .convertStrict(themeFile, themePath) this.writeTheme(themeFile) @@ -293,6 +344,9 @@ class LayerOverviewUtils { mustHaveLanguage: t.mustHaveLanguage?.length > 0, } })); + + console.log("Recompiled themes " + recompiledThemes.join(", ") + " and skipped " + skippedThemes.length + " themes") + return fixed; } diff --git a/test/UI/Popup/TagRenderingQuestion.spec.ts b/test/UI/Popup/TagRenderingQuestion.spec.ts index c07048b49..c4bdd453f 100644 --- a/test/UI/Popup/TagRenderingQuestion.spec.ts +++ b/test/UI/Popup/TagRenderingQuestion.spec.ts @@ -4,6 +4,7 @@ import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; import TagRenderingQuestion from "../../../UI/Popup/TagRenderingQuestion"; import {UIEventSource} from "../../../Logic/UIEventSource"; import { expect } from 'chai'; +import Locale from "../../../UI/i18n/Locale"; describe("TagRenderingQuestion", () => { @@ -27,6 +28,7 @@ describe("TagRenderingQuestion", () => { it("should have a freeform text field with a type explanation", () => { + Locale.language.setData("en") const configJson = { id: "test-tag-rendering", question: "Question?", diff --git a/test/scripts/GenerateCache.spec.ts b/test/scripts/GenerateCache.spec.ts index ea8acb4e5..a57e598b9 100644 --- a/test/scripts/GenerateCache.spec.ts +++ b/test/scripts/GenerateCache.spec.ts @@ -28,23 +28,28 @@ function initDownloads(query: string){ describe("GenerateCache", () => { it("should generate a cached file for the Natuurpunt-theme", async () => { - if (existsSync("/tmp/np-cache")) { - ScriptUtils.readDirRecSync("/tmp/np-cache").forEach(p => unlinkSync(p)) - rmdirSync("/tmp/np-cache") + // We use /var/tmp instead of /tmp, as more OS's (such as MAC) have this + const dir = "/var/tmp/" + if(!existsSync(dir)){ + console.log("Not executing caching test: no temp directory found") } - mkdirSync("/tmp/np-cache") + if (existsSync(dir+"/np-cache")) { + ScriptUtils.readDirRecSync(dir+"np-cache").forEach(p => unlinkSync(p)) + rmdirSync(dir+"np-cache") + } + mkdirSync(dir+"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!%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", "12", - "/tmp/np-cache", + dir+"np-cache", "51.15423567022531", "3.250579833984375", "51.162821593316934", "3.262810707092285", "--generate-point-overview", "nature_reserve,visitor_information_centre" ]) await ScriptUtils.sleep(500) - const birdhides = JSON.parse(readFileSync("/tmp/np-cache/natuurpunt_birdhide_12_2085_1368.geojson", "UTF8")) + const birdhides = JSON.parse(readFileSync(dir+"np-cache/natuurpunt_birdhide_12_2085_1368.geojson", "UTF8")) expect(birdhides.features.length).deep.equal(5) expect(birdhides.features.some(f => f.properties.id === "node/5158056232"), "Didn't find birdhide node/5158056232 ").true