diff --git a/assets/layers/aerialway/aerialway.json b/assets/layers/aerialway/aerialway.json index bc2427975..0b5944c70 100644 --- a/assets/layers/aerialway/aerialway.json +++ b/assets/layers/aerialway/aerialway.json @@ -134,6 +134,27 @@ } }, "opening_hours", + { + "id": "oneway", + "question": { + "en": "In what direction can this aerialway be taken?" + }, + "mappings": [ + { + "if": "oneway=yes", + "alsoShowIf": "oneway=", + "then": { + "en": "This aerialway can only be taken to the top" + } + }, + { + "if": "oneway=no", + "then": { + "en": "This aerialway can be taken in both directions" + } + } + ] + }, { "id": "length", "render": { @@ -144,7 +165,20 @@ "lineRendering": [ { "width": "4", - "color": "black" + "color": "black", + "imageAlongWay": [ { + "if": "oneway=no", + "then": "./assets/png/twoway.png" + },{ + "if": { + "or": [ + "oneway=yes", + "oneway=" + ] + }, + "then": "./assets/png/oneway.png" + } + ] } ], "id": "aerialway", diff --git a/assets/layers/ski_piste/ski_piste.json b/assets/layers/ski_piste/ski_piste.json index 1cd365916..b2063eab1 100644 --- a/assets/layers/ski_piste/ski_piste.json +++ b/assets/layers/ski_piste/ski_piste.json @@ -96,7 +96,8 @@ "then": "gray" } ] - } + }, + "imageAlongWay": "./assets/png/oneway.png" } ], "id": "ski_piste", diff --git a/assets/png/license_info.json b/assets/png/license_info.json index d5571eb0c..548e52793 100644 --- a/assets/png/license_info.json +++ b/assets/png/license_info.json @@ -14,5 +14,21 @@ "Pieter Vander Vennet" ], "sources": [] + }, + { + "path": "twoway.png", + "license": "CC0-1.0", + "authors": [ + "Pieter Vander Vennet" + ], + "sources": [] + }, + { + "path": "twoway.svg", + "license": "CC0-1.0", + "authors": [ + "Pieter Vander Vennet" + ], + "sources": [] } ] \ No newline at end of file diff --git a/assets/png/twoway.png b/assets/png/twoway.png new file mode 100644 index 000000000..e57d53302 Binary files /dev/null and b/assets/png/twoway.png differ diff --git a/assets/png/twoway.png.license b/assets/png/twoway.png.license new file mode 100644 index 000000000..2452bee1e --- /dev/null +++ b/assets/png/twoway.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Pieter Vander Vennet +SPDX-License-Identifier: CC0 \ No newline at end of file diff --git a/assets/png/twoway.svg b/assets/png/twoway.svg new file mode 100644 index 000000000..7a28a60e2 --- /dev/null +++ b/assets/png/twoway.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + diff --git a/assets/png/twoway.svg.license b/assets/png/twoway.svg.license new file mode 100644 index 000000000..2452bee1e --- /dev/null +++ b/assets/png/twoway.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Pieter Vander Vennet +SPDX-License-Identifier: CC0 \ No newline at end of file diff --git a/assets/themes/ski/ski.json b/assets/themes/ski/ski.json index 1209804a7..2211febf9 100644 --- a/assets/themes/ski/ski.json +++ b/assets/themes/ski/ski.json @@ -7,7 +7,7 @@ "en": "Everything you need to go skiing" }, "icon": "./assets/layers/aerialway/chair_lift.svg", - "enableTerrain": true, + "enableTerrain": false, "layers": [ "ski_piste", "aerialway", diff --git a/package.json b/package.json index 7b8e6915c..3eaa0a661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcomplete", - "version": "0.37.3", + "version": "0.37.4", "repository": "https://github.com/pietervdvn/MapComplete", "description": "A small website to edit OSM easily", "bugs": "https://github.com/pietervdvn/MapComplete/issues", diff --git a/src/Logic/Tags/And.ts b/src/Logic/Tags/And.ts index dff0919f8..6654ac99e 100644 --- a/src/Logic/Tags/And.ts +++ b/src/Logic/Tags/And.ts @@ -4,6 +4,7 @@ import { TagUtils } from "./TagUtils" import { Tag } from "./Tag" import { RegexTag } from "./RegexTag" import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { ExpressionSpecification } from "maplibre-gl" export class And extends TagsFilter { public and: TagsFilter[] @@ -429,4 +430,8 @@ export class And extends TagsFilter { f(this) this.and.forEach((sub) => sub.visit(f)) } + + asMapboxExpression(): ExpressionSpecification { + return ["all", ...this.and.map(t => t.asMapboxExpression())] + } } diff --git a/src/Logic/Tags/Or.ts b/src/Logic/Tags/Or.ts index a0c0f6622..2bda9681c 100644 --- a/src/Logic/Tags/Or.ts +++ b/src/Logic/Tags/Or.ts @@ -2,6 +2,7 @@ import { TagsFilter } from "./TagsFilter" import { TagUtils } from "./TagUtils" import { And } from "./And" import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { ExpressionSpecification } from "maplibre-gl" export class Or extends TagsFilter { public or: TagsFilter[] @@ -288,4 +289,8 @@ export class Or extends TagsFilter { f(this) this.or.forEach((t) => t.visit(f)) } + + asMapboxExpression(): ExpressionSpecification { + return ["any", ...this.or.map(t => t.asMapboxExpression())] + } } diff --git a/src/Logic/Tags/RegexTag.ts b/src/Logic/Tags/RegexTag.ts index f024f8251..4f3c82f9b 100644 --- a/src/Logic/Tags/RegexTag.ts +++ b/src/Logic/Tags/RegexTag.ts @@ -1,6 +1,7 @@ import { Tag } from "./Tag" import { TagsFilter } from "./TagsFilter" import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { ExpressionSpecification } from "maplibre-gl" export class RegexTag extends TagsFilter { public readonly key: RegExp | string @@ -357,4 +358,11 @@ export class RegexTag extends TagsFilter { visit(f: (TagsFilter) => void) { f(this) } + + asMapboxExpression(): ExpressionSpecification { + if(typeof this.key=== "string" && typeof this.value === "string" ) { + return [this.invert ? "!=" : "==", ["get",this.key], this.value] + } + throw "TODO" + } } diff --git a/src/Logic/Tags/Tag.ts b/src/Logic/Tags/Tag.ts index b532b7053..cd8146ccd 100644 --- a/src/Logic/Tags/Tag.ts +++ b/src/Logic/Tags/Tag.ts @@ -1,10 +1,12 @@ import { Utils } from "../../Utils" import { TagsFilter } from "./TagsFilter" import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { ExpressionSpecification } from "maplibre-gl" export class Tag extends TagsFilter { public key: string public value: string + constructor(key: string, value: string) { super() this.key = key @@ -63,7 +65,7 @@ export class Tag extends TagsFilter { asOverpass(): string[] { if (this.value === "") { // NOT having this key - return ['[!"' + this.key + '"]'] + return ["[!\"" + this.key + "\"]"] } return [`["${this.key}"="${this.value}"]`] } @@ -81,7 +83,7 @@ export class Tag extends TagsFilter { asHumanString( linkToWiki?: boolean, shorten?: boolean, - currentProperties?: Record + currentProperties?: Record, ) { let v = this.value if (typeof v !== "string") { @@ -165,4 +167,16 @@ export class Tag extends TagsFilter { visit(f: (tagsFilter: TagsFilter) => void) { f(this) } + + asMapboxExpression(): ExpressionSpecification { + if (this.value === "") { + return [ + "any", + ["!", ["has", this.key]], + ["==", ["get", this.key], ""], + ] + + } + return ["==", ["get", this.key], this.value] + } } diff --git a/src/Logic/Tags/TagsFilter.ts b/src/Logic/Tags/TagsFilter.ts index e925a76ef..c75b7f6f4 100644 --- a/src/Logic/Tags/TagsFilter.ts +++ b/src/Logic/Tags/TagsFilter.ts @@ -1,4 +1,5 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { ExpressionSpecification } from "maplibre-gl" export abstract class TagsFilter { abstract asOverpass(): string[] @@ -63,4 +64,6 @@ export abstract class TagsFilter { * Walks the entire tree, every tagsFilter will be passed into the function once */ abstract visit(f: (tagsFilter: TagsFilter) => void) + + abstract asMapboxExpression(): ExpressionSpecification } diff --git a/src/Models/ThemeConfig/Json/LineRenderingConfigJson.ts b/src/Models/ThemeConfig/Json/LineRenderingConfigJson.ts index ffc5d1ea8..e7bbc86dd 100644 --- a/src/Models/ThemeConfig/Json/LineRenderingConfigJson.ts +++ b/src/Models/ThemeConfig/Json/LineRenderingConfigJson.ts @@ -1,4 +1,7 @@ import { MinimalTagRenderingConfigJson } from "./TagRenderingConfigJson" +import { MappingConfigJson } from "./QuestionableTagRenderingConfigJson" +import { TagsFilter } from "../../../Logic/Tags/TagsFilter" +import { TagConfigJson } from "./TagConfigJson" /** * The LineRenderingConfig gives all details onto how to render a single line of a feature. @@ -74,4 +77,12 @@ export default interface LineRenderingConfigJson { * type: int */ offset?: number | MinimalTagRenderingConfigJson + /** + * question: What PNG-image should be shown along the way? + * + * ifunset: no image is shown along the way + * suggestions: [{if: "./assets/png/oneway.png", then: "Show a oneway error"}] + * type: image + */ + imageAlongWay?: {if: TagConfigJson, then: string}[] | string } diff --git a/src/Models/ThemeConfig/LineRenderingConfig.ts b/src/Models/ThemeConfig/LineRenderingConfig.ts index 57f01e8d1..bb52e2cff 100644 --- a/src/Models/ThemeConfig/LineRenderingConfig.ts +++ b/src/Models/ThemeConfig/LineRenderingConfig.ts @@ -1,7 +1,8 @@ import WithContextLoader from "./WithContextLoader" import TagRenderingConfig from "./TagRenderingConfig" -import { Utils } from "../../Utils" import LineRenderingConfigJson from "./Json/LineRenderingConfigJson" +import { TagUtils } from "../../Logic/Tags/TagUtils" +import { TagsFilter } from "../../Logic/Tags/TagsFilter" export default class LineRenderingConfig extends WithContextLoader { public readonly color: TagRenderingConfig @@ -12,6 +13,7 @@ export default class LineRenderingConfig extends WithContextLoader { public readonly fill: TagRenderingConfig public readonly fillColor: TagRenderingConfig public readonly leftRightSensitive: boolean + public readonly imageAlongWay: { if?: TagsFilter, then: string }[] constructor(json: LineRenderingConfigJson, context: string) { super(json, context) @@ -21,6 +23,28 @@ export default class LineRenderingConfig extends WithContextLoader { this.lineCap = this.tr("lineCap", "round") this.fill = this.tr("fill", undefined) this.fillColor = this.tr("fillColor", undefined) + this.imageAlongWay = [] + if (json.imageAlongWay) { + if (typeof json.imageAlongWay === "string") { + this.imageAlongWay.push({ + then: json.imageAlongWay, + }) + } else { + for (let i = 0; i < json.imageAlongWay.length; i++) { + const imgAlong = json.imageAlongWay[i] + const ctx = context + ".imageAlongWay[" + i + "]" + if(!imgAlong.then.endsWith(".png")){ + throw "An imageAlongWay should always be a PNG image" + } + this.imageAlongWay.push( + { + if: TagUtils.Tag(imgAlong.if, ctx), + then: imgAlong.then, + }, + ) + } + } + } if (typeof json.offset === "string") { json.offset = parseFloat(json.offset) diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index e8824f417..3bae53507 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -1,5 +1,5 @@ import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" -import type { Map as MlMap } from "maplibre-gl" +import type { AddLayerObject, Map as MlMap } from "maplibre-gl" import { GeoJSONSource, Marker } from "maplibre-gl" import { ShowDataLayerOptions } from "./ShowDataLayerOptions" import { GeoOperations } from "../../Logic/GeoOperations" @@ -15,6 +15,7 @@ import * as range_layer from "../../../assets/layers/range/range.json" import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" import FilteredLayer from "../../Models/FilteredLayer" import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" +import { TagsFilter } from "../../Logic/Tags/TagsFilter" class PointRenderingLayer { private readonly _config: PointRenderingConfig @@ -36,7 +37,7 @@ class PointRenderingLayer { visibility?: Store, fetchStore?: (id: string) => Store>, onClick?: (feature: Feature) => void, - selectedElement?: Store<{ properties: { id?: string } }> + selectedElement?: Store<{ properties: { id?: string } }>, ) { this._visibility = visibility this._config = config @@ -89,7 +90,7 @@ class PointRenderingLayer { " while rendering", location, "of", - this._config + this._config, ) } const id = feature.properties.id + "-" + location @@ -97,7 +98,7 @@ class PointRenderingLayer { const loc = GeoOperations.featureToCoordinateWithRenderingType( feature, - location + location, ) if (loc === undefined) { continue @@ -153,7 +154,7 @@ class PointRenderingLayer { if (this._onClick) { const self = this - el.addEventListener("click", function (ev) { + el.addEventListener("click", function(ev) { ev.preventDefault() self._onClick(feature) // Workaround to signal the MapLibreAdaptor to ignore this click @@ -221,7 +222,7 @@ class LineRenderingLayer { config: LineRenderingConfig, visibility?: Store, fetchStore?: (id: string) => Store>, - onClick?: (feature: Feature) => void + onClick?: (feature: Feature) => void, ) { this._layername = layername this._map = map @@ -235,53 +236,60 @@ class LineRenderingLayer { map.on("styledata", () => self.update(features.features)) } - private async addSymbolLayer(sourceId: string, url: string = "./assets/png/oneway.png") { - const map = this._map - const imgId = url.replaceAll(/[/.-]/g, "_") - - if (map.getImage(imgId) === undefined) { - await new Promise((resolve, reject) => { - map.loadImage(url, (err, image) => { - if (err) { - console.error("Could not add symbol layer to line due to", err) - reject(err) - return - } - map.addImage(imgId, image) - resolve() - }) - }) - } - - map.addLayer({ - "id": "symbol-layer_" + this._layername + "-" + imgId, - 'type': 'symbol', - 'source': sourceId, - 'layout': { - 'symbol-placement': 'line', - 'symbol-spacing': 10, - 'icon-allow-overlap': true, - 'icon-rotation-alignment':'map', - 'icon-pitch-alignment':'map', - 'icon-image': imgId, - 'icon-size': 0.055, - 'visibility': 'visible' - } - }); - - } - public destruct(): void { this._map.removeLayer(this._layername + "_polygon") } + private async addSymbolLayer(sourceId: string, imageAlongWay: { if?: TagsFilter, then: string }[]) { + const map = this._map + await Promise.allSettled(imageAlongWay.map(async (img, i) => { + const imgId = img.then.replaceAll(/[/.-]/g, "_") + if (map.getImage(imgId) === undefined) { + await new Promise((resolve, reject) => { + map.loadImage(img.then, (err, image) => { + if (err) { + console.error("Could not add symbol layer to line due to", err) + return + } + map.addImage(imgId, image) + resolve() + }) + }) + } + + + const spec: AddLayerObject = { + "id": "symbol-layer_" + this._layername + "-" + i, + "type": "symbol", + "source": sourceId, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 10, + "icon-allow-overlap": true, + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "map", + "icon-image": imgId, + "icon-size": 0.055, + }, + } + const filter = img.if?.asMapboxExpression() + console.log(">>>", this._layername, imgId, img.if, "-->", filter) + if (filter) { + spec.filter = filter + } + map.addLayer(spec) + })) + + + } + /** * Calculate the feature-state for maplibre * @param properties * @private */ private calculatePropsFor( - properties: Record + properties: Record, ): Partial> { const config = this._config @@ -357,11 +365,8 @@ class LineRenderingLayer { }, }) - if(this._layername.startsWith("mapcomplete_ski_piste") || this._layername.startsWith("mapcomplete_aerialway")){ - // TODO FIXME properly enable this so that more layers can use this if appropriate - this.addSymbolLayer(this._layername) - }else{ - console.log("No oneway arrow for", this._layername) + if (this._config.imageAlongWay) { + this.addSymbolLayer(this._layername, this._config.imageAlongWay) } @@ -372,7 +377,7 @@ class LineRenderingLayer { } map.setFeatureState( { source: this._layername, id: feature.properties.id }, - this.calculatePropsFor(feature.properties) + this.calculatePropsFor(feature.properties), ) } @@ -415,7 +420,7 @@ class LineRenderingLayer { "Error while setting visibility of layers ", linelayer, polylayer, - e + e, ) } }) @@ -436,7 +441,7 @@ class LineRenderingLayer { console.trace( "Got a feature without ID; this causes rendering bugs:", feature, - "from" + "from", ) LineRenderingLayer.missingIdTriggered = true } @@ -448,7 +453,7 @@ class LineRenderingLayer { if (this._fetchStore === undefined) { map.setFeatureState( { source: this._layername, id }, - this.calculatePropsFor(feature.properties) + this.calculatePropsFor(feature.properties), ) } else { const tags = this._fetchStore(id) @@ -465,7 +470,7 @@ class LineRenderingLayer { } map.setFeatureState( { source: this._layername, id }, - this.calculatePropsFor(properties) + this.calculatePropsFor(properties), ) }) } @@ -489,7 +494,7 @@ export default class ShowDataLayer { layer: LayerConfig drawMarkers?: true | boolean drawLines?: true | boolean - } + }, ) { this._options = options const self = this @@ -500,7 +505,7 @@ export default class ShowDataLayer { mlmap: UIEventSource, features: FeatureSource, layers: LayerConfig[], - options?: Partial + options?: Partial, ) { const perLayer: PerLayerFeatureSourceSplitter = new PerLayerFeatureSourceSplitter( @@ -508,7 +513,7 @@ export default class ShowDataLayer { features, { constructStore: (features, layer) => new SimpleFeatureSource(layer, features), - } + }, ) perLayer.forEach((fs) => { new ShowDataLayer(mlmap, { @@ -522,7 +527,7 @@ export default class ShowDataLayer { public static showRange( map: Store, features: FeatureSource, - doShowLayer?: Store + doShowLayer?: Store, ): ShowDataLayer { return new ShowDataLayer(map, { layer: ShowDataLayer.rangeLayer, @@ -531,7 +536,8 @@ export default class ShowDataLayer { }) } - public destruct() {} + public destruct() { + } private zoomToCurrentFeatures(map: MlMap) { if (this._options.zoomToFeatures) { @@ -552,9 +558,9 @@ export default class ShowDataLayer { (this._options.layer.title === undefined ? undefined : (feature: Feature) => { - selectedElement?.setData(feature) - selectedLayer?.setData(this._options.layer) - }) + selectedElement?.setData(feature) + selectedLayer?.setData(this._options.layer) + }) if (this._options.drawLines !== false) { for (let i = 0; i < this._options.layer.lineRendering.length; i++) { const lineRenderingConfig = this._options.layer.lineRendering[i] @@ -565,7 +571,7 @@ export default class ShowDataLayer { lineRenderingConfig, doShowLayer, fetchStore, - onClick + onClick, ) this.onDestroy.push(l.destruct) } @@ -580,7 +586,7 @@ export default class ShowDataLayer { doShowLayer, fetchStore, onClick, - selectedElement + selectedElement, ) } }