From 3951b8d5c8c8d474f5cabe477b6da68c6dbf38be Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 28 Apr 2022 00:44:49 +0200 Subject: [PATCH 01/14] Remove obsolete conflict marker --- Logic/Osm/Overpass.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Logic/Osm/Overpass.ts b/Logic/Osm/Overpass.ts index a563c31bf9..b2205cb363 100644 --- a/Logic/Osm/Overpass.ts +++ b/Logic/Osm/Overpass.ts @@ -4,10 +4,6 @@ import {Utils} from "../../Utils"; import {UIEventSource} from "../UIEventSource"; import {BBox} from "../BBox"; import * as osmtogeojson from "osmtogeojson"; -<<<<<<< HEAD - -======= ->>>>>>> b54b5061cc72488ceb007177275fb600cce0a0dd /** * Interfaces overpass to get all the latest data From ef60cfad26c60a9d24b39934e265644495506061 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 28 Apr 2022 11:47:54 +0200 Subject: [PATCH 02/14] Do not warn for features that have been added --- Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index 8bf207aaaa..cfa15af1f5 100644 --- a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -41,17 +41,21 @@ export default class PerLayerFeatureSourceSplitter { } for (const f of features) { + let foundALayer = false; for (const layer of layers.data) { if (layer.layerDef.source.osmTags.matchesProperties(f.feature.properties)) { // We have found our matching layer! featuresPerLayer.get(layer.layerDef.id).push(f) + foundALayer = true; if (!layer.layerDef.passAllFeatures) { // If not 'passAllFeatures', we are done for this feature - break; + break } } } - noLayerFound.push(f) + if(!foundALayer){ + noLayerFound.push(f) + } } // At this point, we have our features per layer as a list From 684907a61b927670eb72eb703bfa565f29d1d126 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 28 Apr 2022 11:48:04 +0200 Subject: [PATCH 03/14] Restore show data layer --- UI/ShowDataLayer/ShowDataLayer.ts | 342 +++++++++++++++++++++++++++++- 1 file changed, 338 insertions(+), 4 deletions(-) diff --git a/UI/ShowDataLayer/ShowDataLayer.ts b/UI/ShowDataLayer/ShowDataLayer.ts index 3c25ee58ba..d5935b91e2 100644 --- a/UI/ShowDataLayer/ShowDataLayer.ts +++ b/UI/ShowDataLayer/ShowDataLayer.ts @@ -1,16 +1,350 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import {ShowDataLayerOptions} from "./ShowDataLayerOptions"; +import {ElementStorage} from "../../Logic/ElementStorage"; +import RenderingMultiPlexerFeatureSource from "../../Logic/FeatureSource/Sources/RenderingMultiPlexerFeatureSource"; +import ScrollableFullScreen from "../Base/ScrollableFullScreen"; +/* +// import 'leaflet-polylineoffset'; +We don't actually import it here. It is imported in the 'MinimapImplementation'-class, which'll result in a patched 'L' object. + Even though actually importing this here would seem cleaner, we don't do this as this breaks some scripts: + - Scripts are ran in ts-node + - ts-node doesn't define the 'window'-object + - Importing this will execute some code which needs the window object + + */ + +/** + * The data layer shows all the given geojson elements with the appropriate icon etc + */ export default class ShowDataLayer { + private static dataLayerIds = 0 + private readonly _leafletMap: UIEventSource; + private readonly _enablePopups: boolean; + private readonly _features: RenderingMultiPlexerFeatureSource + private readonly _layerToShow: LayerConfig; + private readonly _selectedElement: UIEventSource + private readonly allElements: ElementStorage + // Used to generate a fresh ID when needed + private _cleanCount = 0; + private geoLayer = undefined; + + /** + * A collection of functions to call when the current geolayer is unregistered + */ + private unregister: (() => void)[] = []; + private isDirty = false; + /** + * If the selected element triggers, this is used to lookup the correct layer and to open the popup + * Used to avoid a lot of callbacks on the selected element + * + * Note: the key of this dictionary is 'feature.properties.id+features.geometry.type' as one feature might have multiple presentations + * @private + */ + private readonly leafletLayersPerId = new Map() + private readonly showDataLayerid: number; + private readonly createPopup: (tags: UIEventSource, layer: LayerConfig) => ScrollableFullScreen /** * Creates a datalayer. - * + * * If 'createPopup' is set, this function is called every time that 'popupOpen' is called * @param options */ - constructor(options) { - + constructor(options: ShowDataLayerOptions & { layerToShow: LayerConfig }) { + this._leafletMap = options.leafletMap; + this.showDataLayerid = ShowDataLayer.dataLayerIds; + ShowDataLayer.dataLayerIds++ + if (options.features === undefined) { + console.error("Invalid ShowDataLayer invocation: options.features is undefed") + throw "Invalid ShowDataLayer invocation: options.features is undefed" + } + this._features = new RenderingMultiPlexerFeatureSource(options.features, options.layerToShow); + this._layerToShow = options.layerToShow; + this._selectedElement = options.selectedElement + this.allElements = options.state?.allElements; + this.createPopup = undefined; + this._enablePopups = options.popup !== undefined; + if (options.popup !== undefined) { + this.createPopup = options.popup + } + const self = this; + + options.leafletMap.addCallback(_ => { + return self.update(options) + } + ); + + this._features.features.addCallback(_ => self.update(options)); + options.doShowLayer?.addCallback(doShow => { + const mp = options.leafletMap.data; + if (mp === null) { + self.Destroy() + return true; + } + if (mp == undefined) { + return; + } + + if (doShow) { + if (self.isDirty) { + return self.update(options) + } else { + mp.addLayer(this.geoLayer) + } + } else { + if (this.geoLayer !== undefined) { + mp.removeLayer(this.geoLayer) + this.unregister.forEach(f => f()) + this.unregister = [] + } + } + + }) + + + this._selectedElement?.addCallbackAndRunD(selected => { + self.openPopupOfSelectedElement(selected) + }) + + this.update(options) + + } + + private Destroy() { + this.unregister.forEach(f => f()) + } + + private openPopupOfSelectedElement(selected) { + if (selected === undefined) { + return + } + if (this._leafletMap.data === undefined) { + return; + } + const v = this.leafletLayersPerId.get(selected.properties.id + selected.geometry.type) + if (v === undefined) { + return; + } + const leafletLayer = v.leafletlayer + const feature = v.feature + if (leafletLayer.getPopup().isOpen()) { + return; + } + if (selected.properties.id !== feature.properties.id) { + return; + } + + if (feature.id !== feature.properties.id) { + // Probably a feature which has renamed + // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too + console.log("Not opening the popup for", feature, "as probably renamed") + return; + } + if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again + ) { + leafletLayer.openPopup() + } + } + + private update(options: ShowDataLayerOptions): boolean { + if (this._features.features.data === undefined) { + return; + } + this.isDirty = true; + if (options?.doShowLayer?.data === false) { + return; + } + const mp = options.leafletMap.data; + + if (mp === null) { + return true; // Unregister as the map has been destroyed + } + if (mp === undefined) { + return; + } + + this._cleanCount++ + // clean all the old stuff away, if any + if (this.geoLayer !== undefined) { + mp.removeLayer(this.geoLayer); + } + + const self = this; + const data = { + type: "FeatureCollection", + features: [] + } + // @ts-ignore + this.geoLayer = L.geoJSON(data, { + style: feature => self.createStyleFor(feature), + pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng), + onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer) + }); + + const selfLayer = this.geoLayer; + const allFeats = this._features.features.data; + for (const feat of allFeats) { + if (feat === undefined) { + continue + } + try { + if (feat.geometry.type === "LineString") { + const coords = L.GeoJSON.coordsToLatLngs(feat.geometry.coordinates) + const tagsSource = this.allElements?.addOrGetElement(feat) ?? new UIEventSource(feat.properties); + let offsettedLine; + tagsSource + .map(tags => this._layerToShow.lineRendering[feat.lineRenderingIndex].GenerateLeafletStyle(tags), [], undefined, true) + .withEqualityStabilized((a, b) => { + if (a === b) { + return true + } + if (a === undefined || b === undefined) { + return false + } + return a.offset === b.offset && a.color === b.color && a.weight === b.weight && a.dashArray === b.dashArray + }) + .addCallbackAndRunD(lineStyle => { + if (offsettedLine !== undefined) { + self.geoLayer.removeLayer(offsettedLine) + } + // @ts-ignore + offsettedLine = L.polyline(coords, lineStyle); + this.postProcessFeature(feat, offsettedLine) + offsettedLine.addTo(this.geoLayer) + + // If 'self.geoLayer' is not the same as the layer the feature is added to, we can safely remove this callback + return self.geoLayer !== selfLayer + }) + } else { + this.geoLayer.addData(feat); + } + } catch (e) { + console.error("Could not add ", feat, "to the geojson layer in leaflet due to", e, e.stack) + } + } + + if (options.zoomToFeatures ?? false) { + if (this.geoLayer.getLayers().length > 0) { + try { + const bounds = this.geoLayer.getBounds() + mp.fitBounds(bounds, {animate: false}) + } catch (e) { + console.debug("Invalid bounds", e) + } + } + } + + if (options.doShowLayer?.data ?? true) { + mp.addLayer(this.geoLayer) + } + this.isDirty = false; + this.openPopupOfSelectedElement(this._selectedElement?.data) + } + + + private createStyleFor(feature) { + const tagsSource = this.allElements?.addOrGetElement(feature) ?? new UIEventSource(feature.properties); + // Every object is tied to exactly one layer + const layer = this._layerToShow + + const pointRenderingIndex = feature.pointRenderingIndex + const lineRenderingIndex = feature.lineRenderingIndex + + if (pointRenderingIndex !== undefined) { + const style = layer.mapRendering[pointRenderingIndex].GenerateLeafletStyle(tagsSource, this._enablePopups) + return { + icon: style + } + } + if (lineRenderingIndex !== undefined) { + return layer.lineRendering[lineRenderingIndex].GenerateLeafletStyle(tagsSource.data) + } + + throw "Neither lineRendering nor mapRendering defined for " + feature + } + + private pointToLayer(feature, latLng): L.Layer { + // Leaflet cannot handle geojson points natively + // We have to convert them to the appropriate icon + // Click handling is done in the next step + + const layer: LayerConfig = this._layerToShow + if (layer === undefined) { + return; + } + let tagSource = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties) + const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0) && this._enablePopups + let style: any = layer.mapRendering[feature.pointRenderingIndex].GenerateLeafletStyle(tagSource, clickable); + const baseElement = style.html; + if (!this._enablePopups) { + baseElement.SetStyle("cursor: initial !important") + } + style.html = style.html.ConstructElement() + return L.marker(latLng, { + icon: L.divIcon(style) + }); + } + + /** + * Post processing - basically adding the popup + * @param feature + * @param leafletLayer + * @private + */ + private postProcessFeature(feature, leafletLayer: L.Layer) { + const layer: LayerConfig = this._layerToShow + if (layer.title === undefined || !this._enablePopups) { + // No popup action defined -> Don't do anything + // or probably a map in the popup - no popups needed! + return; + } + + const popup = L.popup({ + autoPan: true, + closeOnEscapeKey: true, + closeButton: false, + autoPanPaddingTopLeft: [15, 15], + + }, leafletLayer); + + leafletLayer.bindPopup(popup); + + let infobox: ScrollableFullScreen = undefined; + const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}-${feature.pointRenderingIndex ?? feature.lineRenderingIndex}-${feature.multiLineStringIndex ?? ""}` + popup.setContent(`
Popup for ${feature.properties.id} ${feature.geometry.type} ${id} is loading
`) + const createpopup = this.createPopup; + leafletLayer.on("popupopen", () => { + if (infobox === undefined) { + const tags = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties); + infobox = createpopup(tags, layer); + + infobox.isShown.addCallback(isShown => { + if (!isShown) { + leafletLayer.closePopup() + } + }); + } + infobox.AttachTo(id) + infobox.Activate(); + this.unregister.push(() => { + console.log("Destroying infobox") + infobox.Destroy(); + }) + if (this._selectedElement?.data?.properties?.id !== feature.properties.id) { + this._selectedElement?.setData(feature) + } + + }); + + + // Add the feature to the index to open the popup when needed + this.leafletLayersPerId.set(feature.properties.id + feature.geometry.type, { + feature: feature, + leafletlayer: leafletLayer + }) } - } \ No newline at end of file From d06776a9029c685920bc15131073abc322bf198b Mon Sep 17 00:00:00 2001 From: Tobias Date: Fri, 29 Apr 2022 08:36:45 +0200 Subject: [PATCH 04/14] Add `/.vscode` to `.gitignore` This way, even though https://github.com/pietervdvn/MapComplete/pull/721 was rejected one can still use the validation features without having annoying local changes. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f3b0016740..b7f7822f05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ dist/* node_modules .cache/* .idea/* +.vscode/* scratch assets/editor-layer-index.json assets/generated/* From a776db1901e21dfb18f20849c1fc31342db7a1c6 Mon Sep 17 00:00:00 2001 From: Tobias Date: Fri, 29 Apr 2022 08:39:50 +0200 Subject: [PATCH 05/14] Fix validation issues with the theme-template There where a lot of "duplicate keys" warnings. And also a missing comma. --- Docs/theme-template.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Docs/theme-template.json b/Docs/theme-template.json index 8d904d0aad..448533bbfb 100644 --- a/Docs/theme-template.json +++ b/Docs/theme-template.json @@ -1,10 +1,10 @@ { - "#": "This JSON file is a small template to get you started developing a theme", - "#": "All lines starting with '#' are comments and can be removed in the theme if you don't need the explanation anymore", - "#": "Make sure to join our chat channel at https://app.element.io/#/room/#MapComplete:matrix.org for questions, sharing your theme, ...", - "#": "To actually load your theme: on linux: run a local webserver (e.g. `webfsd`) and go to https://mapcomplete.osm.be/theme?userlayout=http://127.0.0.1:8080/path-to-your-theme.json", - "#": "If you don't know how to run a webserver: go to https://www.base64encode.org/ , copy paste this entire document in the 'encode' field and encode it;", - "#": "Then, go to https://mapcomplete.osm.be/theme?userlayout=true#your-base64-encoded-file", + "#1": "This JSON file is a small template to get you started developing a theme", + "#2": "All lines starting with '#' are comments and can be removed in the theme if you don't need the explanation anymore", + "#3": "Make sure to join our chat channel at https://app.element.io/#/room/#MapComplete:matrix.org for questions, sharing your theme, ...", + "#4": "To actually load your theme: on linux: run a local webserver (e.g. `webfsd`) and go to https://mapcomplete.osm.be/theme?userlayout=http://127.0.0.1:8080/path-to-your-theme.json", + "#5": "If you don't know how to run a webserver: go to https://www.base64encode.org/ , copy paste this entire document in the 'encode' field and encode it;", + "#6": "Then, go to https://mapcomplete.osm.be/theme?userlayout=true#your-base64-encoded-file", "id": "template", "maintainer": "Write your name here", "version": "2022-03-12", @@ -41,7 +41,7 @@ ] } }, - "#": "Minzoom: only download and show if zoom >= minzoom", + "#4": "Minzoom: only download and show if zoom >= minzoom", "minzoom": 12, "name": { "en": "Name of the layer, as shown in the layer selection" @@ -67,17 +67,17 @@ "disused:key:={key}" ] }, - "#": "The maprenderings describe how a feature is shown on the map", + "#2": "The maprenderings describe how a feature is shown on the map", "mapRendering": [ { - "#": "Rendering block of a mapping which is shown for points AND at the center point of a line/area", + "#1": "Rendering block of a mapping which is shown for points AND at the center point of a line/area", "location": [ "point", "centroid" ], "icon": "circle:white;URL or path to icon.svg", - "iconSize": "30,30,center" - "#": "Note: all these values can be tagrenderings too, e.g.:", + "iconSize": "30,30,center", + "#2": "Note: all these values can be tagrenderings too, e.g.:", "label": { "render": { "en": "Item" @@ -93,12 +93,12 @@ } }, { - "#": "Rendering of a line", + "#1": "Rendering of a line", "color": "#ff0", "width": 5 } ], - "#": "Presets describe which new items can be added on click. Delete this block if adding a new point is not relevant", + "#3": "Presets describe which new items can be added on click. Delete this block if adding a new point is not relevant", "presets": [ { "title": { @@ -116,7 +116,7 @@ ] } ], - "#": "The tagrenderings are everything that must be shown and/or asked. Use a full tag-rendering section OR a single string to call a builtin tagrendering (see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/BuiltinQuestions.md)", + "#1": "The tagrenderings are everything that must be shown and/or asked. Use a full tag-rendering section OR a single string to call a builtin tagrendering (see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/BuiltinQuestions.md)", "tagRenderings": [ { "render": { @@ -181,4 +181,4 @@ ] } ] -} \ No newline at end of file +} From 671ba2a0bd782a7e866407a331b6ddaa5bcbd093 Mon Sep 17 00:00:00 2001 From: Tobias Date: Fri, 29 Apr 2022 08:48:54 +0200 Subject: [PATCH 06/14] Update theme-template.json --- Docs/theme-template.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Docs/theme-template.json b/Docs/theme-template.json index 448533bbfb..1ae6be4a00 100644 --- a/Docs/theme-template.json +++ b/Docs/theme-template.json @@ -10,7 +10,7 @@ "version": "2022-03-12", "title": { "en": "Title of your theme", - "#": "You can add extra languages here (and in all translation blocks), but make sure 'en' is everywhere" + "#1": "You can add extra languages here (and in all translation blocks), but make sure 'en' is everywhere" }, "description": { "en": "The welcome message goes here" @@ -19,14 +19,14 @@ "startZoom": 0, "startLat": 0, "startLon": 0, - "#": "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", + "#7": "For more options and configuration, see the documentation in LayoutConfig.json", + "#8": "`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": [ { "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", + "#1": "For a description on which tags are possible, see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Tags_format.md", "and": [ "key0=value0", "key1=value1", @@ -54,12 +54,12 @@ { "if": "name~*", "then": { - "#": "If name is given, use name instead as popup title. Note that the translation here uses '*' instead of 'en', which'll be shown in every language", + "#1": "If name is given, use name instead as popup title. Note that the translation here uses '*' instead of 'en', which'll be shown in every language", "*": "{name}" } } ], - "#": "Note that this is a tagRendering, but doesn't have a question field" + "#1": "Note that this is a tagRendering, but doesn't have a question field" }, "allowMove": true, "deletion": { @@ -143,7 +143,7 @@ }, "freeform": { "key": "some_osm_key", - "#": "Types can be found at https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialInputElements.md", + "#1": "Types can be found at https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialInputElements.md", "type": "nat" }, "mappings": [ @@ -152,7 +152,7 @@ "then": { "en": "Text on radio button which also is shown if somekey=some_value is present on the object" }, - "#": "If this option is picked as answer, these tags will be added additionally. However, if 'somekey=some_value' is present, the above rendering will be shown", + "#1": "If this option is picked as answer, these tags will be added additionally. However, if 'somekey=some_value' is present, the above rendering will be shown", "addExtraTags": [ "extrakey=extravalue" ] @@ -169,9 +169,9 @@ "icon": { "path": "/path/to/extra-icon.svg OR url", "class": "medium", - "#": "An extra icon supporting this option" + "#1": "An extra icon supporting this option" }, - "#": "If this option is picked as answer, these tags will be added additionally. However, if 'somekey=some_value' is present, the above rendering will be shown", + "#1": "If this option is picked as answer, these tags will be added additionally. However, if 'somekey=some_value' is present, the above rendering will be shown", "addExtraTags": [ "extrakey=extravalue" ] From af91c2649a9e913432e977d07f7d9bc6bd81cd70 Mon Sep 17 00:00:00 2001 From: Tobias Date: Fri, 29 Apr 2022 10:12:43 +0200 Subject: [PATCH 07/14] Update theme-template.json This change is needed in order to be able to take the template and open it as a theme without errors. --- Docs/theme-template.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Docs/theme-template.json b/Docs/theme-template.json index 1ae6be4a00..d3482666c5 100644 --- a/Docs/theme-template.json +++ b/Docs/theme-template.json @@ -63,9 +63,13 @@ }, "allowMove": true, "deletion": { - "softDeletionTags": [ - "disused:key:={key}" - ] + "softDeletionTags": { + "and": [ + "razed:tourism=artwork", + "tourism=" + ] + }, + "neededChangesets": 5 }, "#2": "The maprenderings describe how a feature is shown on the map", "mapRendering": [ From 87fa7a03c1f4904150173798c098736c63920046 Mon Sep 17 00:00:00 2001 From: Thierry1030 <64559939+Thierry1030@users.noreply.github.com> Date: Fri, 29 Apr 2022 20:53:31 +0200 Subject: [PATCH 08/14] added bike helmets as an extra option --- assets/layers/bicycle_rental/bicycle_rental.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/assets/layers/bicycle_rental/bicycle_rental.json b/assets/layers/bicycle_rental/bicycle_rental.json index 71443941e7..c95c4ded20 100644 --- a/assets/layers/bicycle_rental/bicycle_rental.json +++ b/assets/layers/bicycle_rental/bicycle_rental.json @@ -231,6 +231,14 @@ "de": "Rennräder können hier gemietet werden", "es": "Aquí se pueden alquilar bicicletas de carreras" } + }, + { + "if": "rental=bike_helmet" + "then": { + "en": "Bike helmets can be rented here", + "nl": "Fietshelmpen kunnen hier gehuurd worden", + "es": "Aquí se pueden alquilar cascos" + } } ] }, @@ -422,4 +430,4 @@ } ] } -} \ No newline at end of file +} From 9a24ab92c8f4527d246275da04b7dbea5c01b4ed Mon Sep 17 00:00:00 2001 From: Thierry1030 <64559939+Thierry1030@users.noreply.github.com> Date: Fri, 29 Apr 2022 20:58:01 +0200 Subject: [PATCH 09/14] fixed typo (comma) --- assets/layers/bicycle_rental/bicycle_rental.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/layers/bicycle_rental/bicycle_rental.json b/assets/layers/bicycle_rental/bicycle_rental.json index c95c4ded20..83f9c10867 100644 --- a/assets/layers/bicycle_rental/bicycle_rental.json +++ b/assets/layers/bicycle_rental/bicycle_rental.json @@ -233,7 +233,7 @@ } }, { - "if": "rental=bike_helmet" + "if": "rental=bike_helmet", "then": { "en": "Bike helmets can be rented here", "nl": "Fietshelmpen kunnen hier gehuurd worden", From 8b06ca77f90181a343812f76cd64fd382c5d02a9 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 30 Apr 2022 00:10:04 +0200 Subject: [PATCH 10/14] Add documentation for a theme function --- Customizations/AllKnownLayouts.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Customizations/AllKnownLayouts.ts b/Customizations/AllKnownLayouts.ts index 7da19ba75b..e93ae4a06e 100644 --- a/Customizations/AllKnownLayouts.ts +++ b/Customizations/AllKnownLayouts.ts @@ -229,5 +229,16 @@ export class AllKnownLayouts { } return dict; } + + public static GenerateDocumentationForTheme(theme: LayoutConfig): BaseUIElement{ + return new Combine([ + new Title(new Combine([theme.title, "(",theme.id+")"]), 2), + theme.description, + "This theme contains the following layers:", + new List(theme.layers.map(l => l.id)), + "Available languages:", + new List(theme.language) + ]) + } } From 73cec987c9d7d9cc35af35fe84f2e9b8a8cd5ae5 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 30 Apr 2022 00:18:47 +0200 Subject: [PATCH 11/14] Add paragraph and wikipedia options --- UI/Base/Paragraph.ts | 33 ++++++++++++++++++++++ UI/Wikipedia/WikidataPreviewBox.ts | 14 +++++----- UI/Wikipedia/WikipediaBox.ts | 45 ++++++++++++++++++++---------- langs/en.json | 1 + 4 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 UI/Base/Paragraph.ts diff --git a/UI/Base/Paragraph.ts b/UI/Base/Paragraph.ts new file mode 100644 index 0000000000..da57142573 --- /dev/null +++ b/UI/Base/Paragraph.ts @@ -0,0 +1,33 @@ +import BaseUIElement from "../BaseUIElement"; + +export class Paragraph extends BaseUIElement { + public readonly content: (string | BaseUIElement); + + constructor(html: (string | BaseUIElement)) { + super(); + this.content = html ?? ""; + } + + + AsMarkdown(): string { + let c:string ; + if(typeof this.content !== "string"){ + c = this.content.AsMarkdown() + }else{ + c = this.content + } + return "\n\n"+c+"\n\n" + } + + protected InnerConstructElement(): HTMLElement { + const e = document.createElement("p") + if(typeof this.content !== "string"){ + e.appendChild(this.content.ConstructElement()) + }else{ + e.innerHTML = this.content + } + return e; + } + + +} \ No newline at end of file diff --git a/UI/Wikipedia/WikidataPreviewBox.ts b/UI/Wikipedia/WikidataPreviewBox.ts index 1e6db1ca33..ca86a70a93 100644 --- a/UI/Wikipedia/WikidataPreviewBox.ts +++ b/UI/Wikipedia/WikidataPreviewBox.ts @@ -28,12 +28,12 @@ export default class WikidataPreviewBox extends VariableUiElement { requires: WikidataPreviewBox.isHuman, property: "P21", display: new Map([ - ['Q6581097', () => Svg.gender_male_ui().SetStyle("width: 1rem; height: auto")], - ['Q6581072', () => Svg.gender_female_ui().SetStyle("width: 1rem; height: auto")], - ['Q1097630', () => Svg.gender_inter_ui().SetStyle("width: 1rem; height: auto")], - ['Q1052281', () => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transwomen'*/], - ['Q2449503', () => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transmen'*/], - ['Q48270', () => Svg.gender_queer_ui().SetStyle("width: 1rem; height: auto")] + ['Q6581097', () => Svg.gender_male_svg().SetStyle("width: 1rem; height: auto")], + ['Q6581072', () => Svg.gender_female_svg().SetStyle("width: 1rem; height: auto")], + ['Q1097630', () => Svg.gender_inter_svg().SetStyle("width: 1rem; height: auto")], + ['Q1052281', () => Svg.gender_trans_svg().SetStyle("width: 1rem; height: auto")/*'transwomen'*/], + ['Q2449503', () => Svg.gender_trans_svg().SetStyle("width: 1rem; height: auto")/*'transmen'*/], + ['Q48270', () => Svg.gender_queer_svg().SetStyle("width: 1rem; height: auto")] ]) }, { @@ -84,7 +84,7 @@ export default class WikidataPreviewBox extends VariableUiElement { let link = new Link( new Combine([ wikidata.id, - Svg.wikidata_ui().SetStyle("width: 2.5rem").SetClass("block") + Svg.wikidata_svg().SetStyle("width: 2.5rem").SetClass("block") ]).SetClass("flex"), Wikidata.IdToArticle(wikidata.id), true)?.SetClass("must-link") diff --git a/UI/Wikipedia/WikipediaBox.ts b/UI/Wikipedia/WikipediaBox.ts index b1a28fd465..e096ceee74 100644 --- a/UI/Wikipedia/WikipediaBox.ts +++ b/UI/Wikipedia/WikipediaBox.ts @@ -14,9 +14,14 @@ import {FixedUiElement} from "../Base/FixedUiElement"; import Translations from "../i18n/Translations"; import Link from "../Base/Link"; import WikidataPreviewBox from "./WikidataPreviewBox"; +import {Paragraph} from "../Base/Paragraph"; export default class WikipediaBox extends Combine { + public static configuration = { + onlyFirstParagaph: false, + addHeader: false + } constructor(wikidataIds: string[]) { @@ -27,9 +32,9 @@ export default class WikipediaBox extends Combine { const page = pages[0] mainContents.push( new Combine([ - new Combine([Svg.wikipedia_ui() - .SetStyle("width: 1.5rem").SetClass("inline-block mr-3"), page.titleElement]) - .SetClass("flex"), + new Combine([ + Svg.wikipedia_svg().SetStyle("width: 1.5rem").SetClass("inline-block mr-3"), + page.titleElement]).SetClass("flex"), page.linkElement ]).SetClass("flex justify-between align-middle"), ) @@ -52,7 +57,7 @@ export default class WikipediaBox extends Combine { }), 0, { - leftOfHeader: Svg.wikipedia_ui().SetStyle("width: 1.5rem; align-self: center;").SetClass("mr-4"), + leftOfHeader: Svg.wikipedia_svg().SetStyle("width: 1.5rem; align-self: center;").SetClass("mr-4"), styleHeader: header => header.SetClass("subtle-background").SetStyle("height: 3.3rem") } ) @@ -141,7 +146,7 @@ export default class WikipediaBox extends Combine { return new Title(pagetitle, 3) } //return new Title(Translations.t.general.wikipedia.wikipediaboxTitle.Clone(), 2) - return new Title(wikidataId, 3) + return new Link(new Title(wikidataId, 3), "https://www.wikidata.org/wiki/"+wikidataId, true) })) @@ -150,13 +155,13 @@ export default class WikipediaBox extends Combine { const [pagetitle, language] = state if (pagetitle === "no page") { const wd = state[1] - return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "), + return new Link(Svg.pop_out_svg().SetStyle("width: 1.2rem").SetClass("block "), "https://www.wikidata.org/wiki/" + wd.id , true) } const url = `https://${language}.wikipedia.org/wiki/${pagetitle}` - return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "), url, true) + return new Link(Svg.pop_out_svg().SetStyle("width: 1.2rem").SetClass("block "), url, true) } return undefined })) @@ -172,15 +177,14 @@ export default class WikipediaBox extends Combine { /** * Returns the actual content in a scrollable way - * @param pagename - * @param language - * @private */ private static createContents(pagename: string, language: string, wikidata: WikidataResponse): BaseUIElement { - const htmlContent = Wikipedia.GetArticle({ + const wpOptions = { pageName: pagename, - language: language - }) + language: language, + firstParagraphOnly: WikipediaBox.configuration.onlyFirstParagaph + } + const htmlContent = Wikipedia.GetArticle(wpOptions) const wp = Translations.t.general.wikipedia const quickFacts = WikidataPreviewBox.QuickFacts(wikidata); const contents: UIEventSource = htmlContent.map(htmlContent => { @@ -189,7 +193,20 @@ export default class WikipediaBox extends Combine { return new Loading(wp.loading.Clone()) } if (htmlContent["success"] !== undefined) { - return new FixedUiElement(htmlContent["success"]).SetClass("wikipedia-article") + let content: BaseUIElement = new FixedUiElement(htmlContent["success"]); + if(WikipediaBox.configuration.addHeader){ + content = new Combine( + [ + new Paragraph( + new Link(wp.fromWikipedia, Wikipedia.getPageUrl(wpOptions), true), + ), + new Paragraph( + content + ) + ] + ) + } + return content.SetClass("wikipedia-article") } if (htmlContent["error"]) { console.warn("Loading wikipage failed due to", htmlContent["error"]) diff --git a/langs/en.json b/langs/en.json index 4b6f74a0ee..1711702fa1 100644 --- a/langs/en.json +++ b/langs/en.json @@ -247,6 +247,7 @@ "createNewWikidata": "Create a new Wikidata item", "doSearch": "Search above to see results", "failed": "Loading the Wikipedia entry failed", + "fromWikipedia": "From Wikipedia, the free encyclopedia", "loading": "Loading Wikipedia...", "noResults": "Nothing found for {search}", "noWikipediaPage": "This Wikidata item has no corresponding Wikipedia page yet.", From f5ae73aac18c9337e8ab5d20245378b566e5a436 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 30 Apr 2022 00:28:51 +0200 Subject: [PATCH 12/14] Convert markdown into UIElements --- Models/ThemeConfig/LayerConfig.ts | 4 ++- Models/ThemeConfig/LayoutConfig.ts | 2 +- Models/ThemeConfig/TagRenderingConfig.ts | 32 ++++++++++++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index ee9063cfa9..e4fe9cb4bb 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -27,6 +27,7 @@ import FilterConfigJson from "./Json/FilterConfigJson"; import {And} from "../../Logic/Tags/And"; import {Overpass} from "../../Logic/Osm/Overpass"; import Constants from "../Constants"; +import {FixedUiElement} from "../../UI/Base/FixedUiElement"; export default class LayerConfig extends WithContextLoader { @@ -416,7 +417,8 @@ export default class LayerConfig extends WithContextLoader { let quickOverview: BaseUIElement = undefined; if (tableRows.length > 0) { quickOverview = new Combine([ - "**Warning** This quick overview is incomplete", + new FixedUiElement("Warning: ").SetClass("bold"), + "this quick overview is incomplete", new Table(["attribute", "type", "values which are supported by this layer"], tableRows) ]).SetClass("flex-col flex") } diff --git a/Models/ThemeConfig/LayoutConfig.ts b/Models/ThemeConfig/LayoutConfig.ts index 2d8598f6af..3cb39d9f7b 100644 --- a/Models/ThemeConfig/LayoutConfig.ts +++ b/Models/ThemeConfig/LayoutConfig.ts @@ -211,5 +211,5 @@ export default class LayoutConfig { } return undefined } - + } \ No newline at end of file diff --git a/Models/ThemeConfig/TagRenderingConfig.ts b/Models/ThemeConfig/TagRenderingConfig.ts index aa9f05eb89..5547e26699 100644 --- a/Models/ThemeConfig/TagRenderingConfig.ts +++ b/Models/ThemeConfig/TagRenderingConfig.ts @@ -13,6 +13,7 @@ import Link from "../../UI/Base/Link"; import List from "../../UI/Base/List"; import {QuestionableTagRenderingConfigJson} from "./Json/QuestionableTagRenderingConfigJson"; import {FixedUiElement} from "../../UI/Base/FixedUiElement"; +import {Paragraph} from "../../UI/Base/Paragraph"; /*** * The parsed version of TagRenderingConfigJSON @@ -514,7 +515,10 @@ export default class TagRenderingConfig { withRender = [ `This rendering asks information about the property `, Link.OsmWiki(this.freeform.key), - `\nThis is rendered with \`${this.render.txt}\`` + new Paragraph(new Combine([ + "This is rendered with", + new FixedUiElement(this.render.txt).SetClass("literalcode bold") + ])) ] } @@ -522,17 +526,25 @@ export default class TagRenderingConfig { let mappings: BaseUIElement = undefined; if (this.mappings !== undefined) { mappings = new List( - this.mappings.map(m => { - let txt = "**" + m.then.txt + "** corresponds with " + m.if.asHumanString(true, false, {}); + [].concat(...this.mappings.map(m => { + const msgs: (string| BaseUIElement)[] = [ + new Combine( + [ + new FixedUiElement(m.then.txt).SetClass("bold"), + "corresponds with", + m.if.asHumanString(true, false, {}) + ] + ) + ] if (m.hideInAnswer === true) { - txt += "_This option cannot be chosen as answer_" + msgs.push(new FixedUiElement("This option cannot be chosen as answer").SetClass("italic")) } if (m.ifnot !== undefined) { - txt += "Unselecting this answer will add " + m.ifnot.asHumanString(true, false, {}) + msgs.push( "Unselecting this answer will add " + m.ifnot.asHumanString(true, false, {})) } - return txt; + return msgs; } - ) + )) ) } @@ -559,7 +571,11 @@ export default class TagRenderingConfig { } return new Combine([ new Title(this.id, 3), - this.question !== undefined ? "The question is **" + this.question.txt + "**" : "_This tagrendering has no question and is thus read-only_", + this.question !== undefined ? + new Combine([ "The question is " , new FixedUiElement( this.question.txt).SetClass("bold")]) : + new FixedUiElement( + "This tagrendering has no question and is thus read-only" + ).SetClass("italic"), new Combine(withRender), mappings, condition, From 7a76109d61f50c5e6e228626f91a189f27fe8ff9 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 30 Apr 2022 00:30:15 +0200 Subject: [PATCH 13/14] Improve wikipedia fetcher --- Logic/Web/Wikipedia.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Logic/Web/Wikipedia.ts b/Logic/Web/Wikipedia.ts index 3a548ee57f..66d848b2e6 100644 --- a/Logic/Web/Wikipedia.ts +++ b/Logic/Web/Wikipedia.ts @@ -31,9 +31,10 @@ export default class Wikipedia { public static GetArticle(options: { pageName: string, - language?: "en" | string + language?: "en" | string, + firstParagraphOnly?: false | boolean }): UIEventSource<{ success: string } | { error: any }> { - const key = (options.language ?? "en") + ":" + options.pageName + const key = (options.language ?? "en") + ":" + options.pageName + ":" + (options.firstParagraphOnly ?? false) const cached = Wikipedia._cache.get(key) if (cached !== undefined) { return cached @@ -43,14 +44,21 @@ export default class Wikipedia { return v; } + public static getDataUrl(options: {language?: "en" | string, pageName: string}): string{ + return `https://${options.language ?? "en"}.wikipedia.org/w/api.php?action=parse&format=json&origin=*&prop=text&page=` + options.pageName + } + + public static getPageUrl(options: {language?: "en" | string, pageName: string}): string{ + return `https://${options.language ?? "en"}.wikipedia.org/wiki/` + options.pageName + } + public static async GetArticleAsync(options: { pageName: string, - language?: "en" | string + language?: "en" | string, + firstParagraphOnly?: false | boolean }): Promise { - const language = options.language ?? "en" - const url = `https://${language}.wikipedia.org/w/api.php?action=parse&format=json&origin=*&prop=text&page=` + options.pageName - const response = await Utils.downloadJson(url) + const response = await Utils.downloadJson(Wikipedia.getDataUrl(options)) const html = response["parse"]["text"]["*"]; const div = document.createElement("div") @@ -73,12 +81,17 @@ export default class Wikipedia { const links = Array.from(content.getElementsByTagName("a")) // Rewrite relative links to absolute links + open them in a new tab + const language = options.language ?? "en" links.filter(link => link.getAttribute("href")?.startsWith("/") ?? false).forEach(link => { link.target = '_blank' // note: link.getAttribute("href") gets the textual value, link.href is the rewritten version which'll contain the host for relative paths link.href = `https://${language}.wikipedia.org${link.getAttribute("href")}`; }) + if (options?.firstParagraphOnly) { + return content.getElementsByTagName("p").item(0).innerHTML + } + return content.innerHTML } From f4a965eacfcb88802f69fdffc4b939b6d628c096 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 30 Apr 2022 00:41:33 +0200 Subject: [PATCH 14/14] Move ShowDataLayer-implementation to a separate class for nodejs compatibility --- UI/Base/MinimapImplementation.ts | 3 + UI/OpeningHours/OpeningHours.ts | 2 +- UI/ShowDataLayer/ShowDataLayer.ts | 339 +---------------- .../ShowDataLayerImplementation.ts | 350 ++++++++++++++++++ .../OSM/Actions/ReplaceGeometryAction.spec.ts | 4 +- 5 files changed, 364 insertions(+), 334 deletions(-) create mode 100644 UI/ShowDataLayer/ShowDataLayerImplementation.ts diff --git a/UI/Base/MinimapImplementation.ts b/UI/Base/MinimapImplementation.ts index 4a240bd56b..9021d17990 100644 --- a/UI/Base/MinimapImplementation.ts +++ b/UI/Base/MinimapImplementation.ts @@ -12,6 +12,8 @@ import 'leaflet-polylineoffset' import {SimpleMapScreenshoter} from "leaflet-simple-map-screenshoter"; import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch"; import AvailableBaseLayersImplementation from "../../Logic/Actors/AvailableBaseLayersImplementation"; +import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"; +import ShowDataLayerImplementation from "../ShowDataLayer/ShowDataLayerImplementation"; export default class MinimapImplementation extends BaseUIElement implements MinimapObj { private static _nextId = 0; @@ -50,6 +52,7 @@ export default class MinimapImplementation extends BaseUIElement implements Mini public static initialize() { AvailableBaseLayers.implement(new AvailableBaseLayersImplementation()) Minimap.createMiniMap = options => new MinimapImplementation(options) + ShowDataLayer.actualContstructor = options => new ShowDataLayerImplementation(options) } public installBounds(factor: number | BBox, showRange?: boolean) { diff --git a/UI/OpeningHours/OpeningHours.ts b/UI/OpeningHours/OpeningHours.ts index c4e0b04e8a..272fa3b247 100644 --- a/UI/OpeningHours/OpeningHours.ts +++ b/UI/OpeningHours/OpeningHours.ts @@ -465,7 +465,7 @@ export class OH { lat: tags._lat, lon: tags._lon, address: { - country_code: tags._country + country_code: tags._country.toLowerCase() }, }, {tag_key: "opening_hours"}); } diff --git a/UI/ShowDataLayer/ShowDataLayer.ts b/UI/ShowDataLayer/ShowDataLayer.ts index d5935b91e2..60de14a44a 100644 --- a/UI/ShowDataLayer/ShowDataLayer.ts +++ b/UI/ShowDataLayer/ShowDataLayer.ts @@ -1,50 +1,12 @@ -import {UIEventSource} from "../../Logic/UIEventSource"; -import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; -import {ShowDataLayerOptions} from "./ShowDataLayerOptions"; -import {ElementStorage} from "../../Logic/ElementStorage"; -import RenderingMultiPlexerFeatureSource from "../../Logic/FeatureSource/Sources/RenderingMultiPlexerFeatureSource"; -import ScrollableFullScreen from "../Base/ScrollableFullScreen"; -/* -// import 'leaflet-polylineoffset'; -We don't actually import it here. It is imported in the 'MinimapImplementation'-class, which'll result in a patched 'L' object. - Even though actually importing this here would seem cleaner, we don't do this as this breaks some scripts: - - Scripts are ran in ts-node - - ts-node doesn't define the 'window'-object - - Importing this will execute some code which needs the window object - - */ - /** * The data layer shows all the given geojson elements with the appropriate icon etc */ +import {ShowDataLayerOptions} from "./ShowDataLayerOptions"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; + export default class ShowDataLayer { - private static dataLayerIds = 0 - private readonly _leafletMap: UIEventSource; - private readonly _enablePopups: boolean; - private readonly _features: RenderingMultiPlexerFeatureSource - private readonly _layerToShow: LayerConfig; - private readonly _selectedElement: UIEventSource - private readonly allElements: ElementStorage - // Used to generate a fresh ID when needed - private _cleanCount = 0; - private geoLayer = undefined; - - /** - * A collection of functions to call when the current geolayer is unregistered - */ - private unregister: (() => void)[] = []; - private isDirty = false; - /** - * If the selected element triggers, this is used to lookup the correct layer and to open the popup - * Used to avoid a lot of callbacks on the selected element - * - * Note: the key of this dictionary is 'feature.properties.id+features.geometry.type' as one feature might have multiple presentations - * @private - */ - private readonly leafletLayersPerId = new Map() - private readonly showDataLayerid: number; - private readonly createPopup: (tags: UIEventSource, layer: LayerConfig) => ScrollableFullScreen + public static actualContstructor : (options: ShowDataLayerOptions & { layerToShow: LayerConfig }) => void = undefined; /** * Creates a datalayer. @@ -53,298 +15,11 @@ export default class ShowDataLayer { * @param options */ constructor(options: ShowDataLayerOptions & { layerToShow: LayerConfig }) { - this._leafletMap = options.leafletMap; - this.showDataLayerid = ShowDataLayer.dataLayerIds; - ShowDataLayer.dataLayerIds++ - if (options.features === undefined) { - console.error("Invalid ShowDataLayer invocation: options.features is undefed") - throw "Invalid ShowDataLayer invocation: options.features is undefed" + if(ShowDataLayer.actualContstructor === undefined){ + throw "Show data layer is called, but it isn't initialized yet. Call ` ShowDataLayer.actualContstructor = (options => new ShowDataLayerImplementation(options)) ` somewhere, e.g. in your init" } - this._features = new RenderingMultiPlexerFeatureSource(options.features, options.layerToShow); - this._layerToShow = options.layerToShow; - this._selectedElement = options.selectedElement - this.allElements = options.state?.allElements; - this.createPopup = undefined; - this._enablePopups = options.popup !== undefined; - if (options.popup !== undefined) { - this.createPopup = options.popup - } - const self = this; - - options.leafletMap.addCallback(_ => { - return self.update(options) - } - ); - - this._features.features.addCallback(_ => self.update(options)); - options.doShowLayer?.addCallback(doShow => { - const mp = options.leafletMap.data; - if (mp === null) { - self.Destroy() - return true; - } - if (mp == undefined) { - return; - } - - if (doShow) { - if (self.isDirty) { - return self.update(options) - } else { - mp.addLayer(this.geoLayer) - } - } else { - if (this.geoLayer !== undefined) { - mp.removeLayer(this.geoLayer) - this.unregister.forEach(f => f()) - this.unregister = [] - } - } - - }) - - - this._selectedElement?.addCallbackAndRunD(selected => { - self.openPopupOfSelectedElement(selected) - }) - - this.update(options) - + ShowDataLayer.actualContstructor(options) } - private Destroy() { - this.unregister.forEach(f => f()) - } - - private openPopupOfSelectedElement(selected) { - if (selected === undefined) { - return - } - if (this._leafletMap.data === undefined) { - return; - } - const v = this.leafletLayersPerId.get(selected.properties.id + selected.geometry.type) - if (v === undefined) { - return; - } - const leafletLayer = v.leafletlayer - const feature = v.feature - if (leafletLayer.getPopup().isOpen()) { - return; - } - if (selected.properties.id !== feature.properties.id) { - return; - } - - if (feature.id !== feature.properties.id) { - // Probably a feature which has renamed - // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too - console.log("Not opening the popup for", feature, "as probably renamed") - return; - } - if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again - ) { - leafletLayer.openPopup() - } - } - - private update(options: ShowDataLayerOptions): boolean { - if (this._features.features.data === undefined) { - return; - } - this.isDirty = true; - if (options?.doShowLayer?.data === false) { - return; - } - const mp = options.leafletMap.data; - - if (mp === null) { - return true; // Unregister as the map has been destroyed - } - if (mp === undefined) { - return; - } - - this._cleanCount++ - // clean all the old stuff away, if any - if (this.geoLayer !== undefined) { - mp.removeLayer(this.geoLayer); - } - - const self = this; - const data = { - type: "FeatureCollection", - features: [] - } - // @ts-ignore - this.geoLayer = L.geoJSON(data, { - style: feature => self.createStyleFor(feature), - pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng), - onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer) - }); - - const selfLayer = this.geoLayer; - const allFeats = this._features.features.data; - for (const feat of allFeats) { - if (feat === undefined) { - continue - } - try { - if (feat.geometry.type === "LineString") { - const coords = L.GeoJSON.coordsToLatLngs(feat.geometry.coordinates) - const tagsSource = this.allElements?.addOrGetElement(feat) ?? new UIEventSource(feat.properties); - let offsettedLine; - tagsSource - .map(tags => this._layerToShow.lineRendering[feat.lineRenderingIndex].GenerateLeafletStyle(tags), [], undefined, true) - .withEqualityStabilized((a, b) => { - if (a === b) { - return true - } - if (a === undefined || b === undefined) { - return false - } - return a.offset === b.offset && a.color === b.color && a.weight === b.weight && a.dashArray === b.dashArray - }) - .addCallbackAndRunD(lineStyle => { - if (offsettedLine !== undefined) { - self.geoLayer.removeLayer(offsettedLine) - } - // @ts-ignore - offsettedLine = L.polyline(coords, lineStyle); - this.postProcessFeature(feat, offsettedLine) - offsettedLine.addTo(this.geoLayer) - - // If 'self.geoLayer' is not the same as the layer the feature is added to, we can safely remove this callback - return self.geoLayer !== selfLayer - }) - } else { - this.geoLayer.addData(feat); - } - } catch (e) { - console.error("Could not add ", feat, "to the geojson layer in leaflet due to", e, e.stack) - } - } - - if (options.zoomToFeatures ?? false) { - if (this.geoLayer.getLayers().length > 0) { - try { - const bounds = this.geoLayer.getBounds() - mp.fitBounds(bounds, {animate: false}) - } catch (e) { - console.debug("Invalid bounds", e) - } - } - } - - if (options.doShowLayer?.data ?? true) { - mp.addLayer(this.geoLayer) - } - this.isDirty = false; - this.openPopupOfSelectedElement(this._selectedElement?.data) - } - - - private createStyleFor(feature) { - const tagsSource = this.allElements?.addOrGetElement(feature) ?? new UIEventSource(feature.properties); - // Every object is tied to exactly one layer - const layer = this._layerToShow - - const pointRenderingIndex = feature.pointRenderingIndex - const lineRenderingIndex = feature.lineRenderingIndex - - if (pointRenderingIndex !== undefined) { - const style = layer.mapRendering[pointRenderingIndex].GenerateLeafletStyle(tagsSource, this._enablePopups) - return { - icon: style - } - } - if (lineRenderingIndex !== undefined) { - return layer.lineRendering[lineRenderingIndex].GenerateLeafletStyle(tagsSource.data) - } - - throw "Neither lineRendering nor mapRendering defined for " + feature - } - - private pointToLayer(feature, latLng): L.Layer { - // Leaflet cannot handle geojson points natively - // We have to convert them to the appropriate icon - // Click handling is done in the next step - - const layer: LayerConfig = this._layerToShow - if (layer === undefined) { - return; - } - let tagSource = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties) - const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0) && this._enablePopups - let style: any = layer.mapRendering[feature.pointRenderingIndex].GenerateLeafletStyle(tagSource, clickable); - const baseElement = style.html; - if (!this._enablePopups) { - baseElement.SetStyle("cursor: initial !important") - } - style.html = style.html.ConstructElement() - return L.marker(latLng, { - icon: L.divIcon(style) - }); - } - - /** - * Post processing - basically adding the popup - * @param feature - * @param leafletLayer - * @private - */ - private postProcessFeature(feature, leafletLayer: L.Layer) { - const layer: LayerConfig = this._layerToShow - if (layer.title === undefined || !this._enablePopups) { - // No popup action defined -> Don't do anything - // or probably a map in the popup - no popups needed! - return; - } - - const popup = L.popup({ - autoPan: true, - closeOnEscapeKey: true, - closeButton: false, - autoPanPaddingTopLeft: [15, 15], - - }, leafletLayer); - - leafletLayer.bindPopup(popup); - - let infobox: ScrollableFullScreen = undefined; - const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}-${feature.pointRenderingIndex ?? feature.lineRenderingIndex}-${feature.multiLineStringIndex ?? ""}` - popup.setContent(`
Popup for ${feature.properties.id} ${feature.geometry.type} ${id} is loading
`) - const createpopup = this.createPopup; - leafletLayer.on("popupopen", () => { - if (infobox === undefined) { - const tags = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties); - infobox = createpopup(tags, layer); - - infobox.isShown.addCallback(isShown => { - if (!isShown) { - leafletLayer.closePopup() - } - }); - } - infobox.AttachTo(id) - infobox.Activate(); - this.unregister.push(() => { - console.log("Destroying infobox") - infobox.Destroy(); - }) - if (this._selectedElement?.data?.properties?.id !== feature.properties.id) { - this._selectedElement?.setData(feature) - } - - }); - - - // Add the feature to the index to open the popup when needed - this.leafletLayersPerId.set(feature.properties.id + feature.geometry.type, { - feature: feature, - leafletlayer: leafletLayer - }) - - } } \ No newline at end of file diff --git a/UI/ShowDataLayer/ShowDataLayerImplementation.ts b/UI/ShowDataLayer/ShowDataLayerImplementation.ts new file mode 100644 index 0000000000..16b4293f89 --- /dev/null +++ b/UI/ShowDataLayer/ShowDataLayerImplementation.ts @@ -0,0 +1,350 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import {ShowDataLayerOptions} from "./ShowDataLayerOptions"; +import {ElementStorage} from "../../Logic/ElementStorage"; +import RenderingMultiPlexerFeatureSource from "../../Logic/FeatureSource/Sources/RenderingMultiPlexerFeatureSource"; +import ScrollableFullScreen from "../Base/ScrollableFullScreen"; +/* +// import 'leaflet-polylineoffset'; +We don't actually import it here. It is imported in the 'MinimapImplementation'-class, which'll result in a patched 'L' object. + Even though actually importing this here would seem cleaner, we don't do this as this breaks some scripts: + - Scripts are ran in ts-node + - ts-node doesn't define the 'window'-object + - Importing this will execute some code which needs the window object + + */ + +/** + * The data layer shows all the given geojson elements with the appropriate icon etc + */ +export default class ShowDataLayerImplementation { + + private static dataLayerIds = 0 + private readonly _leafletMap: UIEventSource; + private readonly _enablePopups: boolean; + private readonly _features: RenderingMultiPlexerFeatureSource + private readonly _layerToShow: LayerConfig; + private readonly _selectedElement: UIEventSource + private readonly allElements: ElementStorage + // Used to generate a fresh ID when needed + private _cleanCount = 0; + private geoLayer = undefined; + + /** + * A collection of functions to call when the current geolayer is unregistered + */ + private unregister: (() => void)[] = []; + private isDirty = false; + /** + * If the selected element triggers, this is used to lookup the correct layer and to open the popup + * Used to avoid a lot of callbacks on the selected element + * + * Note: the key of this dictionary is 'feature.properties.id+features.geometry.type' as one feature might have multiple presentations + * @private + */ + private readonly leafletLayersPerId = new Map() + private readonly showDataLayerid: number; + private readonly createPopup: (tags: UIEventSource, layer: LayerConfig) => ScrollableFullScreen + + /** + * Creates a datalayer. + * + * If 'createPopup' is set, this function is called every time that 'popupOpen' is called + * @param options + */ + constructor(options: ShowDataLayerOptions & { layerToShow: LayerConfig }) { + this._leafletMap = options.leafletMap; + this.showDataLayerid = ShowDataLayerImplementation.dataLayerIds; + ShowDataLayerImplementation.dataLayerIds++ + if (options.features === undefined) { + console.error("Invalid ShowDataLayer invocation: options.features is undefed") + throw "Invalid ShowDataLayer invocation: options.features is undefed" + } + this._features = new RenderingMultiPlexerFeatureSource(options.features, options.layerToShow); + this._layerToShow = options.layerToShow; + this._selectedElement = options.selectedElement + this.allElements = options.state?.allElements; + this.createPopup = undefined; + this._enablePopups = options.popup !== undefined; + if (options.popup !== undefined) { + this.createPopup = options.popup + } + const self = this; + + options.leafletMap.addCallback(_ => { + return self.update(options) + } + ); + + this._features.features.addCallback(_ => self.update(options)); + options.doShowLayer?.addCallback(doShow => { + const mp = options.leafletMap.data; + if (mp === null) { + self.Destroy() + return true; + } + if (mp == undefined) { + return; + } + + if (doShow) { + if (self.isDirty) { + return self.update(options) + } else { + mp.addLayer(this.geoLayer) + } + } else { + if (this.geoLayer !== undefined) { + mp.removeLayer(this.geoLayer) + this.unregister.forEach(f => f()) + this.unregister = [] + } + } + + }) + + + this._selectedElement?.addCallbackAndRunD(selected => { + self.openPopupOfSelectedElement(selected) + }) + + this.update(options) + + } + + private Destroy() { + this.unregister.forEach(f => f()) + } + + private openPopupOfSelectedElement(selected) { + if (selected === undefined) { + return + } + if (this._leafletMap.data === undefined) { + return; + } + const v = this.leafletLayersPerId.get(selected.properties.id + selected.geometry.type) + if (v === undefined) { + return; + } + const leafletLayer = v.leafletlayer + const feature = v.feature + if (leafletLayer.getPopup().isOpen()) { + return; + } + if (selected.properties.id !== feature.properties.id) { + return; + } + + if (feature.id !== feature.properties.id) { + // Probably a feature which has renamed + // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too + console.log("Not opening the popup for", feature, "as probably renamed") + return; + } + if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again + ) { + leafletLayer.openPopup() + } + } + + private update(options: ShowDataLayerOptions): boolean { + if (this._features.features.data === undefined) { + return; + } + this.isDirty = true; + if (options?.doShowLayer?.data === false) { + return; + } + const mp = options.leafletMap.data; + + if (mp === null) { + return true; // Unregister as the map has been destroyed + } + if (mp === undefined) { + return; + } + + this._cleanCount++ + // clean all the old stuff away, if any + if (this.geoLayer !== undefined) { + mp.removeLayer(this.geoLayer); + } + + const self = this; + const data = { + type: "FeatureCollection", + features: [] + } + // @ts-ignore + this.geoLayer = L.geoJSON(data, { + style: feature => self.createStyleFor(feature), + pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng), + onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer) + }); + + const selfLayer = this.geoLayer; + const allFeats = this._features.features.data; + for (const feat of allFeats) { + if (feat === undefined) { + continue + } + try { + if (feat.geometry.type === "LineString") { + const coords = L.GeoJSON.coordsToLatLngs(feat.geometry.coordinates) + const tagsSource = this.allElements?.addOrGetElement(feat) ?? new UIEventSource(feat.properties); + let offsettedLine; + tagsSource + .map(tags => this._layerToShow.lineRendering[feat.lineRenderingIndex].GenerateLeafletStyle(tags), [], undefined, true) + .withEqualityStabilized((a, b) => { + if (a === b) { + return true + } + if (a === undefined || b === undefined) { + return false + } + return a.offset === b.offset && a.color === b.color && a.weight === b.weight && a.dashArray === b.dashArray + }) + .addCallbackAndRunD(lineStyle => { + if (offsettedLine !== undefined) { + self.geoLayer.removeLayer(offsettedLine) + } + // @ts-ignore + offsettedLine = L.polyline(coords, lineStyle); + this.postProcessFeature(feat, offsettedLine) + offsettedLine.addTo(this.geoLayer) + + // If 'self.geoLayer' is not the same as the layer the feature is added to, we can safely remove this callback + return self.geoLayer !== selfLayer + }) + } else { + this.geoLayer.addData(feat); + } + } catch (e) { + console.error("Could not add ", feat, "to the geojson layer in leaflet due to", e, e.stack) + } + } + + if (options.zoomToFeatures ?? false) { + if (this.geoLayer.getLayers().length > 0) { + try { + const bounds = this.geoLayer.getBounds() + mp.fitBounds(bounds, {animate: false}) + } catch (e) { + console.debug("Invalid bounds", e) + } + } + } + + if (options.doShowLayer?.data ?? true) { + mp.addLayer(this.geoLayer) + } + this.isDirty = false; + this.openPopupOfSelectedElement(this._selectedElement?.data) + } + + + private createStyleFor(feature) { + const tagsSource = this.allElements?.addOrGetElement(feature) ?? new UIEventSource(feature.properties); + // Every object is tied to exactly one layer + const layer = this._layerToShow + + const pointRenderingIndex = feature.pointRenderingIndex + const lineRenderingIndex = feature.lineRenderingIndex + + if (pointRenderingIndex !== undefined) { + const style = layer.mapRendering[pointRenderingIndex].GenerateLeafletStyle(tagsSource, this._enablePopups) + return { + icon: style + } + } + if (lineRenderingIndex !== undefined) { + return layer.lineRendering[lineRenderingIndex].GenerateLeafletStyle(tagsSource.data) + } + + throw "Neither lineRendering nor mapRendering defined for " + feature + } + + private pointToLayer(feature, latLng): L.Layer { + // Leaflet cannot handle geojson points natively + // We have to convert them to the appropriate icon + // Click handling is done in the next step + + const layer: LayerConfig = this._layerToShow + if (layer === undefined) { + return; + } + let tagSource = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties) + const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0) && this._enablePopups + let style: any = layer.mapRendering[feature.pointRenderingIndex].GenerateLeafletStyle(tagSource, clickable); + const baseElement = style.html; + if (!this._enablePopups) { + baseElement.SetStyle("cursor: initial !important") + } + style.html = style.html.ConstructElement() + return L.marker(latLng, { + icon: L.divIcon(style) + }); + } + + /** + * Post processing - basically adding the popup + * @param feature + * @param leafletLayer + * @private + */ + private postProcessFeature(feature, leafletLayer: L.Layer) { + const layer: LayerConfig = this._layerToShow + if (layer.title === undefined || !this._enablePopups) { + // No popup action defined -> Don't do anything + // or probably a map in the popup - no popups needed! + return; + } + + const popup = L.popup({ + autoPan: true, + closeOnEscapeKey: true, + closeButton: false, + autoPanPaddingTopLeft: [15, 15], + + }, leafletLayer); + + leafletLayer.bindPopup(popup); + + let infobox: ScrollableFullScreen = undefined; + const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}-${feature.pointRenderingIndex ?? feature.lineRenderingIndex}-${feature.multiLineStringIndex ?? ""}` + popup.setContent(`
Popup for ${feature.properties.id} ${feature.geometry.type} ${id} is loading
`) + const createpopup = this.createPopup; + leafletLayer.on("popupopen", () => { + if (infobox === undefined) { + const tags = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource(feature.properties); + infobox = createpopup(tags, layer); + + infobox.isShown.addCallback(isShown => { + if (!isShown) { + leafletLayer.closePopup() + } + }); + } + infobox.AttachTo(id) + infobox.Activate(); + this.unregister.push(() => { + console.log("Destroying infobox") + infobox.Destroy(); + }) + if (this._selectedElement?.data?.properties?.id !== feature.properties.id) { + this._selectedElement?.setData(feature) + } + + }); + + + // Add the feature to the index to open the popup when needed + this.leafletLayersPerId.set(feature.properties.id + feature.geometry.type, { + feature: feature, + leafletlayer: leafletLayer + }) + + } + +} \ No newline at end of file diff --git a/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts b/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts index 3e4cd31e64..d24d39db19 100644 --- a/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts +++ b/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts @@ -6,6 +6,8 @@ import LayoutConfig from "../../../../Models/ThemeConfig/LayoutConfig"; import State from "../../../../State"; import {BBox} from "../../../../Logic/BBox"; import ReplaceGeometryAction from "../../../../Logic/Osm/Actions/ReplaceGeometryAction"; +import ShowDataLayerImplementation from "../../../../UI/ShowDataLayer/ShowDataLayerImplementation"; +import ShowDataLayer from "../../../../UI/ShowDataLayer/ShowDataLayer"; describe("ReplaceGeometryAction", () => { @@ -874,7 +876,7 @@ it("should move nodes accordingly", async () => { const layout = new LayoutConfig(grbStripped) - + ShowDataLayer.actualContstructor = (_) => undefined; const state = new State(layout) State.state = state;