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/* 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) + ]) + } } diff --git a/Docs/theme-template.json b/Docs/theme-template.json index 8d904d0aad..d3482666c5 100644 --- a/Docs/theme-template.json +++ b/Docs/theme-template.json @@ -1,16 +1,16 @@ { - "#": "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", "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", @@ -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" @@ -54,30 +54,34 @@ { "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": { - "softDeletionTags": [ - "disused:key:={key}" - ] + "softDeletionTags": { + "and": [ + "razed:tourism=artwork", + "tourism=" + ] + }, + "neededChangesets": 5 }, - "#": "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 +97,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 +120,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": { @@ -143,7 +147,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 +156,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 +173,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" ] @@ -181,4 +185,4 @@ ] } ] -} \ No newline at end of file +} 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 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 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 } 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, 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/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/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 3c25ee58ba..60de14a44a 100644 --- a/UI/ShowDataLayer/ShowDataLayer.ts +++ b/UI/ShowDataLayer/ShowDataLayer.ts @@ -1,16 +1,25 @@ +/** + * 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 { + public static actualContstructor : (options: ShowDataLayerOptions & { layerToShow: LayerConfig }) => void = undefined; /** * 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 }) { + 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" + } + ShowDataLayer.actualContstructor(options) } - + } \ 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/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/assets/layers/bicycle_rental/bicycle_rental.json b/assets/layers/bicycle_rental/bicycle_rental.json index 98381be7e8..ff35d50fd8 100644 --- a/assets/layers/bicycle_rental/bicycle_rental.json +++ b/assets/layers/bicycle_rental/bicycle_rental.json @@ -254,6 +254,14 @@ "es": "Aquí se pueden alquilar bicicletas de carreras", "da": "Racercykler kan lejes her" } + }, + { + "if": "rental=bike_helmet", + "then": { + "en": "Bike helmets can be rented here", + "nl": "Fietshelmpen kunnen hier gehuurd worden", + "es": "Aquí se pueden alquilar cascos" + } } ] }, @@ -459,4 +467,4 @@ } ] } -} \ No newline at end of file +} diff --git a/langs/en.json b/langs/en.json index 692eab3c74..105fca8f9c 100644 --- a/langs/en.json +++ b/langs/en.json @@ -250,6 +250,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.", 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;