From 3fd059311bd1eb9cb5c9353ab440847026e6f745 Mon Sep 17 00:00:00 2001 From: Ward Date: Thu, 22 Jul 2021 11:29:09 +0200 Subject: [PATCH] start extra filter functionality --- Customizations/JSON/FilterConfig.ts | 27 + Customizations/JSON/FilterConfigJson.ts | 11 + Customizations/JSON/LayerConfig.ts | 963 +++++++++++++----------- Customizations/JSON/LayerConfigJson.ts | 7 + UI/BigComponents/FilterView.ts | 23 +- 5 files changed, 579 insertions(+), 452 deletions(-) create mode 100644 Customizations/JSON/FilterConfig.ts create mode 100644 Customizations/JSON/FilterConfigJson.ts diff --git a/Customizations/JSON/FilterConfig.ts b/Customizations/JSON/FilterConfig.ts new file mode 100644 index 000000000..4993baf4e --- /dev/null +++ b/Customizations/JSON/FilterConfig.ts @@ -0,0 +1,27 @@ +import { TagsFilter } from "../../Logic/Tags/TagsFilter"; +import { Translation } from "../../UI/i18n/Translation"; +import Translations from "../../UI/i18n/Translations"; +import FilterConfigJson from "./FilterConfigJson"; +import { FromJSON } from "./FromJSON"; + +export default class FilterConfig { + readonly options: { + question: Translation; + osmTags: TagsFilter; + }[]; + + constructor(json: FilterConfigJson, context: string) { + this.options = json.options.map((option, i) => { + const question = Translations.T( + option.question, + context + ".options-[" + i + "].question" + ); + const osmTags = FromJSON.Tag( + option.osmTags, + `${context}.options-[${i}].osmTags` + ); + + return { question: question, osmTags: osmTags }; + }); + } +} diff --git a/Customizations/JSON/FilterConfigJson.ts b/Customizations/JSON/FilterConfigJson.ts new file mode 100644 index 000000000..082fd7fe0 --- /dev/null +++ b/Customizations/JSON/FilterConfigJson.ts @@ -0,0 +1,11 @@ +import { AndOrTagConfigJson } from "./TagConfigJson"; + +export default interface FilterConfigJson { + /** + * The options for a filter + * If there are multiple options these will be a list of radio buttons + * If there is only one option this will be a checkbox + * Filtering is done based on the given osmTags that are compared to the objects in that layer. + */ + options: { question: string | any; osmTags: AndOrTagConfigJson | string }[]; +} diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 0acf8198f..994b4c37c 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -1,489 +1,562 @@ import Translations from "../../UI/i18n/Translations"; import TagRenderingConfig from "./TagRenderingConfig"; -import {LayerConfigJson} from "./LayerConfigJson"; -import {FromJSON} from "./FromJSON"; +import { LayerConfigJson } from "./LayerConfigJson"; +import { FromJSON } from "./FromJSON"; import SharedTagRenderings from "../SharedTagRenderings"; -import {TagRenderingConfigJson} from "./TagRenderingConfigJson"; -import {Translation} from "../../UI/i18n/Translation"; +import { TagRenderingConfigJson } from "./TagRenderingConfigJson"; +import { Translation } from "../../UI/i18n/Translation"; import Svg from "../../Svg"; -import {Utils} from "../../Utils"; +import { Utils } from "../../Utils"; import Combine from "../../UI/Base/Combine"; -import {VariableUiElement} from "../../UI/Base/VariableUIElement"; -import {UIEventSource} from "../../Logic/UIEventSource"; -import {FixedUiElement} from "../../UI/Base/FixedUiElement"; +import { VariableUiElement } from "../../UI/Base/VariableUIElement"; +import { UIEventSource } from "../../Logic/UIEventSource"; +import { FixedUiElement } from "../../UI/Base/FixedUiElement"; import SourceConfig from "./SourceConfig"; -import {TagsFilter} from "../../Logic/Tags/TagsFilter"; -import {Tag} from "../../Logic/Tags/Tag"; +import { TagsFilter } from "../../Logic/Tags/TagsFilter"; +import { Tag } from "../../Logic/Tags/Tag"; import BaseUIElement from "../../UI/BaseUIElement"; -import {Unit} from "./Denomination"; +import { Unit } from "./Denomination"; import DeleteConfig from "./DeleteConfig"; +import FilterConfig from "./FilterConfig"; export default class LayerConfig { + static WAYHANDLING_DEFAULT = 0; + static WAYHANDLING_CENTER_ONLY = 1; + static WAYHANDLING_CENTER_AND_WAY = 2; + id: string; + name: Translation; + description: Translation; + source: SourceConfig; + calculatedTags: [string, string][]; + doNotDownload: boolean; + passAllFeatures: boolean; + isShown: TagRenderingConfig; + minzoom: number; + maxzoom: number; + title?: TagRenderingConfig; + titleIcons: TagRenderingConfig[]; + icon: TagRenderingConfig; + iconOverlays: { if: TagsFilter; then: TagRenderingConfig; badge: boolean }[]; + iconSize: TagRenderingConfig; + label: TagRenderingConfig; + rotation: TagRenderingConfig; + color: TagRenderingConfig; + width: TagRenderingConfig; + dashArray: TagRenderingConfig; + wayHandling: number; + public readonly units: Unit[]; + public readonly deletion: DeleteConfig | null; - static WAYHANDLING_DEFAULT = 0; - static WAYHANDLING_CENTER_ONLY = 1; - static WAYHANDLING_CENTER_AND_WAY = 2; + presets: { + title: Translation; + tags: Tag[]; + description?: Translation; + }[]; - id: string; - name: Translation - description: Translation; - source: SourceConfig; - calculatedTags: [string, string][] - doNotDownload: boolean; - passAllFeatures: boolean; - isShown: TagRenderingConfig; - minzoom: number; - maxzoom: number; - title?: TagRenderingConfig; - titleIcons: TagRenderingConfig[]; - icon: TagRenderingConfig; - iconOverlays: { if: TagsFilter, then: TagRenderingConfig, badge: boolean }[] - iconSize: TagRenderingConfig; - label: TagRenderingConfig; - rotation: TagRenderingConfig; - color: TagRenderingConfig; - width: TagRenderingConfig; - dashArray: TagRenderingConfig; - wayHandling: number; - public readonly units: Unit[]; - public readonly deletion: DeleteConfig | null + tagRenderings: TagRenderingConfig[]; + filters: FilterConfig[]; - presets: { - title: Translation, - tags: Tag[], - description?: Translation, - }[]; + constructor( + json: LayerConfigJson, + units?: Unit[], + context?: string, + official: boolean = true + ) { + this.units = units ?? []; + context = context + "." + json.id; + const self = this; + this.id = json.id; + this.name = Translations.T(json.name, context + ".name"); - tagRenderings: TagRenderingConfig []; - - constructor(json: LayerConfigJson, - units?:Unit[], - context?: string, - official: boolean = true,) { - this.units = units ?? []; - context = context + "." + json.id; - const self = this; - this.id = json.id; - this.name = Translations.T(json.name, context + ".name"); - - if(json.description !== undefined){ - if(Object.keys(json.description).length === 0){ - json.description = undefined; - } - } - - this.description =Translations.T(json.description, context + ".description") ; - - let legacy = undefined; - if (json["overpassTags"] !== undefined) { - // @ts-ignore - legacy = FromJSON.Tag(json["overpassTags"], context + ".overpasstags"); - } - if (json.source !== undefined) { - if (legacy !== undefined) { - throw context + "Both the legacy 'layer.overpasstags' and the new 'layer.source'-field are defined" - } - - let osmTags: TagsFilter = legacy; - if (json.source["osmTags"]) { - osmTags = FromJSON.Tag(json.source["osmTags"], context + "source.osmTags"); - } - - if(json.source["geoJsonSource"] !== undefined){ - throw context + "Use 'geoJson' instead of 'geoJsonSource'" - } - - this.source = new SourceConfig({ - osmTags: osmTags, - geojsonSource: json.source["geoJson"], - geojsonSourceLevel: json.source["geoJsonZoomLevel"], - overpassScript: json.source["overpassScript"], - isOsmCache: json.source["isOsmCache"] - }, this.id); - } else { - this.source = new SourceConfig({ - osmTags: legacy - }) - } - - - this.calculatedTags = undefined; - if (json.calculatedTags !== undefined) { - if (!official) { - console.warn(`Unofficial theme ${this.id} with custom javascript! This is a security risk`) - } - this.calculatedTags = []; - for (const kv of json.calculatedTags) { - - const index = kv.indexOf("=") - const key = kv.substring(0, index); - const code = kv.substring(index + 1); - - this.calculatedTags.push([key, code]) - } - } - - this.doNotDownload = json.doNotDownload ?? false; - this.passAllFeatures = json.passAllFeatures ?? false; - this.minzoom = json.minzoom ?? 0; - this.maxzoom = json.maxzoom ?? 1000; - this.wayHandling = json.wayHandling ?? 0; - this.presets = (json.presets ?? []).map((pr, i) => - ({ - title: Translations.T(pr.title, `${context}.presets[${i}].title`), - tags: pr.tags.map(t => FromJSON.SimpleTag(t)), - description: Translations.T(pr.description, `${context}.presets[${i}].description`) - })) - - - /** Given a key, gets the corresponding property from the json (or the default if not found - * - * The found value is interpreted as a tagrendering and fetched/parsed - * */ - function tr(key: string, deflt) { - const v = json[key]; - if (v === undefined || v === null) { - if (deflt === undefined) { - return undefined; - } - return new TagRenderingConfig(deflt, self.source.osmTags, `${context}.${key}.default value`); - } - if (typeof v === "string") { - const shared = SharedTagRenderings.SharedTagRendering.get(v); - if (shared) { - return shared; - } - } - return new TagRenderingConfig(v, self.source.osmTags, `${context}.${key}`); - } - - /** - * Converts a list of tagRenderingCOnfigJSON in to TagRenderingConfig - * A string is interpreted as a name to call - */ - function trs(tagRenderings?: (string | TagRenderingConfigJson)[], readOnly = false) { - if (tagRenderings === undefined) { - return []; - } - - return Utils.NoNull(tagRenderings.map( - (renderingJson, i) => { - if (typeof renderingJson === "string") { - - if (renderingJson === "questions") { - if (readOnly) { - throw `A tagrendering has a question, but asking a question does not make sense here: is it a title icon or a geojson-layer? ${context}. The offending tagrendering is ${JSON.stringify(renderingJson)}` - } - - return new TagRenderingConfig("questions", undefined) - } - - - const shared = SharedTagRenderings.SharedTagRendering.get(renderingJson); - if (shared !== undefined) { - return shared; - } - - const keys = Array.from(SharedTagRenderings.SharedTagRendering.keys()) - - if(Utils.runningFromConsole){ - return undefined; - } - - throw `Predefined tagRendering ${renderingJson} not found in ${context}.\n Try one of ${(keys.join(", "))}\n If you intent to output this text literally, use {\"render\": } instead"}`; - } - return new TagRenderingConfig(renderingJson, self.source.osmTags, `${context}.tagrendering[${i}]`); - })); - } - - this.tagRenderings = trs(json.tagRenderings, false); - - - const titleIcons = []; - const defaultIcons = ["phonelink", "emaillink", "wikipedialink", "osmlink", "sharelink"]; - for (const icon of (json.titleIcons ?? defaultIcons)) { - if (icon === "defaults") { - titleIcons.push(...defaultIcons); - } else { - titleIcons.push(icon); - } - } - - this.titleIcons = trs(titleIcons, true); - - - this.title = tr("title", undefined); - this.icon = tr("icon", ""); - this.iconOverlays = (json.iconOverlays ?? []).map((overlay, i) => { - let tr = new TagRenderingConfig(overlay.then, self.source.osmTags, `iconoverlays.${i}`); - if (typeof overlay.then === "string" && SharedTagRenderings.SharedIcons.get(overlay.then) !== undefined) { - tr = SharedTagRenderings.SharedIcons.get(overlay.then); - } - return { - if: FromJSON.Tag(overlay.if), - then: tr, - badge: overlay.badge ?? false - } - }); - - const iconPath = this.icon.GetRenderValue({id: "node/-1"}).txt; - if (iconPath.startsWith(Utils.assets_path)) { - const iconKey = iconPath.substr(Utils.assets_path.length); - if (Svg.All[iconKey] === undefined) { - throw "Builtin SVG asset not found: " + iconPath - } - } - this.isShown = tr("isShown", "yes"); - this.iconSize = tr("iconSize", "40,40,center"); - this.label = tr("label", "") - this.color = tr("color", "#0000ff"); - this.width = tr("width", "7"); - this.rotation = tr("rotation", "0"); - this.dashArray = tr("dashArray", ""); - - this.deletion = null; - if(json.deletion === true){ - json.deletion = { - } - } - if(json.deletion !== undefined && json.deletion !== false){ - this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`) - } - - - if (json["showIf"] !== undefined) { - throw "Invalid key on layerconfig " + this.id + ": showIf. Did you mean 'isShown' instead?"; - } + if (json.description !== undefined) { + if (Object.keys(json.description).length === 0) { + json.description = undefined; + } } - public CustomCodeSnippets(): string[] { - if (this.calculatedTags === undefined) { - return [] - } + this.description = Translations.T( + json.description, + context + ".description" + ); - return this.calculatedTags.map(code => code[1]); + let legacy = undefined; + if (json["overpassTags"] !== undefined) { + // @ts-ignore + legacy = FromJSON.Tag(json["overpassTags"], context + ".overpasstags"); } + if (json.source !== undefined) { + if (legacy !== undefined) { + throw ( + context + + "Both the legacy 'layer.overpasstags' and the new 'layer.source'-field are defined" + ); + } - public AddRoamingRenderings(addAll: { - tagRenderings: TagRenderingConfig[], - titleIcons: TagRenderingConfig[], - iconOverlays: { "if": TagsFilter, then: TagRenderingConfig, badge: boolean }[] + let osmTags: TagsFilter = legacy; + if (json.source["osmTags"]) { + osmTags = FromJSON.Tag( + json.source["osmTags"], + context + "source.osmTags" + ); + } - }): LayerConfig { + if (json.source["geoJsonSource"] !== undefined) { + throw context + "Use 'geoJson' instead of 'geoJsonSource'"; + } - let insertionPoint = this.tagRenderings.map(tr => tr.IsQuestionBoxElement()).indexOf(true) - if (insertionPoint < 0) { - // No 'questions' defined - we just add them all to the end - insertionPoint = this.tagRenderings.length; - } - this.tagRenderings.splice(insertionPoint, 0, ...addAll.tagRenderings); - - - this.iconOverlays.push(...addAll.iconOverlays); - for (const icon of addAll.titleIcons) { - this.titleIcons.splice(0, 0, icon); - } - return this; - } - - public GetRoamingRenderings(): { - tagRenderings: TagRenderingConfig[], - titleIcons: TagRenderingConfig[], - iconOverlays: { "if": TagsFilter, then: TagRenderingConfig, badge: boolean }[] - - } { - - const tagRenderings = this.tagRenderings.filter(tr => tr.roaming); - const titleIcons = this.titleIcons.filter(tr => tr.roaming); - const iconOverlays = this.iconOverlays.filter(io => io.then.roaming) - - return { - tagRenderings: tagRenderings, - titleIcons: titleIcons, - iconOverlays: iconOverlays - } - - } - - public GenerateLeafletStyle(tags: UIEventSource, clickable: boolean, widthHeight= "100%"): + this.source = new SourceConfig( { - icon: - { - html: BaseUIElement, - iconSize: [number, number], - iconAnchor: [number, number], - popupAnchor: [number, number], - iconUrl: string, - className: string - }, - color: string, - weight: number, - dashArray: number[] - } { + osmTags: osmTags, + geojsonSource: json.source["geoJson"], + geojsonSourceLevel: json.source["geoJsonZoomLevel"], + overpassScript: json.source["overpassScript"], + isOsmCache: json.source["isOsmCache"], + }, + this.id + ); + } else { + this.source = new SourceConfig({ + osmTags: legacy, + }); + } - function num(str, deflt = 40) { - const n = Number(str); - if (isNaN(n)) { - return deflt; - } - return n; + this.calculatedTags = undefined; + if (json.calculatedTags !== undefined) { + if (!official) { + console.warn( + `Unofficial theme ${this.id} with custom javascript! This is a security risk` + ); + } + this.calculatedTags = []; + for (const kv of json.calculatedTags) { + const index = kv.indexOf("="); + const key = kv.substring(0, index); + const code = kv.substring(index + 1); + + this.calculatedTags.push([key, code]); + } + } + + this.doNotDownload = json.doNotDownload ?? false; + this.passAllFeatures = json.passAllFeatures ?? false; + this.minzoom = json.minzoom ?? 0; + this.maxzoom = json.maxzoom ?? 1000; + this.wayHandling = json.wayHandling ?? 0; + this.presets = (json.presets ?? []).map((pr, i) => ({ + title: Translations.T(pr.title, `${context}.presets[${i}].title`), + tags: pr.tags.map((t) => FromJSON.SimpleTag(t)), + description: Translations.T( + pr.description, + `${context}.presets[${i}].description` + ), + })); + + /** Given a key, gets the corresponding property from the json (or the default if not found + * + * The found value is interpreted as a tagrendering and fetched/parsed + * */ + function tr(key: string, deflt) { + const v = json[key]; + if (v === undefined || v === null) { + if (deflt === undefined) { + return undefined; } - - function rendernum(tr: TagRenderingConfig, deflt: number) { - const str = Number(render(tr, "" + deflt)); - const n = Number(str); - if (isNaN(n)) { - return deflt; - } - return n; + return new TagRenderingConfig( + deflt, + self.source.osmTags, + `${context}.${key}.default value` + ); + } + if (typeof v === "string") { + const shared = SharedTagRenderings.SharedTagRendering.get(v); + if (shared) { + return shared; } + } + return new TagRenderingConfig( + v, + self.source.osmTags, + `${context}.${key}` + ); + } - function render(tr: TagRenderingConfig, deflt?: string) { - const str = (tr?.GetRenderValue(tags.data)?.txt ?? deflt); - return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); - } + /** + * Converts a list of tagRenderingCOnfigJSON in to TagRenderingConfig + * A string is interpreted as a name to call + */ + function trs( + tagRenderings?: (string | TagRenderingConfigJson)[], + readOnly = false + ) { + if (tagRenderings === undefined) { + return []; + } - const iconSize = render(this.iconSize, "40,40,center").split(","); - const dashArray = render(this.dashArray).split(" ").map(Number); - let color = render(this.color, "#00f"); + return Utils.NoNull( + tagRenderings.map((renderingJson, i) => { + if (typeof renderingJson === "string") { + if (renderingJson === "questions") { + if (readOnly) { + throw `A tagrendering has a question, but asking a question does not make sense here: is it a title icon or a geojson-layer? ${context}. The offending tagrendering is ${JSON.stringify( + renderingJson + )}`; + } - if (color.startsWith("--")) { - color = getComputedStyle(document.body).getPropertyValue("--catch-detail-color") - } - - const weight = rendernum(this.width, 5); - - const iconW = num(iconSize[0]); - let iconH = num(iconSize[1]); - const mode = iconSize[2]?.trim()?.toLowerCase() ?? "center" - - let anchorW = iconW / 2; - let anchorH = iconH / 2; - if (mode === "left") { - anchorW = 0; - } - if (mode === "right") { - anchorW = iconW; - } - - if (mode === "top") { - anchorH = 0; - } - if (mode === "bottom") { - anchorH = iconH; - } - - const iconUrlStatic = render(this.icon); - const self = this; - const mappedHtml = tags.map(tgs => { - function genHtmlFromString(sourcePart: string): BaseUIElement { - - const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`; - let html: BaseUIElement = new FixedUiElement(``); - const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/) - if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { - html = new Combine([ - (Svg.All[match[1] + ".svg"] as string) - .replace(/#000000/g, match[2]) - ]).SetStyle(style); - } - return html; + return new TagRenderingConfig("questions", undefined); } - - // What do you mean, 'tgs' is never read? - // It is read implicitly in the 'render' method - const iconUrl = render(self.icon); - const rotation = render(self.rotation, "0deg"); - - let htmlParts: BaseUIElement[] = []; - let sourceParts = Utils.NoNull(iconUrl.split(";").filter(prt => prt != "")); - for (const sourcePart of sourceParts) { - htmlParts.push(genHtmlFromString(sourcePart)) + const shared = + SharedTagRenderings.SharedTagRendering.get(renderingJson); + if (shared !== undefined) { + return shared; } - let badges = []; - for (const iconOverlay of self.iconOverlays) { - if (!iconOverlay.if.matchesProperties(tgs)) { - continue; - } - if (iconOverlay.badge) { - const badgeParts: BaseUIElement[] = []; - const partDefs = iconOverlay.then.GetRenderValue(tgs).txt.split(";").filter(prt => prt != ""); + const keys = Array.from( + SharedTagRenderings.SharedTagRendering.keys() + ); - for (const badgePartStr of partDefs) { - badgeParts.push(genHtmlFromString(badgePartStr)) - } - - const badgeCompound = new Combine(badgeParts) - .SetStyle("display:flex;position:relative;width:100%;height:100%;"); - - badges.push(badgeCompound) - - } else { - htmlParts.push(genHtmlFromString( - iconOverlay.then.GetRenderValue(tgs).txt)); - } + if (Utils.runningFromConsole) { + return undefined; } - if (badges.length > 0) { - const badgesComponent = new Combine(badges) - .SetStyle("display:flex;height:50%;width:100%;position:absolute;top:50%;left:50%;"); - htmlParts.push(badgesComponent) - } - - if (sourceParts.length == 0) { - iconH = 0 - } - try { - - const label = self.label?.GetRenderValue(tgs)?.Subs(tgs) - ?.SetClass("block text-center") - ?.SetStyle("margin-top: " + (iconH + 2) + "px") - if (label !== undefined) { - htmlParts.push(new Combine([label]).SetClass("flex flex-col items-center")) - } - } catch (e) { - console.error(e, tgs) - } - return new Combine(htmlParts); + throw `Predefined tagRendering ${renderingJson} not found in ${context}.\n Try one of ${keys.join( + ", " + )}\n If you intent to output this text literally, use {\"render\": } instead"}`; + } + return new TagRenderingConfig( + renderingJson, + self.source.osmTags, + `${context}.tagrendering[${i}]` + ); }) - - - return { - icon: - { - html: new VariableUiElement(mappedHtml), - iconSize: [iconW, iconH], - iconAnchor: [anchorW, anchorH], - popupAnchor: [0, 3 - anchorH], - iconUrl: iconUrlStatic, - className: clickable ? "leaflet-div-icon" : "leaflet-div-icon unclickable" - }, - color: color, - weight: weight, - dashArray: dashArray - }; + ); } - public ExtractImages(): Set { - const parts: Set[] = [] - parts.push(...this.tagRenderings?.map(tr => tr.ExtractImages(false))) - parts.push(...this.titleIcons?.map(tr => tr.ExtractImages(true))) - parts.push(this.icon?.ExtractImages(true)) - parts.push(...this.iconOverlays?.map(overlay => overlay.then.ExtractImages(true))) - for (const preset of this.presets) { - parts.push(new Set(preset.description?.ExtractImages(false))) - } + this.tagRenderings = trs(json.tagRenderings, false); - const allIcons = new Set(); - for (const part of parts) { - part?.forEach(allIcons.add, allIcons) - } + this.filters = (json.filter ?? []).map((option, i) => { + return new FilterConfig(option, `${context}.filter-[${i}]`) + }); - return allIcons; + const titleIcons = []; + const defaultIcons = [ + "phonelink", + "emaillink", + "wikipedialink", + "osmlink", + "sharelink", + ]; + for (const icon of json.titleIcons ?? defaultIcons) { + if (icon === "defaults") { + titleIcons.push(...defaultIcons); + } else { + titleIcons.push(icon); + } } -} \ No newline at end of file + this.titleIcons = trs(titleIcons, true); + + this.title = tr("title", undefined); + this.icon = tr("icon", ""); + this.iconOverlays = (json.iconOverlays ?? []).map((overlay, i) => { + let tr = new TagRenderingConfig( + overlay.then, + self.source.osmTags, + `iconoverlays.${i}` + ); + if ( + typeof overlay.then === "string" && + SharedTagRenderings.SharedIcons.get(overlay.then) !== undefined + ) { + tr = SharedTagRenderings.SharedIcons.get(overlay.then); + } + return { + if: FromJSON.Tag(overlay.if), + then: tr, + badge: overlay.badge ?? false, + }; + }); + + const iconPath = this.icon.GetRenderValue({ id: "node/-1" }).txt; + if (iconPath.startsWith(Utils.assets_path)) { + const iconKey = iconPath.substr(Utils.assets_path.length); + if (Svg.All[iconKey] === undefined) { + throw "Builtin SVG asset not found: " + iconPath; + } + } + this.isShown = tr("isShown", "yes"); + this.iconSize = tr("iconSize", "40,40,center"); + this.label = tr("label", ""); + this.color = tr("color", "#0000ff"); + this.width = tr("width", "7"); + this.rotation = tr("rotation", "0"); + this.dashArray = tr("dashArray", ""); + + this.deletion = null; + if (json.deletion === true) { + json.deletion = {}; + } + if (json.deletion !== undefined && json.deletion !== false) { + this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`); + } + + if (json["showIf"] !== undefined) { + throw ( + "Invalid key on layerconfig " + + this.id + + ": showIf. Did you mean 'isShown' instead?" + ); + } + } + + public CustomCodeSnippets(): string[] { + if (this.calculatedTags === undefined) { + return []; + } + + return this.calculatedTags.map((code) => code[1]); + } + + public AddRoamingRenderings(addAll: { + tagRenderings: TagRenderingConfig[]; + titleIcons: TagRenderingConfig[]; + iconOverlays: { + if: TagsFilter; + then: TagRenderingConfig; + badge: boolean; + }[]; + }): LayerConfig { + let insertionPoint = this.tagRenderings + .map((tr) => tr.IsQuestionBoxElement()) + .indexOf(true); + if (insertionPoint < 0) { + // No 'questions' defined - we just add them all to the end + insertionPoint = this.tagRenderings.length; + } + this.tagRenderings.splice(insertionPoint, 0, ...addAll.tagRenderings); + + this.iconOverlays.push(...addAll.iconOverlays); + for (const icon of addAll.titleIcons) { + this.titleIcons.splice(0, 0, icon); + } + return this; + } + + public GetRoamingRenderings(): { + tagRenderings: TagRenderingConfig[]; + titleIcons: TagRenderingConfig[]; + iconOverlays: { + if: TagsFilter; + then: TagRenderingConfig; + badge: boolean; + }[]; + } { + const tagRenderings = this.tagRenderings.filter((tr) => tr.roaming); + const titleIcons = this.titleIcons.filter((tr) => tr.roaming); + const iconOverlays = this.iconOverlays.filter((io) => io.then.roaming); + + return { + tagRenderings: tagRenderings, + titleIcons: titleIcons, + iconOverlays: iconOverlays, + }; + } + + public GenerateLeafletStyle( + tags: UIEventSource, + clickable: boolean, + widthHeight = "100%" + ): { + icon: { + html: BaseUIElement; + iconSize: [number, number]; + iconAnchor: [number, number]; + popupAnchor: [number, number]; + iconUrl: string; + className: string; + }; + color: string; + weight: number; + dashArray: number[]; + } { + function num(str, deflt = 40) { + const n = Number(str); + if (isNaN(n)) { + return deflt; + } + return n; + } + + function rendernum(tr: TagRenderingConfig, deflt: number) { + const str = Number(render(tr, "" + deflt)); + const n = Number(str); + if (isNaN(n)) { + return deflt; + } + return n; + } + + function render(tr: TagRenderingConfig, deflt?: string) { + const str = tr?.GetRenderValue(tags.data)?.txt ?? deflt; + return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); + } + + const iconSize = render(this.iconSize, "40,40,center").split(","); + const dashArray = render(this.dashArray).split(" ").map(Number); + let color = render(this.color, "#00f"); + + if (color.startsWith("--")) { + color = getComputedStyle(document.body).getPropertyValue( + "--catch-detail-color" + ); + } + + const weight = rendernum(this.width, 5); + + const iconW = num(iconSize[0]); + let iconH = num(iconSize[1]); + const mode = iconSize[2]?.trim()?.toLowerCase() ?? "center"; + + let anchorW = iconW / 2; + let anchorH = iconH / 2; + if (mode === "left") { + anchorW = 0; + } + if (mode === "right") { + anchorW = iconW; + } + + if (mode === "top") { + anchorH = 0; + } + if (mode === "bottom") { + anchorH = iconH; + } + + const iconUrlStatic = render(this.icon); + const self = this; + const mappedHtml = tags.map((tgs) => { + function genHtmlFromString(sourcePart: string): BaseUIElement { + const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`; + let html: BaseUIElement = new FixedUiElement( + `` + ); + const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/); + if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { + html = new Combine([ + (Svg.All[match[1] + ".svg"] as string).replace( + /#000000/g, + match[2] + ), + ]).SetStyle(style); + } + return html; + } + + // What do you mean, 'tgs' is never read? + // It is read implicitly in the 'render' method + const iconUrl = render(self.icon); + const rotation = render(self.rotation, "0deg"); + + let htmlParts: BaseUIElement[] = []; + let sourceParts = Utils.NoNull( + iconUrl.split(";").filter((prt) => prt != "") + ); + for (const sourcePart of sourceParts) { + htmlParts.push(genHtmlFromString(sourcePart)); + } + + let badges = []; + for (const iconOverlay of self.iconOverlays) { + if (!iconOverlay.if.matchesProperties(tgs)) { + continue; + } + if (iconOverlay.badge) { + const badgeParts: BaseUIElement[] = []; + const partDefs = iconOverlay.then + .GetRenderValue(tgs) + .txt.split(";") + .filter((prt) => prt != ""); + + for (const badgePartStr of partDefs) { + badgeParts.push(genHtmlFromString(badgePartStr)); + } + + const badgeCompound = new Combine(badgeParts).SetStyle( + "display:flex;position:relative;width:100%;height:100%;" + ); + + badges.push(badgeCompound); + } else { + htmlParts.push( + genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt) + ); + } + } + + if (badges.length > 0) { + const badgesComponent = new Combine(badges).SetStyle( + "display:flex;height:50%;width:100%;position:absolute;top:50%;left:50%;" + ); + htmlParts.push(badgesComponent); + } + + if (sourceParts.length == 0) { + iconH = 0; + } + try { + const label = self.label + ?.GetRenderValue(tgs) + ?.Subs(tgs) + ?.SetClass("block text-center") + ?.SetStyle("margin-top: " + (iconH + 2) + "px"); + if (label !== undefined) { + htmlParts.push( + new Combine([label]).SetClass("flex flex-col items-center") + ); + } + } catch (e) { + console.error(e, tgs); + } + return new Combine(htmlParts); + }); + + return { + icon: { + html: new VariableUiElement(mappedHtml), + iconSize: [iconW, iconH], + iconAnchor: [anchorW, anchorH], + popupAnchor: [0, 3 - anchorH], + iconUrl: iconUrlStatic, + className: clickable + ? "leaflet-div-icon" + : "leaflet-div-icon unclickable", + }, + color: color, + weight: weight, + dashArray: dashArray, + }; + } + + public ExtractImages(): Set { + const parts: Set[] = []; + parts.push(...this.tagRenderings?.map((tr) => tr.ExtractImages(false))); + parts.push(...this.titleIcons?.map((tr) => tr.ExtractImages(true))); + parts.push(this.icon?.ExtractImages(true)); + parts.push( + ...this.iconOverlays?.map((overlay) => overlay.then.ExtractImages(true)) + ); + for (const preset of this.presets) { + parts.push(new Set(preset.description?.ExtractImages(false))); + } + + const allIcons = new Set(); + for (const part of parts) { + part?.forEach(allIcons.add, allIcons); + } + + return allIcons; + } +} diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index d81307fd9..a3ae74080 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -1,6 +1,7 @@ import {TagRenderingConfigJson} from "./TagRenderingConfigJson"; import {AndOrTagConfigJson} from "./TagConfigJson"; import {DeleteConfigJson} from "./DeleteConfigJson"; +import FilterConfigJson from "./FilterConfigJson"; /** * Configuration for a single layer @@ -233,6 +234,12 @@ export interface LayerConfigJson { */ tagRenderings?: (string | TagRenderingConfigJson) [], + + /** + * All the extra questions for filtering + */ + filter?: (FilterConfigJson) [], + /** * This block defines under what circumstances the delete dialog is shown for objects of this layer. * If set, a dialog is shown to the user to (soft) delete the point. diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index 1aff3d6f8..9b5b79386 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -39,11 +39,11 @@ export default class FilterView extends ScrollableFullScreen { const checkboxes: BaseUIElement[] = []; for (const layer of activeLayers.data) { - const icon = new Combine([Svg.checkbox_filled]).SetStyle( - "width:1.5rem;height:1.5rem" - ); + const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem"; + + const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle); const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle( - "width:1.5rem;height:1.5rem" + iconStyle ); if (layer.layerDef.name === undefined) { @@ -53,13 +53,22 @@ export default class FilterView extends ScrollableFullScreen { const style = "display:flex;align-items:center;color:#007759"; const name: Translation = Translations.WT(layer.layerDef.name)?.Clone(); - name.SetStyle("font-size:large;"); - const layerChecked = new Combine([icon, name.Clone()]).SetStyle(style); + const styledNameChecked = name + .Clone() + .SetStyle("font-size:large;padding-left:1.25rem"); + + const styledNameUnChecked = name + .Clone() + .SetStyle("font-size:large;padding-left:1.25rem"); + + const layerChecked = new Combine([icon, styledNameChecked]).SetStyle( + style + ); const layerNotChecked = new Combine([ iconUnselected, - name.Clone(), + styledNameUnChecked, ]).SetStyle(style); checkboxes.push(