From 91d2272861ac982a23ce14837480d79d3b559b8f Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Fri, 7 Jan 2022 17:31:39 +0100 Subject: [PATCH] First working version of the notes-layer, add filtering --- Logic/ExtraFunctions.ts | 4 + Logic/FeatureSource/FeaturePipeline.ts | 60 ++++++----- .../PerLayerFeatureSourceSplitter.ts | 2 +- .../Sources/FilteringFeatureSource.ts | 6 +- .../TiledFeatureSource/DynamicTileSource.ts | 6 +- Logic/MetaTagging.ts | 3 +- Logic/State/FeaturePipelineState.ts | 3 +- Models/Constants.ts | 2 +- Models/ThemeConfig/FilterConfig.ts | 48 +++++++-- Models/ThemeConfig/Json/FilterConfigJson.ts | 9 +- Models/ThemeConfig/TagRenderingConfig.ts | 2 +- Models/TileRange.ts | 2 +- UI/AutomatonGui.ts | 2 +- UI/BigComponents/FilterView.ts | 70 ++++++++++++- UI/Input/ValidatedTextField.ts | 9 +- UI/Popup/FeatureInfoBox.ts | 4 +- Utils.ts | 99 ++++++++++++------- assets/themes/notes/notes.json | 56 +++++++---- css/index-tailwind-output.css | 4 + 19 files changed, 282 insertions(+), 109 deletions(-) diff --git a/Logic/ExtraFunctions.ts b/Logic/ExtraFunctions.ts index ce571546a..7d523bde0 100644 --- a/Logic/ExtraFunctions.ts +++ b/Logic/ExtraFunctions.ts @@ -403,6 +403,10 @@ export class ExtraFunctions { ]; public static FullPatchFeature(params: ExtraFuncParams, feature) { + if(feature._is_patched){ + return + } + feature._is_patched = true for (const func of ExtraFunctions.allFuncs) { feature[func._name] = func._f(params, feature) } diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index d0f03b03b..125428296 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -59,7 +59,8 @@ export default class FeaturePipeline { private readonly localStorageSavers = new Map() private readonly metataggingRecalculated = new UIEventSource(undefined) - + private readonly requestMetataggingRecalculation = new UIEventSource(undefined) + /** * Keeps track of all raw OSM-nodes. * Only initialized if 'type_node' is defined as layer @@ -97,6 +98,10 @@ export default class FeaturePipeline { } ); + this.requestMetataggingRecalculation.stabilized(500).addCallbackAndRunD(_ => { + self.updateAllMetaTagging("Request stabilized") + }) + const neededTilesFromOsm = this.getNeededTilesFromOsm(this.sufficientlyZoomed) const perLayerHierarchy = new Map() @@ -141,7 +146,7 @@ export default class FeaturePipeline { tile => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile)) }); continue; } @@ -169,7 +174,10 @@ export default class FeaturePipeline { if (id === "current_view") { handlePriviligedFeatureSource(state.currentView) state.currentView.features.map(ffs => ffs[0]?.feature?.properties?.id).withEqualityStabilized((x,y) => x === y) - .addCallbackAndRunD(_ => self.applyMetaTags(state.currentView, state)) + .addCallbackAndRunD(_ => { + self.applyMetaTags(state.currentView, this.state, `currentview changed`) + } + ) continue } @@ -187,7 +195,7 @@ export default class FeaturePipeline { console.debug("Loaded tile ", id, tile.tileIndex, "from local cache") new RegisteringAllFromFeatureSourceActor(tile, state.allElements) hierarchy.registerTile(tile); - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile)) } ) @@ -207,13 +215,13 @@ export default class FeaturePipeline { registerTile: (tile) => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(id).registerTile(tile) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile)) } }) } else { new RegisteringAllFromFeatureSourceActor(src, state.allElements) perLayerHierarchy.get(id).registerTile(src) - src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src)) + src.features.addCallbackAndRunD(_ => self.onNewDataLoaded(src)) } } else { new DynamicGeoJsonTileSource( @@ -221,7 +229,7 @@ export default class FeaturePipeline { tile => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(id).registerTile(tile) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile)) }, state ) @@ -242,7 +250,7 @@ export default class FeaturePipeline { saver?.addTile(tile) } perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile)) }, state: state, @@ -282,7 +290,12 @@ export default class FeaturePipeline { // We save the tile data for the given layer to local storage - data sourced from overpass self.localStorageSavers.get(tile.layer.layerDef.id)?.addTile(tile) perLayerHierarchy.get(source.layer.layerDef.id).registerTile(new RememberingSource(tile)) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + tile.features.addCallbackAndRunD(f => { + if(f.length === 0){ + return + } + self.onNewDataLoaded(tile) + }) } }), @@ -302,9 +315,7 @@ export default class FeaturePipeline { // We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer) // AT last, we always apply the metatags whenever possible - // @ts-ignore - perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer, state)) - perLayer.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(perLayer)) + perLayer.features.addCallbackAndRunD(_ => self.onNewDataLoaded(perLayer)) }, newGeometry @@ -312,8 +323,8 @@ export default class FeaturePipeline { // Whenever fresh data comes in, we need to update the metatagging - self.newDataLoadedSignal.stabilized(250).addCallback(_ => { - self.updateAllMetaTagging() + self.newDataLoadedSignal.stabilized(250).addCallback(src => { + self.updateAllMetaTagging(`New data loaded by ${src.name} (and stabilized)`) }) @@ -325,9 +336,13 @@ export default class FeaturePipeline { }, [osmFeatureSource.isRunning] ) - } + private onNewDataLoaded(src: FeatureSource){ + this.newDataLoadedSignal.setData(src) + this.requestMetataggingRecalculation.setData(new Date()) + } + public GetAllFeaturesWithin(bbox: BBox): any[][] { const self = this const tiles = [] @@ -471,12 +486,16 @@ export default class FeaturePipeline { return updater; } - private applyMetaTags(src: FeatureSourceForLayer, state: any) { + private applyMetaTags(src: FeatureSourceForLayer, state: any, reason: string) { const self = this if(src === undefined){ throw "Src is undefined" } const layerDef = src.layer.layerDef; + console.debug(`Applying metatags onto ${src.name} due to ${reason} which has ${src.features.data?.length} features`) + if(src.features.data.length == 0){ + return + } MetaTagging.addMetatags( src.features.data, { @@ -494,18 +513,15 @@ export default class FeaturePipeline { ) } + - public updateAllMetaTagging() { + public updateAllMetaTagging(reason: string) { const self = this; - console.debug("Updating the meta tagging of all tiles as new data got loaded") this.perLayerHierarchy.forEach(hierarchy => { hierarchy.loadedTiles.forEach(tile => { - self.applyMetaTags(tile, this.state) + self.applyMetaTags(tile, this.state, `${reason} (tile ${tile.tileIndex})`) }) }) - if(this.state.currentView !== undefined){ - this.applyMetaTags(this.state.currentView, this.state) - } self.metataggingRecalculated.ping() } diff --git a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index 078db5fb4..3b18e2144 100644 --- a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -26,7 +26,7 @@ export default class PerLayerFeatureSourceSplitter { if (features === undefined) { return; } - if (layers.data === undefined) { + if (layers.data === undefined || layers.data.length === 0) { return; } diff --git a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts index 6f9717c2d..a6fcee559 100644 --- a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts @@ -4,7 +4,6 @@ import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import Hash from "../../Web/Hash"; import {BBox} from "../../BBox"; import {ElementStorage} from "../../ElementStorage"; -import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { public features: UIEventSource<{ feature: any; freshness: Date }[]> = @@ -71,8 +70,8 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti self.registerCallback(f.feature) if ( - this.state.selectedElement.data?.id === f.feature.id || - f.feature.id === Hash.hash.data) { + (this.state.selectedElement !== undefined && this.state.selectedElement.data?.id === f.feature.properties.id) || + (Hash.hash.data !== undefined && f.feature.properties.id === Hash.hash.data)) { // This is the selected object - it gets a free pass even if zoom is not sufficient or it is filtered away return true; } @@ -89,6 +88,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti } const tagsFilter = layer.appliedFilters.data; + console.log("Current filters for "+layer.layerDef.id+" are ",tagsFilter) for (const filter of tagsFilter ?? []) { const neededTags = filter.filter.options[filter.selected].osmTags if (!neededTags.matchesProperties(f.feature.properties)) { diff --git a/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts b/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts index bcc7b7186..7019364aa 100644 --- a/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts @@ -44,7 +44,11 @@ export default class DynamicTileSource implements TileHierarchy 10000){ + console.error("Got a really big tilerange, bounds and location might be out of sync") + return undefined + } + const needed = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i)) if (needed.length === 0) { return undefined diff --git a/Logic/MetaTagging.ts b/Logic/MetaTagging.ts index 13f67c606..a48dae3d9 100644 --- a/Logic/MetaTagging.ts +++ b/Logic/MetaTagging.ts @@ -28,7 +28,6 @@ export default class MetaTagging { includeDates?: true | boolean, includeNonDates?: true | boolean }): boolean { - if (features === undefined || features.length === 0) { return; } @@ -106,7 +105,6 @@ export default class MetaTagging { } public static createFunctionsForFeature(layerId: string, calculatedTags: [string, string, boolean][]): ((feature: any) => void)[] { const functions: ((feature: any) => any)[] = []; - for (const entry of calculatedTags) { const key = entry[0] const code = entry[1]; @@ -148,6 +146,7 @@ export default class MetaTagging { // Lazy function const f = (feature: any) => { + const oldValue = feature.properties[key] delete feature.properties[key] Object.defineProperty(feature.properties, key, { configurable: true, diff --git a/Logic/State/FeaturePipelineState.ts b/Logic/State/FeaturePipelineState.ts index 5f1e6d426..90f0539e1 100644 --- a/Logic/State/FeaturePipelineState.ts +++ b/Logic/State/FeaturePipelineState.ts @@ -9,7 +9,6 @@ import MapState from "./MapState"; import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler"; import Hash from "../Web/Hash"; import {BBox} from "../BBox"; -import {FeatureSourceForLayer} from "../FeatureSource/FeatureSource"; export default class FeaturePipelineState extends MapState { @@ -33,7 +32,7 @@ export default class FeaturePipelineState extends MapState { const sourceBBox = source.features.map(allFeatures => BBox.bboxAroundAll(allFeatures.map(f => BBox.get(f.feature)))) - // Do show features indicates if the 'showDataLayer' should be shown + // Do show features indicates if the respective 'showDataLayer' should be shown. It can be hidden by e.g. clustering const doShowFeatures = source.features.map( f => { const z = self.locationControl.data.zoom diff --git a/Models/Constants.ts b/Models/Constants.ts index 22e85ed96..227f2cf19 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -22,7 +22,7 @@ export default class Constants { /** * Layer IDs of layers which have special properties through built-in hooks */ - public static readonly priviliged_layers: string[] = [...Constants.added_by_default, "type_node", ...Constants.no_include] + public static readonly priviliged_layers: string[] = [...Constants.added_by_default, "type_node", "notes", ...Constants.no_include] // The user journey states thresholds when a new feature gets unlocked diff --git a/Models/ThemeConfig/FilterConfig.ts b/Models/ThemeConfig/FilterConfig.ts index 833ae5050..5f23e0e44 100644 --- a/Models/ThemeConfig/FilterConfig.ts +++ b/Models/ThemeConfig/FilterConfig.ts @@ -3,14 +3,20 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import FilterConfigJson from "./Json/FilterConfigJson"; import Translations from "../../UI/i18n/Translations"; import {TagUtils} from "../../Logic/Tags/TagUtils"; +import ValidatedTextField from "../../UI/Input/ValidatedTextField"; +import {Utils} from "../../Utils"; +import {TagRenderingConfigJson} from "./Json/TagRenderingConfigJson"; +import {AndOrTagConfigJson} from "./Json/TagConfigJson"; export default class FilterConfig { public readonly id: string public readonly options: { question: Translation; osmTags: TagsFilter; + originalTagsSpec: string | AndOrTagConfigJson + fields: { name: string, type: string }[] }[]; - + constructor(json: FilterConfigJson, context: string) { if (json.options === undefined) { throw `A filter without options was given at ${context}` @@ -28,23 +34,49 @@ export default class FilterConfig { } this.id = json.id; this.options = json.options.map((option, i) => { + const ctx = `${context}.options[${i}]`; const question = Translations.T( option.question, - context + ".options-[" + i + "].question" - ); - const osmTags = TagUtils.Tag( - option.osmTags ?? {and: []}, - `${context}.options-[${i}].osmTags` + `${ctx}.question` ); + let osmTags = TagUtils.Tag( + option.osmTags ?? {and: []}, + `${ctx}.osmTags` + ); + if (question === undefined) { - throw `Invalid filter: no question given at ${context}[${i}]` + throw `Invalid filter: no question given at ${ctx}` } - return {question: question, osmTags: osmTags}; + const fields: { name: string, type: string }[] = ((option.fields) ?? []).map((f, i) => { + const type = f.type ?? "string" + if (!ValidatedTextField.AllTypes.has(type)) { + throw `Invalid filter: ${type} is not a valid validated textfield type (at ${ctx}.fields[${i}])\n\tTry one of ${Array.from(ValidatedTextField.AllTypes.keys()).join(",")}` + } + if (f.name === undefined || f.name === "" || f.name.match(/[a-z0-9_-]+/) == null) { + throw `Invalid filter: a variable name should match [a-z0-9_-]+ at ${ctx}.fields[${i}]` + } + return { + name: f.name, + type + } + }) + + if(fields.length > 0){ + // erase the tags, they aren't needed + osmTags = TagUtils.Tag({and:[]}) + } + + return {question: question, osmTags: osmTags, fields, originalTagsSpec: option.osmTags}; }); + if (this.options.some(o => o.fields.length > 0) && this.options.length > 1) { + throw `Invalid filter at ${context}: a filter with textfields should only offer a single option.` + } + if (this.options.length > 1 && this.options[0].osmTags["and"]?.length !== 0) { throw "Error in " + context + "." + this.id + ": the first option of a multi-filter should always be the 'reset' option and not have any filters" } } + } \ No newline at end of file diff --git a/Models/ThemeConfig/Json/FilterConfigJson.ts b/Models/ThemeConfig/Json/FilterConfigJson.ts index 7151e3854..25bcd1ea8 100644 --- a/Models/ThemeConfig/Json/FilterConfigJson.ts +++ b/Models/ThemeConfig/Json/FilterConfigJson.ts @@ -11,5 +11,12 @@ export default interface FilterConfigJson { * 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 }[]; + options: { + question: string | any; + osmTags?: AndOrTagConfigJson | string, + fields?: { + name: string, + type?: string | "string" + }[] + }[]; } \ No newline at end of file diff --git a/Models/ThemeConfig/TagRenderingConfig.ts b/Models/ThemeConfig/TagRenderingConfig.ts index 23f617583..093c6ce9f 100644 --- a/Models/ThemeConfig/TagRenderingConfig.ts +++ b/Models/ThemeConfig/TagRenderingConfig.ts @@ -111,7 +111,7 @@ export default class TagRenderingConfig { } - if (ValidatedTextField.AllTypes[this.freeform.type] === undefined) { + if (!ValidatedTextField.AllTypes.has(this.freeform.type)) { const knownKeys = ValidatedTextField.tpList.map(tp => tp.name).join(", "); throw `Freeform.key ${this.freeform.key} is an invalid type. Known keys are ${knownKeys}` } diff --git a/Models/TileRange.ts b/Models/TileRange.ts index 96143b23d..f5be84e0b 100644 --- a/Models/TileRange.ts +++ b/Models/TileRange.ts @@ -16,7 +16,7 @@ export class Tiles { const result: T[] = [] const total = tileRange.total if (total > 100000) { - throw "Tilerange too big (z is "+tileRange.zoomlevel+")" + throw `Tilerange too big (z is ${tileRange.zoomlevel}, total tiles needed: ${tileRange.total})` } for (let x = tileRange.xstart; x <= tileRange.xend; x++) { for (let y = tileRange.ystart; y <= tileRange.yend; y++) { diff --git a/UI/AutomatonGui.ts b/UI/AutomatonGui.ts index 7712191bb..b462da25c 100644 --- a/UI/AutomatonGui.ts +++ b/UI/AutomatonGui.ts @@ -162,7 +162,7 @@ class AutomationPanel extends Combine{ return true; } stateToShow.setData("Applying metatags") - pipeline.updateAllMetaTagging() + pipeline.updateAllMetaTagging("triggered by automaton") stateToShow.setData("Gathering applicable elements") let handled = 0 diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index 011083a3f..13b431153 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -14,6 +14,9 @@ import FilteredLayer from "../../Models/FilteredLayer"; import BackgroundSelector from "./BackgroundSelector"; import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; +import {SubstitutedTranslation} from "../SubstitutedTranslation"; +import ValidatedTextField from "../Input/ValidatedTextField"; +import {QueryParameters} from "../../Logic/Web/QueryParameters"; export default class FilterView extends VariableUiElement { constructor(filteredLayer: UIEventSource, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource }[]) { @@ -144,7 +147,7 @@ export default class FilterView extends VariableUiElement { layer.filters.forEach((f, i) => filterIndexes.set(f.id, i)) let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map( - FilterView.createFilter + filter => FilterView.createFilter(filter) ); listFilterElements.forEach((inputElement, i) => @@ -193,6 +196,71 @@ export default class FilterView extends VariableUiElement { } private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>] { + + if (filterConfig.options[0].fields.length > 0) { + + // Filter which uses one or more textfields + const filter = filterConfig.options[0] + const mappings = new Map() + let allValid = new UIEventSource(true) + const properties = new UIEventSource({}) + for (const {name, type} of filter.fields) { + const value = QueryParameters.GetQueryParameter("filter-" + filterConfig.id + "-" + name, "", "Value for filter " + filterConfig.id) + const field = ValidatedTextField.InputForType(type, { + value + }).SetClass("inline-block") + mappings.set(name, field) + const stable = value.stabilized(250) + stable.addCallbackAndRunD(v => { + properties.data[name] = v.toLowerCase(); + properties.ping() + }) + allValid = allValid.map(previous => previous && field.IsValid(stable.data) && stable.data !== "", [stable]) + } + const tr = new SubstitutedTranslation(filter.question, new UIEventSource({id: filterConfig.id}), State.state, mappings) + const neutral = { + filter: new FilterConfig({ + id: filterConfig.id, + options: [ + { + question: "--", + } + ] + }, "While dynamically constructing a filterconfig"), + selected: 0 + } + const trigger = allValid.map(isValid => { + if (!isValid) { + return neutral + } + + // Replace all the field occurences in the tags... + const osmTags = Utils.WalkJson(filter.originalTagsSpec, + v => { + if (typeof v !== "string") { + return v + } + return Utils.SubstituteKeys(v, properties.data) + } + ) + // ... which we use below to construct a filter! + return { + filter: new FilterConfig({ + id: filterConfig.id, + options: [ + { + question: "--", + osmTags + } + ] + }, "While dynamically constructing a filterconfig"), + selected: 0 + } + }, [properties]) + return [tr, trigger]; + } + + if (filterConfig.options.length === 1) { let option = filterConfig.options[0]; diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index 800218606..61e822060 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -436,7 +436,7 @@ export default class ValidatedTextField { /** * {string (typename) --> TextFieldDef} */ - public static AllTypes = ValidatedTextField.allTypesDict(); + public static AllTypes: Map = ValidatedTextField.allTypesDict(); public static InputForType(type: string, options?: { placeholder?: string | BaseUIElement, @@ -455,7 +455,7 @@ export default class ValidatedTextField { }): InputElement { options = options ?? {}; options.placeholder = options.placeholder ?? type; - const tp: TextFieldDef = ValidatedTextField.AllTypes[type] + const tp: TextFieldDef = ValidatedTextField.AllTypes.get(type) const isValidTp = tp.isValid; let isValid; options.textArea = options.textArea ?? type === "text"; @@ -615,10 +615,11 @@ export default class ValidatedTextField { } - private static allTypesDict() { - const types = {}; + private static allTypesDict(): Map { + const types = new Map(); for (const tp of ValidatedTextField.tpList) { types[tp.name] = tp; + types.set(tp.name, tp); } return types; } diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index 3b1c6dfa0..f893b1070 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -43,8 +43,8 @@ export default class FeatureInfoBox extends ScrollableFullScreen { const title = new TagRenderingAnswer(tags, layerConfig.title ?? new TagRenderingConfig("POI"), State.state) .SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2"); const titleIcons = new Combine( - layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, - "block w-8 h-8 max-h-8 align-baseline box-content sm:p-0.5", "width: 2rem;") + layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, State.state, + "block w-8 h-8 max-h-8 align-baseline box-content sm:p-0.5 w-10",) )) .SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2") diff --git a/Utils.ts b/Utils.ts index 5928fa86f..b3d1656f4 100644 --- a/Utils.ts +++ b/Utils.ts @@ -45,34 +45,31 @@ There are also some technicalities in your theme to keep in mind: The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md). The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console. In the case that MapComplete is pointed to the testing grounds, the edit will be made on https://master.apis.dev.openstreetmap.org` - + private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"] + private static extraKeys = ["nl", "en", "fr", "de", "pt", "es", "name", "phone", "email", "amenity", "leisure", "highway", "building", "yes", "no", "true", "false"] + private static injectedDownloads = {} + private static _download_cache = new Map, timestamp: number }>() /** * Parses the arguments for special visualisations */ public static ParseVisArgs(specs: { name: string, defaultValue?: string }[], args: string[]): any { const parsed = {}; - if(args.length> specs.length){ - throw "To much arguments for special visualization: got "+args.join(",")+" but expected only "+args.length+" arguments" + if (args.length > specs.length) { + throw "To much arguments for special visualization: got " + args.join(",") + " but expected only " + args.length + " arguments" } - for (let i = 0; i < specs.length; i++){ + for (let i = 0; i < specs.length; i++) { const spec = specs[i]; let arg = args[i]?.trim(); - if(arg === undefined || arg === ""){ + if (arg === undefined || arg === "") { arg = spec.defaultValue } - parsed[spec.name] = arg + parsed[spec.name] = arg } return parsed; } - - private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"] - private static extraKeys = ["nl", "en", "fr", "de", "pt", "es", "name", "phone", "email", "amenity", "leisure", "highway", "building", "yes", "no", "true", "false"] - private static injectedDownloads = {} - private static _download_cache = new Map, timestamp: number }>() - static EncodeXmlValue(str) { if (typeof str !== "string") { str = "" + str @@ -198,14 +195,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be const newArr = []; const seen = new Set(); for (const string of arr) { - if(seen.has(string)){ + if (seen.has(string)) { newArr.push(string) } seen.add(string) } return newArr; } - + public static Identical(t1: T[], t2: T[], eq?: (t: T, t0: T) => boolean): boolean { if (t1.length !== t2.length) { return false @@ -238,6 +235,13 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return [a.substr(0, index), a.substr(index + sep.length)]; } + /** + * Given a piece of text, will replace any key occuring in 'tags' by the corresponding value + * @param txt + * @param tags + * @param useLang + * @constructor + */ public static SubstituteKeys(txt: string | undefined, tags: any, useLang?: string): string | undefined { if (txt === undefined) { return undefined @@ -249,7 +253,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be while (match) { const key = match[1] let v = tags[key] - if(v !== undefined ){ + if (v !== undefined) { if (v["toISOString"] != undefined) { // This is a date, probably the timestamp of the object @@ -257,18 +261,18 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be const date: Date = el; v = date.toISOString() } - - if(useLang !== undefined && v?.translations !== undefined){ + + if (useLang !== undefined && v?.translations !== undefined) { v = v.translations[useLang] ?? v.translations["*"] ?? (v.textFor !== undefined ? v.textFor(useLang) : v); } - - if(v.InnerConstructElement !== undefined){ - console.warn("SubstituteKeys received a BaseUIElement to substitute in - this is probably a bug and will be downcast to a string\nThe key is", key,"\nThe value is", v) - v = ( v.InnerConstructElement())?.innerText + + if (v.InnerConstructElement !== undefined) { + console.warn("SubstituteKeys received a BaseUIElement to substitute in - this is probably a bug and will be downcast to a string\nThe key is", key, "\nThe value is", v) + v = (v.InnerConstructElement())?.innerText } - - if(typeof v !== "string"){ - v = ""+v + + if (typeof v !== "string") { + v = "" + v } v = v.replace(/\n/g, "
") } @@ -321,7 +325,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be } const sourceV = source[key]; - if(target === null){ + if (target === null) { return source } const targetV = target[key] @@ -342,6 +346,27 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return target; } + static WalkJson(json: any, f: (v: number | string | boolean | undefined) => any) { + if(json === undefined){ + return f(undefined) + } + const jtp = typeof json + if (jtp === "boolean" || jtp === "string" || jtp === "number"){ + return f(json) + } + if (json.map !== undefined) { + return json.map(sub => { + return Utils.WalkJson(sub, f); + }) + } + + const cp = {...json} + for (const key in json) { + cp[key] = Utils.WalkJson(json[key], f) + } + return cp + } + static getOrSetDefault(dict: Map, k: K, v: () => V) { let found = dict.get(k); if (found !== undefined) { @@ -592,6 +617,18 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return "https://osmcha.org/?filters=" + encodeURIComponent("{" + osmcha_link + "}") } + /** + * Deepclone an object by serializing and deserializing it + * @param x + * @constructor + */ + static Clone(x: T): T { + if (x === undefined) { + return undefined; + } + return JSON.parse(JSON.stringify(x)); + } + private static colorDiff(c0: { r: number, g: number, b: number }, c1: { r: number, g: number, b: number }) { return Math.abs(c0.r - c1.r) + Math.abs(c0.g - c1.g) + Math.abs(c0.b - c1.b); } @@ -618,17 +655,5 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be b: parseInt(hex.substr(5, 2), 16), } } - - /** - * Deepclone an object by serializing and deserializing it - * @param x - * @constructor - */ - static Clone(x: T): T { - if(x === undefined){ - return undefined; - } - return JSON.parse(JSON.stringify(x)); - } } diff --git a/assets/themes/notes/notes.json b/assets/themes/notes/notes.json index beac23cfc..9685a59a6 100644 --- a/assets/themes/notes/notes.json +++ b/assets/themes/notes/notes.json @@ -12,6 +12,7 @@ "description": "Notes from OpenStreetMap", "icon": "./assets/themes/notes/resolved.svg", "clustering": false, + "enableDownload": true, "layers": [ { "id": "notes", @@ -25,25 +26,29 @@ "geoJsonZoomLevel": 12, "maxCacheAge": 0 }, - "minzoom": 10, + "minzoom": 8, "title": { "render": { - "en": "Note" + "en": "Note" }, - "mappings": [{ - "if": "closed_at~*", - "then": { - "en": "Closed note" + "mappings": [ + { + "if": "closed_at~*", + "then": { + "en": "Closed note" + } } - }] + ] }, "calculatedTags": [ - "_first_comment:=feat.get('comments')[0].text", - "_conversation=feat.get('comments').map(c => {if(c.user_url == undefined) {return 'anonymous user, '+c.date;} return c.html+'
'+c.user+'  '+c.date+'
'}).join('')" + "_first_comment:=feat.get('comments')[0].text.toLowerCase()", + "_conversation=feat.get('comments').map(c => { let user = 'anonymous user'; if(c.user_url !== undefined){user = ''+c.user+''}; return c.html +'
' + user + ' '+c.date+'
' }).join('')" + ], + "titleIcons": [ + { + "render": "" + } ], - "titleIcons": [{ - "render": "" - }], "tagRenderings": [ { "id": "conversation", @@ -76,18 +81,27 @@ } ] }, - "iconSize": "40,40,bottom" } ], - "filter": [{ - "id": "bookcases", - "options": [ - { - "osmTags": "_first_comment~.*bookcase.*", - "question": "Should mention 'bookcase' in the first comment" - }] - }] + "filter": [ + { + "id": "search", + "options": [ + { + "osmTags": "_first_comment~.*{search}.*", + "fields": [ + { + "name": "search" + } + ], + "question": { + "en": "Should mention {search} in the first comment" + } + } + ] + } + ] } ] } \ No newline at end of file diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 3895b45e1..954a299ab 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -1044,6 +1044,10 @@ video { max-height: 1rem; } +.max-h-8 { + max-height: 2rem; +} + .w-full { width: 100%; }