forked from MapComplete/MapComplete
		
	Add theme for 'notes'
This commit is contained in:
		
							parent
							
								
									677a07e3d2
								
							
						
					
					
						commit
						a58ce564c2
					
				
					 20 changed files with 678 additions and 314 deletions
				
			
		|  | @ -4,6 +4,7 @@ import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; | ||||||
| import Hash from "../../Web/Hash"; | import Hash from "../../Web/Hash"; | ||||||
| import {BBox} from "../../BBox"; | import {BBox} from "../../BBox"; | ||||||
| import {ElementStorage} from "../../ElementStorage"; | import {ElementStorage} from "../../ElementStorage"; | ||||||
|  | import {TagsFilter} from "../../Tags/TagsFilter"; | ||||||
| 
 | 
 | ||||||
| export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { | export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { | ||||||
|     public features: UIEventSource<{ feature: any; freshness: Date }[]> = |     public features: UIEventSource<{ feature: any; freshness: Date }[]> = | ||||||
|  | @ -87,10 +88,10 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             const tagsFilter = layer.appliedFilters.data; |             const tagsFilter = Array.from(layer.appliedFilters.data.values()); | ||||||
|             for (const filter of tagsFilter ?? []) { |             for (const filter of tagsFilter ?? []) { | ||||||
|                 const neededTags = filter.filter.options[filter.selected].osmTags |                 const neededTags : TagsFilter = filter?.currentFilter | ||||||
|                 if (!neededTags.matchesProperties(f.feature.properties)) { |                 if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) { | ||||||
|                     // Hidden by the filter on the layer itself - we want to hide it no matter wat
 |                     // Hidden by the filter on the layer itself - we want to hide it no matter wat
 | ||||||
|                     return false; |                     return false; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  | @ -218,13 +218,58 @@ export class OsmConnection { | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public closeNote(id: number | string): Promise<any> { |     public closeNote(id: number | string, text?: string): Promise<any> { | ||||||
|  |         let textSuffix = "" | ||||||
|  |         if((text ?? "") !== "" ){ | ||||||
|  |             textSuffix = "?text="+encodeURIComponent(text) | ||||||
|  |         } | ||||||
|         return new Promise((ok, error) => { |         return new Promise((ok, error) => { | ||||||
|             this.auth.xhr({ |             this.auth.xhr({ | ||||||
|                 method: 'POST', |                 method: 'POST', | ||||||
|                 path: `/api/0.6/notes/${id}/close` |                 path: `/api/0.6/notes/${id}/close${textSuffix}` | ||||||
|  |             }, function (err, response) { | ||||||
|  |                 if (err !== null) { | ||||||
|  |                     error(err) | ||||||
|  |                 } else { | ||||||
|  |                     ok() | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  | 
 | ||||||
|  |         }) | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public reopenNote(id: number | string, text?: string): Promise<any> { | ||||||
|  |         let textSuffix = "" | ||||||
|  |         if((text ?? "") !== "" ){ | ||||||
|  |             textSuffix = "?text="+encodeURIComponent(text) | ||||||
|  |         } | ||||||
|  |         return new Promise((ok, error) => { | ||||||
|  |             this.auth.xhr({ | ||||||
|  |                 method: 'POST', | ||||||
|  |                 path: `/api/0.6/notes/${id}/reopen${textSuffix}` | ||||||
|  |             }, function (err, response) { | ||||||
|  |                 if (err !== null) { | ||||||
|  |                     error(err) | ||||||
|  |                 } else { | ||||||
|  |                     ok() | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  | 
 | ||||||
|  |         }) | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public addCommentToNode(id: number | string, text: string): Promise<any> { | ||||||
|  |         if ((text ?? "") === "") { | ||||||
|  |             throw "Invalid text!" | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return new Promise((ok, error) => { | ||||||
|  |             this.auth.xhr({ | ||||||
|  |                 method: 'POST', | ||||||
|  |                 path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}` | ||||||
|             }, function (err, response) { |             }, function (err, response) { | ||||||
|                 console.log("Closing note gave:", err, response) |  | ||||||
|                 if (err !== null) { |                 if (err !== null) { | ||||||
|                     error(err) |                     error(err) | ||||||
|                 } else { |                 } else { | ||||||
|  |  | ||||||
|  | @ -7,11 +7,10 @@ import Attribution from "../../UI/BigComponents/Attribution"; | ||||||
| import Minimap, {MinimapObj} from "../../UI/Base/Minimap"; | import Minimap, {MinimapObj} from "../../UI/Base/Minimap"; | ||||||
| import {Tiles} from "../../Models/TileRange"; | import {Tiles} from "../../Models/TileRange"; | ||||||
| import BaseUIElement from "../../UI/BaseUIElement"; | import BaseUIElement from "../../UI/BaseUIElement"; | ||||||
| import FilteredLayer from "../../Models/FilteredLayer"; | import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; | ||||||
| import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; | import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; | ||||||
| import {QueryParameters} from "../Web/QueryParameters"; | import {QueryParameters} from "../Web/QueryParameters"; | ||||||
| import * as personal from "../../assets/themes/personal/personal.json"; | import * as personal from "../../assets/themes/personal/personal.json"; | ||||||
| import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; |  | ||||||
| import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"; | import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"; | ||||||
| import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; | import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; | ||||||
| import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; | import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; | ||||||
|  | @ -339,7 +338,6 @@ export default class MapState extends UserRelatedState { | ||||||
|     private InitializeFilteredLayers() { |     private InitializeFilteredLayers() { | ||||||
| 
 | 
 | ||||||
|         const layoutToUse = this.layoutToUse; |         const layoutToUse = this.layoutToUse; | ||||||
|         const empty = [] |  | ||||||
|         const flayers: FilteredLayer[] = []; |         const flayers: FilteredLayer[] = []; | ||||||
|         for (const layer of layoutToUse.layers) { |         for (const layer of layoutToUse.layers) { | ||||||
|             let isDisplayed: UIEventSource<boolean> |             let isDisplayed: UIEventSource<boolean> | ||||||
|  | @ -355,26 +353,18 @@ export default class MapState extends UserRelatedState { | ||||||
|                     "Wether or not layer " + layer.id + " is shown" |                     "Wether or not layer " + layer.id + " is shown" | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             const flayer = { |             const flayer : FilteredLayer = { | ||||||
|                 isDisplayed: isDisplayed, |                 isDisplayed: isDisplayed, | ||||||
|                 layerDef: layer, |                 layerDef: layer, | ||||||
|                 appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]), |                 appliedFilters:   new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>()) | ||||||
|             }; |             }; | ||||||
|  |             layer.filters.forEach(filterConfig => { | ||||||
|  |                 const stateSrc = filterConfig.initState() | ||||||
|                  |                  | ||||||
|             if (layer.filters.length > 0) { |                 stateSrc    .addCallbackAndRun(state => flayer.appliedFilters.data.set(filterConfig.id, state)) | ||||||
|                 const filtersPerName = new Map<string, FilterConfig>() |                 flayer.appliedFilters.map(dict => dict.get(filterConfig.id)) | ||||||
|                 layer.filters.forEach(f => filtersPerName.set(f.id, f)) |                     .addCallback(state => stateSrc.setData(state)) | ||||||
|                 const qp = QueryParameters.GetQueryParameter("filter-" + layer.id, "", "Filtering state for a layer") |             }) | ||||||
|                 flayer.appliedFilters.map(filters => (filters ?? []).map(f => f.filter.id + "." + f.selected).join(","), [], textual => { |  | ||||||
|                     if (textual.length === 0) { |  | ||||||
|                         return empty |  | ||||||
|                     } |  | ||||||
|                     return textual.split(",").map(part => { |  | ||||||
|                         const [filterId, selected] = part.split("."); |  | ||||||
|                         return {filter: filtersPerName.get(filterId), selected: Number(selected)} |  | ||||||
|                     }).filter(f => f.filter !== undefined && !isNaN(f.selected)) |  | ||||||
|                 }).syncWith(qp, true) |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             flayers.push(flayer); |             flayers.push(flayer); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -1,9 +1,13 @@ | ||||||
| import {UIEventSource} from "../Logic/UIEventSource"; | import {UIEventSource} from "../Logic/UIEventSource"; | ||||||
| import LayerConfig from "./ThemeConfig/LayerConfig"; | import LayerConfig from "./ThemeConfig/LayerConfig"; | ||||||
| import FilterConfig from "./ThemeConfig/FilterConfig"; | import {TagsFilter} from "../Logic/Tags/TagsFilter"; | ||||||
|  | 
 | ||||||
|  | export interface FilterState { | ||||||
|  |     currentFilter: TagsFilter, state: string | number | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| export default interface FilteredLayer { | export default interface FilteredLayer { | ||||||
|     readonly isDisplayed: UIEventSource<boolean>; |     readonly isDisplayed: UIEventSource<boolean>; | ||||||
|     readonly appliedFilters: UIEventSource<{ filter: FilterConfig, selected: number }[]>; |     readonly appliedFilters: UIEventSource<Map<string, FilterState>>; | ||||||
|     readonly layerDef: LayerConfig; |     readonly layerDef: LayerConfig; | ||||||
| } | } | ||||||
|  | @ -4,15 +4,17 @@ import FilterConfigJson from "./Json/FilterConfigJson"; | ||||||
| import Translations from "../../UI/i18n/Translations"; | import Translations from "../../UI/i18n/Translations"; | ||||||
| import {TagUtils} from "../../Logic/Tags/TagUtils"; | import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||||
| import ValidatedTextField from "../../UI/Input/ValidatedTextField"; | import ValidatedTextField from "../../UI/Input/ValidatedTextField"; | ||||||
| import {Utils} from "../../Utils"; |  | ||||||
| import {TagRenderingConfigJson} from "./Json/TagRenderingConfigJson"; |  | ||||||
| import {AndOrTagConfigJson} from "./Json/TagConfigJson"; | import {AndOrTagConfigJson} from "./Json/TagConfigJson"; | ||||||
|  | import {UIEventSource} from "../../Logic/UIEventSource"; | ||||||
|  | import {FilterState} from "../FilteredLayer"; | ||||||
|  | import {QueryParameters} from "../../Logic/Web/QueryParameters"; | ||||||
|  | import {Utils} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export default class FilterConfig { | export default class FilterConfig { | ||||||
|     public readonly id: string |     public readonly id: string | ||||||
|     public readonly options: { |     public readonly options: { | ||||||
|         question: Translation; |         question: Translation; | ||||||
|         osmTags: TagsFilter; |         osmTags: TagsFilter | undefined; | ||||||
|         originalTagsSpec: string | AndOrTagConfigJson |         originalTagsSpec: string | AndOrTagConfigJson | ||||||
|         fields: { name: string, type: string }[] |         fields: { name: string, type: string }[] | ||||||
|     }[]; |     }[]; | ||||||
|  | @ -39,11 +41,14 @@ export default class FilterConfig { | ||||||
|                 option.question, |                 option.question, | ||||||
|                 `${ctx}.question` |                 `${ctx}.question` | ||||||
|             ); |             ); | ||||||
|             let osmTags = TagUtils.Tag( |             let osmTags = undefined; | ||||||
|                     option.osmTags ?? {and: []}, |             if (option.osmTags !== undefined) { | ||||||
|  |                 osmTags = TagUtils.Tag( | ||||||
|  |                     option.osmTags, | ||||||
|                     `${ctx}.osmTags` |                     `${ctx}.osmTags` | ||||||
|                 ); |                 ); | ||||||
| 
 | 
 | ||||||
|  |             } | ||||||
|             if (question === undefined) { |             if (question === undefined) { | ||||||
|                 throw `Invalid filter: no question given at ${ctx}` |                 throw `Invalid filter: no question given at ${ctx}` | ||||||
|             } |             } | ||||||
|  | @ -64,7 +69,7 @@ export default class FilterConfig { | ||||||
| 
 | 
 | ||||||
|             if (fields.length > 0) { |             if (fields.length > 0) { | ||||||
|                 // erase the tags, they aren't needed
 |                 // erase the tags, they aren't needed
 | ||||||
|                 osmTags = TagUtils.Tag({and:[]}) |                 osmTags = undefined | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             return {question: question, osmTags: osmTags, fields, originalTagsSpec: option.osmTags}; |             return {question: question, osmTags: osmTags, fields, originalTagsSpec: option.osmTags}; | ||||||
|  | @ -74,9 +79,87 @@ export default class FilterConfig { | ||||||
|             throw `Invalid filter at ${context}: a filter with textfields should only offer a single option.` |             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) { |         if (this.options.length > 1 && this.options[0].osmTags !== undefined) { | ||||||
|             throw "Error in " + context + "." + this.id + ": the first option of a multi-filter should always be the 'reset' option and not have any filters" |             throw "Error in " + context + "." + this.id + ": the first option of a multi-filter should always be the 'reset' option and not have any filters" | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     public initState(): UIEventSource<FilterState> { | ||||||
|  | 
 | ||||||
|  |         function reset(state: FilterState): string { | ||||||
|  |             if (state === undefined) { | ||||||
|  |                 return "" | ||||||
|  |             } | ||||||
|  |             return "" + state.state | ||||||
|  |         } | ||||||
|  |         const defaultValue = this.options.length > 1 ? "0" : "" | ||||||
|  |         const qp = QueryParameters.GetQueryParameter("filter-" + this.id, defaultValue, "State of filter " + this.id) | ||||||
|  | 
 | ||||||
|  |         if (this.options.length > 1) { | ||||||
|  |             // This is a multi-option filter; state should be a number which selects the correct entry
 | ||||||
|  |             const possibleStates: FilterState [] = this.options.map((opt, i) => ({ | ||||||
|  |                 currentFilter: opt.osmTags, | ||||||
|  |                 state: i | ||||||
|  |             })) | ||||||
|  | 
 | ||||||
|  |             // We map the query parameter for this case
 | ||||||
|  |             return qp.map(str => { | ||||||
|  |                 const parsed = Number(str) | ||||||
|  |                 if (isNaN(parsed)) { | ||||||
|  |                     // Nope, not a correct number!
 | ||||||
|  |                     return undefined | ||||||
|  |                 } | ||||||
|  |                 return possibleStates[parsed] | ||||||
|  |             }, [], reset) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         const option = this.options[0] | ||||||
|  | 
 | ||||||
|  |         if (option.fields.length > 0) { | ||||||
|  |             return qp.map(str => { | ||||||
|  |                 // There are variables in play!
 | ||||||
|  |                 // str should encode a json-hash
 | ||||||
|  |                 try { | ||||||
|  |                     const props = JSON.parse(str) | ||||||
|  | 
 | ||||||
|  |                     const origTags = option.originalTagsSpec | ||||||
|  |                     const rewrittenTags = Utils.WalkJson(origTags, | ||||||
|  |                         v => { | ||||||
|  |                             if (typeof v !== "string") { | ||||||
|  |                                 return v | ||||||
|  |                             } | ||||||
|  |                             for (const key in props) { | ||||||
|  |                                 v = (<string>v).replace("{"+key+"}", props[key]) | ||||||
|  |                             } | ||||||
|  |                             return v | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |                     return <FilterState>{ | ||||||
|  |                         currentFilter: TagUtils.Tag(rewrittenTags), | ||||||
|  |                         state: str | ||||||
|  |                     } | ||||||
|  |                 } catch (e) { | ||||||
|  |                     return undefined | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |             }, [], reset) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // The last case is pretty boring: it is checked or it isn't
 | ||||||
|  |         const filterState: FilterState = { | ||||||
|  |             currentFilter: option.osmTags, | ||||||
|  |             state: "true" | ||||||
|  |         } | ||||||
|  |         return qp.map( | ||||||
|  |             str => { | ||||||
|  |                 // Only a single option exists here
 | ||||||
|  |                 if (str === "true") { | ||||||
|  |                     return filterState | ||||||
|  |                 } | ||||||
|  |                 return undefined | ||||||
|  |             }, [], | ||||||
|  |             reset | ||||||
|  |         ) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | @ -29,7 +29,7 @@ abstract class Conversion<TIn, TOut> { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static strict<T>(fixed: { errors: string[], warnings: string[], result?: T }): T { |     public static strict<T>(fixed: { errors: string[], warnings: string[], result?: T }): T { | ||||||
|         if (fixed.errors?.length > 0) { |         if (fixed?.errors?.length > 0) { | ||||||
|             throw fixed.errors.join("\n"); |             throw fixed.errors.join("\n"); | ||||||
|         } |         } | ||||||
|         fixed.warnings?.forEach(w => console.warn(w)) |         fixed.warnings?.forEach(w => console.warn(w)) | ||||||
|  |  | ||||||
|  | @ -10,13 +10,14 @@ import Svg from "../../Svg"; | ||||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | import {UIEventSource} from "../../Logic/UIEventSource"; | ||||||
| import BaseUIElement from "../BaseUIElement"; | import BaseUIElement from "../BaseUIElement"; | ||||||
| import State from "../../State"; | import State from "../../State"; | ||||||
| import FilteredLayer from "../../Models/FilteredLayer"; | import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; | ||||||
| import BackgroundSelector from "./BackgroundSelector"; | import BackgroundSelector from "./BackgroundSelector"; | ||||||
| import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; | import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; | ||||||
| import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; | import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; | ||||||
| import {SubstitutedTranslation} from "../SubstitutedTranslation"; | import {SubstitutedTranslation} from "../SubstitutedTranslation"; | ||||||
| import ValidatedTextField from "../Input/ValidatedTextField"; | import ValidatedTextField from "../Input/ValidatedTextField"; | ||||||
| import {QueryParameters} from "../../Logic/Web/QueryParameters"; | import {QueryParameters} from "../../Logic/Web/QueryParameters"; | ||||||
|  | import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||||
| 
 | 
 | ||||||
| export default class FilterView extends VariableUiElement { | export default class FilterView extends VariableUiElement { | ||||||
|     constructor(filteredLayer: UIEventSource<FilteredLayer[]>, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[]) { |     constructor(filteredLayer: UIEventSource<FilteredLayer[]>, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[]) { | ||||||
|  | @ -143,63 +144,33 @@ export default class FilterView extends VariableUiElement { | ||||||
|             return undefined; |             return undefined; | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         const filterIndexes = new Map<string, number>() |  | ||||||
|         layer.filters.forEach((f, i) => filterIndexes.set(f.id, i)) |  | ||||||
|          |          | ||||||
|         let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map( |         const toShow : BaseUIElement [] = [] | ||||||
|             filter => FilterView.createFilter(filter) |  | ||||||
|         ); |  | ||||||
| 
 | 
 | ||||||
|         listFilterElements.forEach((inputElement, i) => |         for (const filter of layer.filters) { | ||||||
|             inputElement[1].addCallback((changed) => { |  | ||||||
|                 const oldValue = flayer.appliedFilters.data |  | ||||||
|              |              | ||||||
|                 if (changed === undefined) { |             const [ui, actualTags] = FilterView.createFilter(filter) | ||||||
|                     // Lets figure out which filter should be removed
 |  | ||||||
|                     // We know this inputElement corresponds with layer.filters[i]
 |  | ||||||
|                     // SO, if there is a value in 'oldValue' with this filter, we have to recalculated
 |  | ||||||
|                     if (!oldValue.some(f => f.filter === layer.filters[i])) { |  | ||||||
|                         // The filter to remove is already gone, we can stop
 |  | ||||||
|                         return; |  | ||||||
|                     } |  | ||||||
|                 } else if (oldValue.some(f => f.filter === changed.filter && f.selected === changed.selected)) { |  | ||||||
|                     // The changed value is already there
 |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|                 const listTagsFilters = Utils.NoNull( |  | ||||||
|                     listFilterElements.map((input) => input[1].data) |  | ||||||
|                 ); |  | ||||||
|              |              | ||||||
|                 flayer.appliedFilters.setData(listTagsFilters); |             ui.SetClass("mt-3") | ||||||
|  |             toShow.push(ui) | ||||||
|  |             actualTags.addCallback(tagsToFilterFor => { | ||||||
|  |                 flayer.appliedFilters.data.set(filter.id, tagsToFilterFor) | ||||||
|  |                 flayer.appliedFilters.ping() | ||||||
|             }) |             }) | ||||||
|         ); |             flayer.appliedFilters.map(dict => dict.get(filter.id)) | ||||||
|  |                 .addCallbackAndRun(filters => actualTags.setData(filters)) | ||||||
|  |              | ||||||
|              |              | ||||||
|         flayer.appliedFilters.addCallbackAndRun(appliedFilters => { |  | ||||||
|             for (let i = 0; i < layer.filters.length; i++) { |  | ||||||
|                 const filter = layer.filters[i]; |  | ||||||
|                 let foundMatch = undefined |  | ||||||
|                 for (const appliedFilter of appliedFilters) { |  | ||||||
|                     if (appliedFilter.filter === filter) { |  | ||||||
|                         foundMatch = appliedFilter |  | ||||||
|                         break; |  | ||||||
|                     } |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|                 listFilterElements[i][1].setData(foundMatch) |         return new Combine(toShow) | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|         }) |  | ||||||
| 
 |  | ||||||
|         return new Combine(listFilterElements.map(input => input[0].SetClass("mt-3"))) |  | ||||||
|             .SetClass("flex flex-col ml-8 bg-gray-300 rounded-xl p-2") |             .SetClass("flex flex-col ml-8 bg-gray-300 rounded-xl p-2") | ||||||
| 
 | 
 | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     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
 |     // Filter which uses one or more textfields
 | ||||||
|  |     private static createFilterWithFields(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] { | ||||||
|  | 
 | ||||||
|         const filter = filterConfig.options[0] |         const filter = filterConfig.options[0] | ||||||
|         const mappings = new Map<string, BaseUIElement>() |         const mappings = new Map<string, BaseUIElement>() | ||||||
|         let allValid = new UIEventSource(true) |         let allValid = new UIEventSource(true) | ||||||
|  | @ -218,50 +189,36 @@ export default class FilterView extends VariableUiElement { | ||||||
|             allValid = allValid.map(previous => previous && field.IsValid(stable.data) && stable.data !== "", [stable]) |             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 tr = new SubstitutedTranslation(filter.question, new UIEventSource<any>({id: filterConfig.id}), State.state, mappings) | ||||||
|             const neutral = { |         const trigger : UIEventSource<FilterState>= allValid.map(isValid => { | ||||||
|                 filter: new FilterConfig({ |  | ||||||
|                     id: filterConfig.id, |  | ||||||
|                     options: [ |  | ||||||
|                         { |  | ||||||
|                             question: "--", |  | ||||||
|                         } |  | ||||||
|                     ] |  | ||||||
|                 }, "While dynamically constructing a filterconfig"), |  | ||||||
|                 selected: 0 |  | ||||||
|             } |  | ||||||
|             const trigger = allValid.map(isValid => { |  | ||||||
|             if (!isValid) { |             if (!isValid) { | ||||||
|                     return neutral |                 return undefined | ||||||
|             } |             } | ||||||
| 
 |             const props = properties.data | ||||||
|             // Replace all the field occurences in the tags...
 |             // Replace all the field occurences in the tags...
 | ||||||
|                 const osmTags = Utils.WalkJson(filter.originalTagsSpec, |            const tagsSpec = Utils.WalkJson(filter.originalTagsSpec, | ||||||
|                 v => { |                 v => { | ||||||
|                     if (typeof v !== "string") { |                     if (typeof v !== "string") { | ||||||
|                         return v |                         return v | ||||||
|                     } |                     } | ||||||
|                         return Utils.SubstituteKeys(v, properties.data) | 
 | ||||||
|  |                     for (const key in props) { | ||||||
|  |                         v = (<string>v).replace("{"+key+"}", props[key]) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     return v | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|                 // ... which we use below to construct a filter!
 |             const tagsFilter = TagUtils.Tag(tagsSpec) | ||||||
|             return { |             return { | ||||||
|                     filter: new FilterConfig({ |                 currentFilter: tagsFilter, | ||||||
|                         id: filterConfig.id, |                 state: JSON.stringify(props) | ||||||
|                         options: [ |  | ||||||
|                             { |  | ||||||
|                                 question: "--", |  | ||||||
|                                 osmTags |  | ||||||
|                             } |  | ||||||
|                         ] |  | ||||||
|                     }, "While dynamically constructing a filterconfig"), |  | ||||||
|                     selected: 0 |  | ||||||
|             } |             } | ||||||
|         }, [properties]) |         }, [properties]) | ||||||
|  |          | ||||||
|         return [tr, trigger]; |         return [tr, trigger]; | ||||||
|     } |     } | ||||||
|      |      | ||||||
| 
 |     private static createCheckboxFilter(filterConfig: FilterConfig):  [BaseUIElement, UIEventSource<FilterState>] { | ||||||
|         if (filterConfig.options.length === 1) { |  | ||||||
|         let option = filterConfig.options[0]; |         let option = filterConfig.options[0]; | ||||||
| 
 | 
 | ||||||
|         const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6"); |         const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6"); | ||||||
|  | @ -274,19 +231,16 @@ export default class FilterView extends VariableUiElement { | ||||||
|             .ToggleOnClick() |             .ToggleOnClick() | ||||||
|             .SetClass("block m-1") |             .SetClass("block m-1") | ||||||
| 
 | 
 | ||||||
|             const selected = { |         return [toggle, toggle.isEnabled.map(enabled => enabled ? {currentFilter: option.osmTags, state: "true"} : undefined, [], | ||||||
|                 filter: filterConfig, |             f => f !== undefined) | ||||||
|                 selected: 0 |  | ||||||
|             } |  | ||||||
|             return [toggle, toggle.isEnabled.map(enabled => enabled ? selected : undefined, [], |  | ||||||
|                 f => f?.filter === filterConfig && f?.selected === 0) |  | ||||||
|         ] |         ] | ||||||
|     } |     } | ||||||
|  |     private static createMultiFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] { | ||||||
| 
 | 
 | ||||||
|         let options = filterConfig.options; |         let options = filterConfig.options; | ||||||
| 
 | 
 | ||||||
|         const values = options.map((f, i) => ({ |         const values : FilterState[] = options.map((f, i) => ({ | ||||||
|             filter: filterConfig, selected: i |             currentFilter: f.osmTags, state: i | ||||||
|         })) |         })) | ||||||
|         const radio = new RadioButton( |         const radio = new RadioButton( | ||||||
|             options.map( |             options.map( | ||||||
|  | @ -302,8 +256,25 @@ export default class FilterView extends VariableUiElement { | ||||||
|                 i => values[i], |                 i => values[i], | ||||||
|                 [], |                 [], | ||||||
|                 selected => { |                 selected => { | ||||||
|                     return selected?.selected |                     const v = selected?.state | ||||||
|  |                     if(v === undefined || typeof v === "string"){ | ||||||
|  |                         return undefined | ||||||
|  |                     } | ||||||
|  |                     return v | ||||||
|                 } |                 } | ||||||
|             )] |             )] | ||||||
|     } |     } | ||||||
|  |     private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] { | ||||||
|  | 
 | ||||||
|  |         if (filterConfig.options[0].fields.length > 0) { | ||||||
|  |             return FilterView.createFilterWithFields(filterConfig) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         if (filterConfig.options.length === 1) { | ||||||
|  |           return FilterView.createCheckboxFilter(filterConfig) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return FilterView.createMultiFilter(filterConfig) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -111,7 +111,10 @@ export default class SimpleAddUI extends Toggle { | ||||||
|                         message, |                         message, | ||||||
|                         state.LastClickLocation.data, |                         state.LastClickLocation.data, | ||||||
|                         confirm, |                         confirm, | ||||||
|                         cancel) |                         cancel, | ||||||
|  |                         () => { | ||||||
|  |                             isShown.setData(false) | ||||||
|  |                         }) | ||||||
|                 } |                 } | ||||||
|             )) |             )) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,5 @@ | ||||||
| import {UIEventSource} from "../Logic/UIEventSource"; | import {UIEventSource} from "../Logic/UIEventSource"; | ||||||
| import {QueryParameters} from "../Logic/Web/QueryParameters"; | import {QueryParameters} from "../Logic/Web/QueryParameters"; | ||||||
| import Constants from "../Models/Constants"; |  | ||||||
| import Hash from "../Logic/Web/Hash"; | import Hash from "../Logic/Web/Hash"; | ||||||
| 
 | 
 | ||||||
| export class DefaultGuiState { | export class DefaultGuiState { | ||||||
|  | @ -46,18 +45,19 @@ export class DefaultGuiState { | ||||||
|             "false", |             "false", | ||||||
|             "Whether or not the current view box is shown" |             "Whether or not the current view box is shown" | ||||||
|         ) |         ) | ||||||
|         if (Hash.hash.data === "download") { |         const states = { | ||||||
|             this.downloadControlIsOpened.setData(true) |             download: this.downloadControlIsOpened, | ||||||
|  |             filters: this.filterViewIsOpened, | ||||||
|  |             copyright: this.copyrightViewIsOpened, | ||||||
|  |             currentview: this.currentViewControlIsOpened, | ||||||
|  |             welcome: this.welcomeMessageIsOpened | ||||||
|         } |         } | ||||||
|         if (Hash.hash.data === "filters") { |         Hash.hash.addCallbackAndRunD(hash => { | ||||||
|             this.filterViewIsOpened.setData(true) |             hash = hash.toLowerCase() | ||||||
|         } |             states[hash]?.setData(true) | ||||||
|         if (Hash.hash.data === "copyright") { |         }) | ||||||
|             this.copyrightViewIsOpened.setData(true) |         | ||||||
|         }if (Hash.hash.data === "currentview") { |         if (Hash.hash.data === "" || Hash.hash.data === undefined) { | ||||||
|             this.currentViewControlIsOpened.setData(true) |  | ||||||
|         } |  | ||||||
|         if (Hash.hash.data === "" || Hash.hash.data === undefined || Hash.hash.data === "welcome") { |  | ||||||
|             this.welcomeMessageIsOpened.setData(true) |             this.welcomeMessageIsOpened.setData(true) | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -29,6 +29,7 @@ export default class ConfirmLocationOfPoint extends Combine { | ||||||
|         loc: { lon: number, lat: number }, |         loc: { lon: number, lat: number }, | ||||||
|         confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, |         confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, | ||||||
|         cancel: () => void, |         cancel: () => void, | ||||||
|  |         closePopup: () => void | ||||||
|     ) { |     ) { | ||||||
| 
 | 
 | ||||||
|         let preciseInput: LocationInput = undefined |         let preciseInput: LocationInput = undefined | ||||||
|  | @ -137,33 +138,26 @@ export default class ConfirmLocationOfPoint extends Combine { | ||||||
|                 ] |                 ] | ||||||
|             ).SetClass("flex flex-col") |             ).SetClass("flex flex-col") | ||||||
|         ).onClick(() => { |         ).onClick(() => { | ||||||
|             preset.layerToAddTo.appliedFilters.setData([]) |              | ||||||
|  |             const appliedFilters = preset.layerToAddTo.appliedFilters; | ||||||
|  |             appliedFilters.data.forEach((_, k) => appliedFilters.data.set(k, undefined)) | ||||||
|  |             appliedFilters.ping() | ||||||
|             cancel() |             cancel() | ||||||
|  |             closePopup() | ||||||
|         }) |         }) | ||||||
| 
 | 
 | ||||||
|  |         const hasActiveFilter = preset.layerToAddTo.appliedFilters | ||||||
|  |             .map(appliedFilters => { | ||||||
|  |                 const activeFilters = Array.from(appliedFilters.values()).filter(f => f?.currentFilter !== undefined); | ||||||
|  |                     return activeFilters.length === 0; | ||||||
|  |             }) | ||||||
|  |          | ||||||
|  |         // If at least one filter is active which _might_ hide a newly added item, this blocks the preset and requests the filter to be disabled
 | ||||||
|         const disableFiltersOrConfirm = new Toggle( |         const disableFiltersOrConfirm = new Toggle( | ||||||
|             openLayerOrConfirm, |             openLayerOrConfirm, | ||||||
|             disableFilter,  |             disableFilter,  | ||||||
|             preset.layerToAddTo.appliedFilters.map(filters => { |             hasActiveFilter) | ||||||
|                 if (filters === undefined || filters.length === 0) { |  | ||||||
|                     return true; |  | ||||||
|                 } |  | ||||||
|                 for (const filter of filters) { |  | ||||||
|                     if (filter.selected === 0 && filter.filter.options.length === 1) { |  | ||||||
|                         return false; |  | ||||||
|                     } |  | ||||||
|                     if (filter.selected !== undefined) { |  | ||||||
|                         const tags = filter.filter.options[filter.selected].osmTags |  | ||||||
|                         if (tags !== undefined && tags["and"]?.length !== 0) { |  | ||||||
|                             // This actually doesn't filter anything at all
 |  | ||||||
|                             return false; |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 return true |  | ||||||
|          |          | ||||||
|             }) |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|         const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection); |         const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection); | ||||||
|  |  | ||||||
|  | @ -520,7 +520,8 @@ export class ImportPointButton extends AbstractImportButton { | ||||||
|         guiState: DefaultGuiState, |         guiState: DefaultGuiState, | ||||||
|         originalFeatureTags: UIEventSource<any>, |         originalFeatureTags: UIEventSource<any>, | ||||||
|         feature: any, |         feature: any, | ||||||
|         onCancel: () => void): BaseUIElement { |         onCancel: () => void, | ||||||
|  |         close: () => void): BaseUIElement { | ||||||
| 
 | 
 | ||||||
|         async function confirm(tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) { |         async function confirm(tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) { | ||||||
| 
 | 
 | ||||||
|  | @ -559,7 +560,7 @@ export class ImportPointButton extends AbstractImportButton { | ||||||
|         return new ConfirmLocationOfPoint(state, guiState.filterViewIsOpened, presetInfo, Translations.W(args.text), { |         return new ConfirmLocationOfPoint(state, guiState.filterViewIsOpened, presetInfo, Translations.W(args.text), { | ||||||
|             lon, |             lon, | ||||||
|             lat |             lat | ||||||
|         }, confirm, onCancel) |         }, confirm, onCancel, close) | ||||||
| 
 | 
 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -567,7 +568,7 @@ export class ImportPointButton extends AbstractImportButton { | ||||||
|                      originalFeatureTags, |                      originalFeatureTags, | ||||||
|                      guiState, |                      guiState, | ||||||
|                      feature, |                      feature, | ||||||
|                      onCancel): BaseUIElement { |                      onCancel: () => void): BaseUIElement { | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|         const geometry = feature.geometry |         const geometry = feature.geometry | ||||||
|  | @ -579,7 +580,11 @@ export class ImportPointButton extends AbstractImportButton { | ||||||
|                 guiState, |                 guiState, | ||||||
|                 originalFeatureTags, |                 originalFeatureTags, | ||||||
|                 feature, |                 feature, | ||||||
|                 onCancel |                 onCancel, | ||||||
|  |                 () => { | ||||||
|  |                     // Close the current popup
 | ||||||
|  |                     state.selectedElement.setData(undefined) | ||||||
|  |                 } | ||||||
|             )) |             )) | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -39,6 +39,9 @@ import AutoApplyButton from "./Popup/AutoApplyButton"; | ||||||
| import * as left_right_style_json from "../assets/layers/left_right_style/left_right_style.json"; | import * as left_right_style_json from "../assets/layers/left_right_style/left_right_style.json"; | ||||||
| import {OpenIdEditor} from "./BigComponents/CopyrightPanel"; | import {OpenIdEditor} from "./BigComponents/CopyrightPanel"; | ||||||
| import Toggle from "./Input/Toggle"; | import Toggle from "./Input/Toggle"; | ||||||
|  | import Img from "./Base/Img"; | ||||||
|  | import ValidatedTextField from "./Input/ValidatedTextField"; | ||||||
|  | import Link from "./Base/Link"; | ||||||
| 
 | 
 | ||||||
| export interface SpecialVisualization { | export interface SpecialVisualization { | ||||||
|     funcName: string, |     funcName: string, | ||||||
|  | @ -53,6 +56,39 @@ export default class SpecialVisualizations { | ||||||
| 
 | 
 | ||||||
|     public static specialVisualizations = SpecialVisualizations.init() |     public static specialVisualizations = SpecialVisualizations.init() | ||||||
| 
 | 
 | ||||||
|  |     public static HelpMessage() { | ||||||
|  | 
 | ||||||
|  |         const helpTexts = | ||||||
|  |             SpecialVisualizations.specialVisualizations.map(viz => new Combine( | ||||||
|  |                 [ | ||||||
|  |                     new Title(viz.funcName, 3), | ||||||
|  |                     viz.docs, | ||||||
|  |                     viz.args.length > 0 ? new Table(["name", "default", "description"], | ||||||
|  |                         viz.args.map(arg => { | ||||||
|  |                             let defaultArg = arg.defaultValue ?? "_undefined_" | ||||||
|  |                             if (defaultArg == "") { | ||||||
|  |                                 defaultArg = "_empty string_" | ||||||
|  |                             } | ||||||
|  |                             return [arg.name, defaultArg, arg.doc]; | ||||||
|  |                         }) | ||||||
|  |                     ) : undefined, | ||||||
|  |                     new Title("Example usage of " + viz.funcName, 4), | ||||||
|  |                     new FixedUiElement( | ||||||
|  |                         viz.example ?? "`{" + viz.funcName + "(" + viz.args.map(arg => arg.defaultValue).join(",") + ")}`" | ||||||
|  |                     ).SetClass("literal-code"), | ||||||
|  | 
 | ||||||
|  |                 ] | ||||||
|  |             )); | ||||||
|  | 
 | ||||||
|  |         return new Combine([ | ||||||
|  |                 new Title("Special tag renderings", 1), | ||||||
|  |                 "In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.", | ||||||
|  |                 "General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_ need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args", | ||||||
|  |                 ...helpTexts | ||||||
|  |             ] | ||||||
|  |         ).SetClass("flex flex-col"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     private static init() { |     private static init() { | ||||||
|         const specialVisualizations: SpecialVisualization[] = |         const specialVisualizations: SpecialVisualization[] = | ||||||
|             [ |             [ | ||||||
|  | @ -611,12 +647,63 @@ export default class SpecialVisualizations { | ||||||
|                 }, |                 }, | ||||||
|                 { |                 { | ||||||
|                     funcName: "close_note", |                     funcName: "close_note", | ||||||
|                     docs: "Button to close a note", |                     docs: "Button to close a note - eventually with a prefixed text", | ||||||
|                     args: [ |                     args: [ | ||||||
|                         { |                         { | ||||||
|                             name: "text", |                             name: "text", | ||||||
|                             doc: "Text to show on this button", |                             doc: "Text to show on this button", | ||||||
|                         }, |                         }, | ||||||
|  |                         { | ||||||
|  |                             name: "icon", | ||||||
|  |                             doc: "Icon to show", | ||||||
|  |                             defaultValue: "checkmark.svg" | ||||||
|  |                         }, | ||||||
|  |                         { | ||||||
|  |                             name: "Id-key", | ||||||
|  |                             doc: "The property name where the ID of the note to close can be found", | ||||||
|  |                             defaultValue: "id" | ||||||
|  |                         }, | ||||||
|  |                         { | ||||||
|  |                             name: "comment", | ||||||
|  |                             doc: "Text to add onto the note when closing", | ||||||
|  |                         } | ||||||
|  |                     ], | ||||||
|  |                     constr: (state, tags, args, guiState) => { | ||||||
|  |                         const t = Translations.t.notes; | ||||||
|  | 
 | ||||||
|  |                         let icon = Svg.checkmark_svg() | ||||||
|  |                         if (args[2] !== "checkmark.svg" && (args[2] ?? "") !== "") { | ||||||
|  |                             icon = new Img(args[2]) | ||||||
|  |                         } | ||||||
|  |                         let textToShow = t.closeNote; | ||||||
|  |                         if ((args[0] ?? "") !== "") { | ||||||
|  |                             textToShow = Translations.T(args[0]) | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         const closeButton = new SubtleButton(icon, textToShow) | ||||||
|  |                         const isClosed = tags.map(tags => (tags["closed_at"] ?? "") === ""); | ||||||
|  |                         closeButton.onClick(() => { | ||||||
|  |                             const id = tags.data[args[1] ?? "id"] | ||||||
|  |                             if (state.featureSwitchIsTesting.data) { | ||||||
|  |                                 console.log("Not actually closing note...") | ||||||
|  |                                 return; | ||||||
|  |                             } | ||||||
|  |                             state.osmConnection.closeNote(id, args[3]).then(_ => { | ||||||
|  |                                 tags.data["closed_at"] = new Date().toISOString(); | ||||||
|  |                                 tags.ping() | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  |                         return new Toggle( | ||||||
|  |                             t.isClosed.SetClass("thanks"), | ||||||
|  |                             closeButton, | ||||||
|  |                             isClosed | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                     funcName: "add_note_comment", | ||||||
|  |                     docs: "A textfield to add a comment to a node (with the option to close the note).", | ||||||
|  |                     args: [ | ||||||
|                         { |                         { | ||||||
|                             name: "Id-key", |                             name: "Id-key", | ||||||
|                             doc: "The property name where the ID of the note to close can be found", |                             doc: "The property name where the ID of the note to close can be found", | ||||||
|  | @ -624,21 +711,137 @@ export default class SpecialVisualizations { | ||||||
|                         } |                         } | ||||||
|                     ], |                     ], | ||||||
|                     constr: (state, tags, args, guiState) => { |                     constr: (state, tags, args, guiState) => { | ||||||
|  | 
 | ||||||
|                         const t = Translations.t.notes; |                         const t = Translations.t.notes; | ||||||
|                             const closeButton = new SubtleButton( Svg.checkmark_svg(), t.closeNote) |                         const textField = ValidatedTextField.InputForType("text", {placeholder: t.addCommentPlaceholder}) | ||||||
|                         const isClosed = new UIEventSource(false); |                         textField.SetClass("rounded-l border border-grey") | ||||||
|                         closeButton.onClick(() => { |                         const txt = textField.GetValue() | ||||||
|  | 
 | ||||||
|  |                         const addCommentButton = new SubtleButton(undefined, t.addCommentPlaceholder) | ||||||
|  |                             .onClick(async () => { | ||||||
|  |                                 const id = tags.data[args[1] ?? "id"] | ||||||
|  | 
 | ||||||
|  |                                 if (isClosed.data) { | ||||||
|  |                                     await state.osmConnection.reopenNote(id, txt.data) | ||||||
|  |                                     await state.osmConnection.closeNote(id) | ||||||
|  |                                 } else { | ||||||
|  |                                     await state.osmConnection.addCommentToNode(id, txt.data) | ||||||
|  |                                 } | ||||||
|  |                                 const comments: any[] = JSON.parse(tags.data["comments"]) | ||||||
|  |                                 const username = state.osmConnection.userDetails.data.name | ||||||
|  |                                 comments.push({ | ||||||
|  |                                     "date": new Date().toISOString(), | ||||||
|  |                                     "uid": state.osmConnection.userDetails.data.uid, | ||||||
|  |                                     "user": username, | ||||||
|  |                                     "user_url": "https://www.openstreetmap.org/user/" + username, | ||||||
|  |                                     "action": "commented", | ||||||
|  |                                     "text": txt.data | ||||||
|  |                                 }) | ||||||
|  |                                 tags.data["comments"] = JSON.stringify(comments) | ||||||
|  |                                 tags.ping() | ||||||
|  |                                 txt.setData("") | ||||||
|  | 
 | ||||||
|  |                             }) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                         const close = new SubtleButton(undefined, new VariableUiElement(txt.map(txt => { | ||||||
|  |                             if (txt === undefined || txt === "") { | ||||||
|  |                                 return t.closeNote | ||||||
|  |                             } | ||||||
|  |                             return t.addCommentAndClose | ||||||
|  |                         }))).onClick(() => { | ||||||
|                             const id = tags.data[args[1] ?? "id"] |                             const id = tags.data[args[1] ?? "id"] | ||||||
|                             if (state.featureSwitchIsTesting.data) { |                             if (state.featureSwitchIsTesting.data) { | ||||||
|                                 console.log("Not actually closing note...") |                                 console.log("Testmode: Not actually closing note...") | ||||||
|                                 return; |                                 return; | ||||||
|                             } |                             } | ||||||
|                            state.osmConnection.closeNote(id).then(_ => isClosed.setData(true)) |                             state.osmConnection.closeNote(id, txt.data).then(_ => { | ||||||
|  |                                 tags.data["closed_at"] = new Date().toISOString(); | ||||||
|  |                                 tags.ping() | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  | 
 | ||||||
|  |                         const reopen = new SubtleButton(undefined, new VariableUiElement(txt.map(txt => { | ||||||
|  |                             if (txt === undefined || txt === "") { | ||||||
|  |                                 return t.reopenNote | ||||||
|  |                             } | ||||||
|  |                             return t.reopenNoteAndComment | ||||||
|  |                         }))).onClick(() => { | ||||||
|  |                             const id = tags.data[args[1] ?? "id"] | ||||||
|  |                             if (state.featureSwitchIsTesting.data) { | ||||||
|  |                                 console.log("Testmode: Not actually reopening note...") | ||||||
|  |                                 return; | ||||||
|  |                             } | ||||||
|  |                             state.osmConnection.reopenNote(id, txt.data).then(_ => { | ||||||
|  |                                 tags.data["closed_at"] = undefined; | ||||||
|  |                                 tags.ping() | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  | 
 | ||||||
|  |                         const isClosed = tags.map(tags => (tags["closed_at"] ?? "") !== ""); | ||||||
|  |                         const stateButtons = new Toggle(reopen, close, isClosed) | ||||||
|  | 
 | ||||||
|  |                         return new Combine([ | ||||||
|  |                             new Title("Add a comment"), | ||||||
|  |                             textField, | ||||||
|  |                             new Combine([addCommentButton.SetClass("mr-2"), stateButtons]).SetClass("flex justify-end") | ||||||
|  |                         ]).SetClass("border-2 border-black rounded-xl p-4 block"); | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                     funcName: "visualize_note_comments", | ||||||
|  |                     docs: "Visualises the comments for nodes", | ||||||
|  |                     args: [ | ||||||
|  |                         { | ||||||
|  |                             name: "commentsKey", | ||||||
|  |                             doc: "The property name of the comments, which should be stringified json", | ||||||
|  |                             defaultValue: "comments" | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                     , constr: (state, tags, args) => { | ||||||
|  |                         const t = Translations.t.notes; | ||||||
|  |                         return new VariableUiElement( | ||||||
|  |                             tags.map(tags => tags[args[0]]) | ||||||
|  |                                 .map(commentsStr => { | ||||||
|  |                                     const comments: | ||||||
|  |                                         { | ||||||
|  |                                             "date": string, | ||||||
|  |                                             "uid": number, | ||||||
|  |                                             "user": string, | ||||||
|  |                                             "user_url": string, | ||||||
|  |                                             "action": "closed" | "opened" | "reopened" | "commented", | ||||||
|  |                                             "text": string, "html": string | ||||||
|  |                                         }[] = JSON.parse(commentsStr) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                                     return new Combine(comments | ||||||
|  |                                         .filter(c => c.text !== "") | ||||||
|  |                                         .map(c => { | ||||||
|  |                                             let actionIcon: BaseUIElement = undefined; | ||||||
|  |                                             if (c.action === "opened" || c.action === "reopened") { | ||||||
|  |                                                 actionIcon = Svg.note_svg() | ||||||
|  |                                             } else if (c.action === "closed") { | ||||||
|  |                                                 actionIcon = Svg.resolved_svg() | ||||||
|  |                                             } else { | ||||||
|  |                                                 actionIcon = Svg.addSmall_svg() | ||||||
|  |                                             } | ||||||
|  | 
 | ||||||
|  |                                             let user: BaseUIElement | ||||||
|  |                                             if (c.user === undefined) { | ||||||
|  |                                                 user = t.anonymous | ||||||
|  |                                             } else { | ||||||
|  |                                                 user = new Link(c.user, c.user_url ?? "", true) | ||||||
|  |                                             } | ||||||
|  | 
 | ||||||
|  |                                             return new Combine([new Combine([ | ||||||
|  |                                                 actionIcon.SetClass("mr-4 w-6").SetStyle("flex-shrink: 0"), | ||||||
|  |                                                 new FixedUiElement(c.html).SetClass("flex flex-col").SetStyle("margin: 0"), | ||||||
|  |                                             ]).SetClass("flex"), | ||||||
|  |                                                 new Combine([user.SetClass("mr-2"), c.date]).SetClass("flex justify-end subtle") | ||||||
|  |                                             ]).SetClass("flex flex-col") | ||||||
|  | 
 | ||||||
|  |                                         })).SetClass("flex flex-col") | ||||||
|                                 }) |                                 }) | ||||||
|                         return new Toggle( |  | ||||||
|                             t.isClosed.SetClass("thanks"), |  | ||||||
|                             closeButton, |  | ||||||
|                             isClosed |  | ||||||
|                         ) |                         ) | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  | @ -649,38 +852,4 @@ export default class SpecialVisualizations { | ||||||
|         return specialVisualizations; |         return specialVisualizations; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|   |  | ||||||
|     public static HelpMessage() { |  | ||||||
| 
 |  | ||||||
|         const helpTexts = |  | ||||||
|             SpecialVisualizations.specialVisualizations.map(viz => new Combine( |  | ||||||
|                 [ |  | ||||||
|                     new Title(viz.funcName, 3), |  | ||||||
|                     viz.docs, |  | ||||||
|                     viz.args.length > 0 ? new Table(["name", "default", "description"], |  | ||||||
|                         viz.args.map(arg => { |  | ||||||
|                             let defaultArg = arg.defaultValue ?? "_undefined_" |  | ||||||
|                             if (defaultArg == "") { |  | ||||||
|                                 defaultArg = "_empty string_" |  | ||||||
|                             } |  | ||||||
|                             return [arg.name, defaultArg, arg.doc]; |  | ||||||
|                         }) |  | ||||||
|                     ) : undefined, |  | ||||||
|                     new Title("Example usage of "+viz.funcName, 4), |  | ||||||
|                     new FixedUiElement( |  | ||||||
|                         viz.example ?? "`{" + viz.funcName + "(" + viz.args.map(arg => arg.defaultValue).join(",") + ")}`" |  | ||||||
|                     ).SetClass("literal-code"), |  | ||||||
| 
 |  | ||||||
|                 ] |  | ||||||
|             )); |  | ||||||
| 
 |  | ||||||
|         return new Combine([ |  | ||||||
|                 new Title("Special tag renderings", 1), |  | ||||||
|                 "In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.", |  | ||||||
|                 "General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_ need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args", |  | ||||||
|                 ...helpTexts |  | ||||||
|             ] |  | ||||||
|         ).SetClass("flex flex-col"); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | @ -823,6 +823,14 @@ | ||||||
|     "authors": [], |     "authors": [], | ||||||
|     "sources": [] |     "sources": [] | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     "path": "note.svg", | ||||||
|  |     "license": "CC0", | ||||||
|  |     "authors": [ | ||||||
|  |       "Pieter Vander Vennet" | ||||||
|  |     ], | ||||||
|  |     "sources": [] | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     "path": "osm-logo-us.svg", |     "path": "osm-logo-us.svg", | ||||||
|     "license": "Logo", |     "license": "Logo", | ||||||
|  | @ -965,6 +973,14 @@ | ||||||
|     ], |     ], | ||||||
|     "sources": [] |     "sources": [] | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     "path": "resolved.svg", | ||||||
|  |     "license": "CC0", | ||||||
|  |     "authors": [ | ||||||
|  |       "Pieter Vander Vennet" | ||||||
|  |     ], | ||||||
|  |     "sources": [] | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     "path": "ring.svg", |     "path": "ring.svg", | ||||||
|     "license": "CC0; trivial", |     "license": "CC0; trivial", | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB | 
| Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB | 
|  | @ -1,18 +0,0 @@ | ||||||
| [ |  | ||||||
|   { |  | ||||||
|     "path": "note.svg", |  | ||||||
|     "license": "CC0", |  | ||||||
|     "authors": [ |  | ||||||
|       "Pieter Vander Vennet" |  | ||||||
|     ], |  | ||||||
|     "sources": [] |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     "path": "resolved.svg", |  | ||||||
|     "license": "CC0", |  | ||||||
|     "authors": [ |  | ||||||
|       "Pieter Vander Vennet" |  | ||||||
|     ], |  | ||||||
|     "sources": [] |  | ||||||
|   } |  | ||||||
| ] |  | ||||||
|  | @ -9,8 +9,8 @@ | ||||||
|   "startZoom": 0, |   "startZoom": 0, | ||||||
|   "title": "Notes on OpenStreetMap", |   "title": "Notes on OpenStreetMap", | ||||||
|   "version": "0.1", |   "version": "0.1", | ||||||
|   "description": "Notes from OpenStreetMap", |   "description": "A note is a pin on the map with some text to indicate something wrong.<br/><br/>Make sure to checkout the <a href='#filters'>filter view</a> to search for users and text.", | ||||||
|   "icon": "./assets/themes/notes/resolved.svg", |   "icon": "./assets/svg/resolved.svg", | ||||||
|   "clustering": false, |   "clustering": false, | ||||||
|   "enableDownload": true, |   "enableDownload": true, | ||||||
|   "layers": [ |   "layers": [ | ||||||
|  | @ -19,7 +19,7 @@ | ||||||
|       "name": { |       "name": { | ||||||
|         "en": "OpenStreetMap notes" |         "en": "OpenStreetMap notes" | ||||||
|       }, |       }, | ||||||
|       "description": "Notes on OpenStreetMap.org", |       "description": "This layer shows notes on OpenStreetMap.", | ||||||
|       "source": { |       "source": { | ||||||
|         "osmTags": "id~*", |         "osmTags": "id~*", | ||||||
|         "geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?closed=7&bbox={x_min},{y_min},{x_max},{y_max}", |         "geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?closed=7&bbox={x_min},{y_min},{x_max},{y_max}", | ||||||
|  | @ -42,7 +42,10 @@ | ||||||
|       }, |       }, | ||||||
|       "calculatedTags": [ |       "calculatedTags": [ | ||||||
|         "_first_comment:=feat.get('comments')[0].text.toLowerCase()", |         "_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('')" |         "_opened_by_anonymous_user:=feat.get('comments')[0].user === undefined", | ||||||
|  |         "_first_user:=feat.get('comments')[0].user", | ||||||
|  |         "_first_user_lc:=feat.get('comments')[0].user?.toLowerCase()", | ||||||
|  |         "_first_user_id:=feat.get('comments')[0].uid" | ||||||
|       ], |       ], | ||||||
|       "titleIcons": [ |       "titleIcons": [ | ||||||
|         { |         { | ||||||
|  | @ -52,18 +55,18 @@ | ||||||
|       "tagRenderings": [ |       "tagRenderings": [ | ||||||
|         { |         { | ||||||
|           "id": "conversation", |           "id": "conversation", | ||||||
|           "render": "{_conversation}" |           "render": "{visualize_note_comments()}" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           "id": "date_created", |           "id": "comment", | ||||||
|  |           "render": "{add_note_comment()}" | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           "id": "Spam", | ||||||
|           "render": { |           "render": { | ||||||
|             "en": "Opened on {date_created}" |             "en": "<a href='https://www.openstreetmap.org/reports/new?reportable_id={_first_user_id}&reportable_type=User' target='_blank' class='subtle'>Report {_first_user} as spam</a>" | ||||||
|           } |  | ||||||
|           }, |           }, | ||||||
|         { |           "condition": "_opened_by_anonymous_user=false" | ||||||
|           "id": "close", |  | ||||||
|           "render": "{close_note()}", |  | ||||||
|           "condition": "closed_at=" |  | ||||||
|         } |         } | ||||||
|       ], |       ], | ||||||
|       "mapRendering": [ |       "mapRendering": [ | ||||||
|  | @ -73,11 +76,11 @@ | ||||||
|             "centroid" |             "centroid" | ||||||
|           ], |           ], | ||||||
|           "icon": { |           "icon": { | ||||||
|             "render": "./assets/themes/notes/note.svg", |             "render": "./assets/svg/note.svg", | ||||||
|             "mappings": [ |             "mappings": [ | ||||||
|               { |               { | ||||||
|                 "if": "closed_at~*", |                 "if": "closed_at~*", | ||||||
|                 "then": "./assets/themes/notes/resolved.svg" |                 "then": "./assets/svg/resolved.svg" | ||||||
|               } |               } | ||||||
|             ] |             ] | ||||||
|           }, |           }, | ||||||
|  | @ -116,6 +119,49 @@ | ||||||
|               } |               } | ||||||
|             } |             } | ||||||
|           ] |           ] | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           "id": "opened_by", | ||||||
|  |           "options": [ | ||||||
|  |             { | ||||||
|  |               "osmTags": "_first_user_lc~.*{search}.*", | ||||||
|  |               "fields": [ | ||||||
|  |                 { | ||||||
|  |                   "name": "search" | ||||||
|  |                 } | ||||||
|  |               ], | ||||||
|  |               "question": { | ||||||
|  |                 "en": "Opened by {search}" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           "id": "not_opened_by", | ||||||
|  |           "options": [ | ||||||
|  |             { | ||||||
|  |               "osmTags": "_first_user_lc!~.*{search}.*", | ||||||
|  |               "fields": [ | ||||||
|  |                 { | ||||||
|  |                   "name": "search" | ||||||
|  |                 } | ||||||
|  |               ], | ||||||
|  |               "question": { | ||||||
|  |                 "en": "<b>Not</b> opened by {search}" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           "id": "anonymous", | ||||||
|  |           "options": [ | ||||||
|  |             { | ||||||
|  |               "osmTags": "_opened_by_anonymous_user=true", | ||||||
|  |               "question": { | ||||||
|  |                 "en": "Opened by anonymous user" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           ] | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -864,14 +864,18 @@ video { | ||||||
|   margin-top: 1rem; |   margin-top: 1rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .mt-1 { | .mr-2 { | ||||||
|   margin-top: 0.25rem; |   margin-right: 0.5rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .mr-4 { | .mr-4 { | ||||||
|   margin-right: 1rem; |   margin-right: 1rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .mt-1 { | ||||||
|  |   margin-top: 0.25rem; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .mt-2 { | .mt-2 { | ||||||
|   margin-top: 0.5rem; |   margin-top: 0.5rem; | ||||||
| } | } | ||||||
|  | @ -888,10 +892,6 @@ video { | ||||||
|   margin-left: 2rem; |   margin-left: 2rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .mr-2 { |  | ||||||
|   margin-right: 0.5rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .mb-10 { | .mb-10 { | ||||||
|   margin-bottom: 2.5rem; |   margin-bottom: 2.5rem; | ||||||
| } | } | ||||||
|  | @ -1068,6 +1068,10 @@ video { | ||||||
|   width: 2rem; |   width: 2rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .w-6 { | ||||||
|  |   width: 1.5rem; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .w-0 { | .w-0 { | ||||||
|   width: 0px; |   width: 0px; | ||||||
| } | } | ||||||
|  | @ -1080,10 +1084,6 @@ video { | ||||||
|   width: 2.75rem; |   width: 2.75rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .w-6 { |  | ||||||
|   width: 1.5rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .w-16 { | .w-16 { | ||||||
|   width: 4rem; |   width: 4rem; | ||||||
| } | } | ||||||
|  | @ -1283,26 +1283,31 @@ video { | ||||||
|   border-radius: 0.25rem; |   border-radius: 0.25rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .rounded-xl { | ||||||
|  |   border-radius: 0.75rem; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .rounded-lg { | .rounded-lg { | ||||||
|   border-radius: 0.5rem; |   border-radius: 0.5rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .rounded-xl { | .rounded-l { | ||||||
|   border-radius: 0.75rem; |   border-top-left-radius: 0.25rem; | ||||||
|  |   border-bottom-left-radius: 0.25rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .border { | .border { | ||||||
|   border-width: 1px; |   border-width: 1px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .border-4 { |  | ||||||
|   border-width: 4px; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .border-2 { | .border-2 { | ||||||
|   border-width: 2px; |   border-width: 2px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .border-4 { | ||||||
|  |   border-width: 4px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .border-l-4 { | .border-l-4 { | ||||||
|   border-left-width: 4px; |   border-left-width: 4px; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -425,7 +425,12 @@ | ||||||
|   }, |   }, | ||||||
|   "notes": { |   "notes": { | ||||||
|     "isClosed": "This note is resolved", |     "isClosed": "This note is resolved", | ||||||
|     "closeNote":  |     "addCommentPlaceholder": "Add a comment...", | ||||||
|       "Close this note" |     "addComment": "Add comment", | ||||||
|  |     "addCommentAndClose": "Add comment and close", | ||||||
|  |     "closeNote":       "Close note", | ||||||
|  |     "reopenNote": "Reopen note", | ||||||
|  |     "reopenNoteAndComment": "Reopen note and comment", | ||||||
|  |     "anonymous": "Anonymous user" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -947,11 +947,56 @@ | ||||||
|     "notes": { |     "notes": { | ||||||
|         "layers": { |         "layers": { | ||||||
|             "0": { |             "0": { | ||||||
|  |                 "filter": { | ||||||
|  |                     "0": { | ||||||
|  |                         "options": { | ||||||
|  |                             "0": { | ||||||
|  |                                 "question": "Should mention {search} in the first comment" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "1": { | ||||||
|  |                         "options": { | ||||||
|  |                             "0": { | ||||||
|  |                                 "question": "Should <b>not</b> mention {search} in the first comment" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "2": { | ||||||
|  |                         "options": { | ||||||
|  |                             "0": { | ||||||
|  |                                 "question": "Opened by {search}" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "3": { | ||||||
|  |                         "options": { | ||||||
|  |                             "0": { | ||||||
|  |                                 "question": "<b>Not</b> opened by {search}" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "4": { | ||||||
|  |                         "options": { | ||||||
|  |                             "0": { | ||||||
|  |                                 "question": "Opened by anonymous user" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|                 "name": "OpenStreetMap notes", |                 "name": "OpenStreetMap notes", | ||||||
|                 "tagRenderings": { |                 "tagRenderings": { | ||||||
|                     "date_created": { |                     "Spam": { | ||||||
|                         "render": "Opened on {date_created}" |                         "render": "<a href='https://www.openstreetmap.org/reports/new?reportable_id={_first_user_id}&reportable_type=User' target='_blank' class='subtle'>Report {_first_user} as spam</a>" | ||||||
|                     } |                     } | ||||||
|  |                 }, | ||||||
|  |                 "title": { | ||||||
|  |                     "mappings": { | ||||||
|  |                         "0": { | ||||||
|  |                             "then": "Closed note" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "render": "Note" | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue