forked from MapComplete/MapComplete
		
	First working version of the notes-layer, add filtering
This commit is contained in:
		
							parent
							
								
									ebb510da04
								
							
						
					
					
						commit
						91d2272861
					
				
					 19 changed files with 282 additions and 109 deletions
				
			
		|  | @ -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) | ||||
|         } | ||||
|  |  | |||
|  | @ -59,6 +59,7 @@ export default class FeaturePipeline { | |||
| 
 | ||||
|     private readonly localStorageSavers = new Map<string, SaveTileToLocalStorageActor>() | ||||
|     private readonly metataggingRecalculated = new UIEventSource<void>(undefined) | ||||
|     private readonly requestMetataggingRecalculation = new UIEventSource<Date>(undefined) | ||||
|      | ||||
|     /** | ||||
|      * Keeps track of all raw OSM-nodes. | ||||
|  | @ -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<string, TileHierarchyMerger>() | ||||
|  | @ -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, <any>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,7 +336,11 @@ export default class FeaturePipeline { | |||
|             }, [osmFeatureSource.isRunning] | ||||
|         ) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private onNewDataLoaded(src: FeatureSource){ | ||||
|         this.newDataLoadedSignal.setData(src) | ||||
|         this.requestMetataggingRecalculation.setData(new Date()) | ||||
|     } | ||||
|      | ||||
|     public GetAllFeaturesWithin(bbox: BBox): any[][] { | ||||
|  | @ -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, | ||||
|             { | ||||
|  | @ -495,17 +514,14 @@ 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, <any> this.state) | ||||
|                 self.applyMetaTags(tile, <any> this.state, `${reason} (tile ${tile.tileIndex})`) | ||||
|             }) | ||||
|         }) | ||||
|         if(this.state.currentView !== undefined){ | ||||
|             this.applyMetaTags(this.state.currentView, <any> this.state) | ||||
|         } | ||||
|         self.metataggingRecalculated.ping() | ||||
| 
 | ||||
|     } | ||||
|  |  | |||
|  | @ -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; | ||||
|             } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)) { | ||||
|  |  | |||
|  | @ -44,6 +44,10 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor | |||
|                     return undefined | ||||
|                 } | ||||
|                 const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest()) | ||||
|                 if(tileRange.total > 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) { | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -3,12 +3,18 @@ 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) { | ||||
|  | @ -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" | ||||
|         } | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -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" | ||||
|         }[] | ||||
|     }[]; | ||||
| } | ||||
|  | @ -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}` | ||||
|             } | ||||
|  |  | |||
|  | @ -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++) { | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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<FilteredLayer[]>, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[]) { | ||||
|  | @ -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<string, BaseUIElement>() | ||||
|             let allValid = new UIEventSource(true) | ||||
|             const properties = new UIEventSource<any>({}) | ||||
|             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<any>({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]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -436,7 +436,7 @@ export default class ValidatedTextField { | |||
|     /** | ||||
|      * {string (typename) --> TextFieldDef} | ||||
|      */ | ||||
|     public static AllTypes = ValidatedTextField.allTypesDict(); | ||||
|     public static AllTypes: Map<string, TextFieldDef> = ValidatedTextField.allTypesDict(); | ||||
| 
 | ||||
|     public static InputForType(type: string, options?: { | ||||
|         placeholder?: string | BaseUIElement, | ||||
|  | @ -455,7 +455,7 @@ export default class ValidatedTextField { | |||
|     }): InputElement<string> { | ||||
|         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<string, TextFieldDef> { | ||||
|         const types = new Map<string, TextFieldDef>(); | ||||
|         for (const tp of ValidatedTextField.tpList) { | ||||
|             types[tp.name] = tp; | ||||
|             types.set(tp.name, tp); | ||||
|         } | ||||
|         return types; | ||||
|     } | ||||
|  |  | |||
|  | @ -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") | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										91
									
								
								Utils.ts
									
										
									
									
									
								
							
							
						
						
									
										91
									
								
								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<string, { promise: Promise<any>, 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<string, { promise: Promise<any>, timestamp: number }>() | ||||
| 
 | ||||
|     static EncodeXmlValue(str) { | ||||
|         if (typeof str !== "string") { | ||||
|             str = "" + str | ||||
|  | @ -198,7 +195,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
|         const newArr = []; | ||||
|         const seen = new Set<string>(); | ||||
|         for (const string of arr) { | ||||
|             if(seen.has(string)){ | ||||
|             if (seen.has(string)) { | ||||
|                 newArr.push(string) | ||||
|             } | ||||
|             seen.add(string) | ||||
|  | @ -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
 | ||||
|  | @ -258,17 +262,17 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
|                     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 = ( <HTMLElement> 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 = (<HTMLElement>v.InnerConstructElement())?.innerText | ||||
|                 } | ||||
| 
 | ||||
|                 if(typeof v !== "string"){ | ||||
|                     v = ""+v | ||||
|                 if (typeof v !== "string") { | ||||
|                     v = "" + v | ||||
|                 } | ||||
|                 v = v.replace(/\n/g, "<br/>") | ||||
|             } | ||||
|  | @ -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<K, V>(dict: Map<K, V>, 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<T>(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<T>(x: T): T { | ||||
|         if(x === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         return JSON.parse(JSON.stringify(x)); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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+'<div class=\"subtle flex justify-end border-b border-gray-500\"><a href=\"'+c.user_url+'\" target=\"_blank\">'+c.user+'</a>  '+c.date+'</div>'}).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 = '<a href=\"'+c.user_url+'\" target=\"_blank\">'+c.user+'</a>'}; return c.html +'<div class=\"subtle flex justify-end border-t border-gray-500\">' + user + ' '+c.date+'</div>' }).join('')" | ||||
|       ], | ||||
|       "titleIcons": [ | ||||
|         { | ||||
|           "render": "<a href='https://openstreetmap.org/note/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'></a>" | ||||
|         } | ||||
|       ], | ||||
|       "titleIcons": [{ | ||||
|         "render": "<a href='https://openstreetmap.org/note/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'></a>" | ||||
|       }], | ||||
|       "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" | ||||
|               } | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  | @ -1044,6 +1044,10 @@ video { | |||
|   max-height: 1rem; | ||||
| } | ||||
| 
 | ||||
| .max-h-8 { | ||||
|   max-height: 2rem; | ||||
| } | ||||
| 
 | ||||
| .w-full { | ||||
|   width: 100%; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue