diff --git a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts index 9495e3325..6d3c12aa6 100644 --- a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts @@ -4,6 +4,7 @@ import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import Hash from "../../Web/Hash"; import {BBox} from "../../BBox"; import {ElementStorage} from "../../ElementStorage"; +import {TagsFilter} from "../../Tags/TagsFilter"; export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { 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 ?? []) { - const neededTags = filter.filter.options[filter.selected].osmTags - if (!neededTags.matchesProperties(f.feature.properties)) { + const neededTags : TagsFilter = filter?.currentFilter + if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) { // Hidden by the filter on the layer itself - we want to hide it no matter wat return false; } diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index 752115c5f..8971f199a 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -218,13 +218,58 @@ export class OsmConnection { }); } - public closeNote(id: number | string): Promise { + public closeNote(id: number | string, text?: string): Promise { + let textSuffix = "" + if((text ?? "") !== "" ){ + textSuffix = "?text="+encodeURIComponent(text) + } return new Promise((ok, error) => { this.auth.xhr({ 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 { + 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 { + 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) { - console.log("Closing note gave:", err, response) if (err !== null) { error(err) } else { diff --git a/Logic/State/MapState.ts b/Logic/State/MapState.ts index 07d3450dc..85a216a22 100644 --- a/Logic/State/MapState.ts +++ b/Logic/State/MapState.ts @@ -7,11 +7,10 @@ import Attribution from "../../UI/BigComponents/Attribution"; import Minimap, {MinimapObj} from "../../UI/Base/Minimap"; import {Tiles} from "../../Models/TileRange"; import BaseUIElement from "../../UI/BaseUIElement"; -import FilteredLayer from "../../Models/FilteredLayer"; +import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; import {QueryParameters} from "../Web/QueryParameters"; import * as personal from "../../assets/themes/personal/personal.json"; -import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; @@ -339,7 +338,6 @@ export default class MapState extends UserRelatedState { private InitializeFilteredLayers() { const layoutToUse = this.layoutToUse; - const empty = [] const flayers: FilteredLayer[] = []; for (const layer of layoutToUse.layers) { let isDisplayed: UIEventSource @@ -355,26 +353,18 @@ export default class MapState extends UserRelatedState { "Wether or not layer " + layer.id + " is shown" ) } - const flayer = { + const flayer : FilteredLayer = { isDisplayed: isDisplayed, layerDef: layer, - appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]), + appliedFilters: new UIEventSource>(new Map()) }; - - if (layer.filters.length > 0) { - const filtersPerName = new Map() - layer.filters.forEach(f => filtersPerName.set(f.id, f)) - 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) - } + layer.filters.forEach(filterConfig => { + const stateSrc = filterConfig.initState() + + stateSrc .addCallbackAndRun(state => flayer.appliedFilters.data.set(filterConfig.id, state)) + flayer.appliedFilters.map(dict => dict.get(filterConfig.id)) + .addCallback(state => stateSrc.setData(state)) + }) flayers.push(flayer); } diff --git a/Models/FilteredLayer.ts b/Models/FilteredLayer.ts index f57cea891..0c4e163a3 100644 --- a/Models/FilteredLayer.ts +++ b/Models/FilteredLayer.ts @@ -1,9 +1,13 @@ import {UIEventSource} from "../Logic/UIEventSource"; 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 { readonly isDisplayed: UIEventSource; - readonly appliedFilters: UIEventSource<{ filter: FilterConfig, selected: number }[]>; + readonly appliedFilters: UIEventSource>; readonly layerDef: LayerConfig; } \ No newline at end of file diff --git a/Models/ThemeConfig/FilterConfig.ts b/Models/ThemeConfig/FilterConfig.ts index 5f23e0e44..08aeec7fa 100644 --- a/Models/ThemeConfig/FilterConfig.ts +++ b/Models/ThemeConfig/FilterConfig.ts @@ -4,19 +4,21 @@ 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"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {FilterState} from "../FilteredLayer"; +import {QueryParameters} from "../../Logic/Web/QueryParameters"; +import {Utils} from "../../Utils"; export default class FilterConfig { public readonly id: string public readonly options: { question: Translation; - osmTags: TagsFilter; + osmTags: TagsFilter | undefined; originalTagsSpec: string | AndOrTagConfigJson fields: { name: string, type: string }[] }[]; - + constructor(json: FilterConfigJson, context: string) { if (json.options === undefined) { throw `A filter without options was given at ${context}` @@ -39,11 +41,14 @@ export default class FilterConfig { option.question, `${ctx}.question` ); - let osmTags = TagUtils.Tag( - option.osmTags ?? {and: []}, + let osmTags = undefined; + if (option.osmTags !== undefined) { + osmTags = TagUtils.Tag( + option.osmTags, `${ctx}.osmTags` ); + } if (question === undefined) { throw `Invalid filter: no question given at ${ctx}` } @@ -61,12 +66,12 @@ export default class FilterConfig { type } }) - - if(fields.length > 0){ + + if (fields.length > 0) { // erase the tags, they aren't needed - osmTags = TagUtils.Tag({and:[]}) + osmTags = undefined } - + 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.` } - 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" } } - + + public initState(): UIEventSource { + + 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 = (v).replace("{"+key+"}", props[key]) + } + return v + } + ) + return { + 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 + ) + } } \ No newline at end of file diff --git a/Models/ThemeConfig/LegacyJsonConvert.ts b/Models/ThemeConfig/LegacyJsonConvert.ts index 7e0397137..861e77dcc 100644 --- a/Models/ThemeConfig/LegacyJsonConvert.ts +++ b/Models/ThemeConfig/LegacyJsonConvert.ts @@ -29,7 +29,7 @@ abstract class Conversion { } public static strict(fixed: { errors: string[], warnings: string[], result?: T }): T { - if (fixed.errors?.length > 0) { + if (fixed?.errors?.length > 0) { throw fixed.errors.join("\n"); } fixed.warnings?.forEach(w => console.warn(w)) diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index 13b431153..b51368437 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -10,13 +10,14 @@ import Svg from "../../Svg"; import {UIEventSource} from "../../Logic/UIEventSource"; import BaseUIElement from "../BaseUIElement"; import State from "../../State"; -import FilteredLayer from "../../Models/FilteredLayer"; +import FilteredLayer, {FilterState} 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"; +import {TagUtils} from "../../Logic/Tags/TagUtils"; export default class FilterView extends VariableUiElement { constructor(filteredLayer: UIEventSource, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource }[]) { @@ -142,151 +143,104 @@ export default class FilterView extends VariableUiElement { if (layer.filters.length === 0) { return undefined; } + + + const toShow : BaseUIElement [] = [] - const filterIndexes = new Map() - layer.filters.forEach((f, i) => filterIndexes.set(f.id, i)) - - let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map( - filter => FilterView.createFilter(filter) - ); - - listFilterElements.forEach((inputElement, i) => - inputElement[1].addCallback((changed) => { - const oldValue = flayer.appliedFilters.data - - if (changed === undefined) { - // 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); + for (const filter of layer.filters) { + + const [ui, actualTags] = FilterView.createFilter(filter) + + 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(listFilterElements.map(input => input[0].SetClass("mt-3"))) + return new Combine(toShow) .SetClass("flex flex-col ml-8 bg-gray-300 rounded-xl p-2") } + + // Filter which uses one or more textfields + private static createFilterWithFields(filterConfig: FilterConfig): [BaseUIElement, UIEventSource] { - private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>] { - - if (filterConfig.options[0].fields.length > 0) { - - // Filter which uses one or more textfields - const filter = filterConfig.options[0] - const mappings = new Map() - let allValid = new UIEventSource(true) - const properties = new UIEventSource({}) - for (const {name, type} of filter.fields) { - const value = QueryParameters.GetQueryParameter("filter-" + filterConfig.id + "-" + name, "", "Value for filter " + filterConfig.id) - const field = ValidatedTextField.InputForType(type, { - value - }).SetClass("inline-block") - mappings.set(name, field) - const stable = value.stabilized(250) - stable.addCallbackAndRunD(v => { - properties.data[name] = v.toLowerCase(); - properties.ping() - }) - allValid = allValid.map(previous => previous && field.IsValid(stable.data) && stable.data !== "", [stable]) + const filter = filterConfig.options[0] + const mappings = new Map() + let allValid = new UIEventSource(true) + const properties = new UIEventSource({}) + for (const {name, type} of filter.fields) { + const value = QueryParameters.GetQueryParameter("filter-" + filterConfig.id + "-" + name, "", "Value for filter " + filterConfig.id) + const field = ValidatedTextField.InputForType(type, { + value + }).SetClass("inline-block") + mappings.set(name, field) + const stable = value.stabilized(250) + stable.addCallbackAndRunD(v => { + properties.data[name] = v.toLowerCase(); + properties.ping() + }) + allValid = allValid.map(previous => previous && field.IsValid(stable.data) && stable.data !== "", [stable]) + } + const tr = new SubstitutedTranslation(filter.question, new UIEventSource({id: filterConfig.id}), State.state, mappings) + const trigger : UIEventSource= allValid.map(isValid => { + if (!isValid) { + return undefined } - const tr = new SubstitutedTranslation(filter.question, new UIEventSource({id: filterConfig.id}), State.state, mappings) - const neutral = { - filter: new FilterConfig({ - id: filterConfig.id, - options: [ - { - question: "--", - } - ] - }, "While dynamically constructing a filterconfig"), - selected: 0 - } - const trigger = allValid.map(isValid => { - if (!isValid) { - return neutral - } - - // Replace all the field occurences in the tags... - const osmTags = Utils.WalkJson(filter.originalTagsSpec, - v => { - if (typeof v !== "string") { - return v - } - return Utils.SubstituteKeys(v, properties.data) + const props = properties.data + // Replace all the field occurences in the tags... + const tagsSpec = Utils.WalkJson(filter.originalTagsSpec, + v => { + if (typeof v !== "string") { + return v } - ) - // ... 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 + + for (const key in props) { + v = (v).replace("{"+key+"}", props[key]) + } + + return v } - }, [properties]) - return [tr, trigger]; - } - - - if (filterConfig.options.length === 1) { - let option = filterConfig.options[0]; - - const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6"); - const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2 w-6"); - - const toggle = new Toggle( - new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"), - new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex") ) - .ToggleOnClick() - .SetClass("block m-1") - - const selected = { - filter: filterConfig, - selected: 0 + const tagsFilter = TagUtils.Tag(tagsSpec) + return { + currentFilter: tagsFilter, + state: JSON.stringify(props) } - return [toggle, toggle.isEnabled.map(enabled => enabled ? selected : undefined, [], - f => f?.filter === filterConfig && f?.selected === 0) - ] - } + }, [properties]) + + return [tr, trigger]; + } + + private static createCheckboxFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource] { + let option = filterConfig.options[0]; + + const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6"); + const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2 w-6"); + + const toggle = new Toggle( + new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"), + new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex") + ) + .ToggleOnClick() + .SetClass("block m-1") + + return [toggle, toggle.isEnabled.map(enabled => enabled ? {currentFilter: option.osmTags, state: "true"} : undefined, [], + f => f !== undefined) + ] + } + private static createMultiFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource] { let options = filterConfig.options; - const values = options.map((f, i) => ({ - filter: filterConfig, selected: i + const values : FilterState[] = options.map((f, i) => ({ + currentFilter: f.osmTags, state: i })) const radio = new RadioButton( options.map( @@ -302,8 +256,25 @@ export default class FilterView extends VariableUiElement { i => values[i], [], 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] { + + if (filterConfig.options[0].fields.length > 0) { + return FilterView.createFilterWithFields(filterConfig) + } + + + if (filterConfig.options.length === 1) { + return FilterView.createCheckboxFilter(filterConfig) + } + + return FilterView.createMultiFilter(filterConfig) + } } diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 719647df4..9d5b03e64 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -111,7 +111,10 @@ export default class SimpleAddUI extends Toggle { message, state.LastClickLocation.data, confirm, - cancel) + cancel, + () => { + isShown.setData(false) + }) } )) diff --git a/UI/DefaultGuiState.ts b/UI/DefaultGuiState.ts index ab834bc26..34b8cc668 100644 --- a/UI/DefaultGuiState.ts +++ b/UI/DefaultGuiState.ts @@ -1,6 +1,5 @@ import {UIEventSource} from "../Logic/UIEventSource"; import {QueryParameters} from "../Logic/Web/QueryParameters"; -import Constants from "../Models/Constants"; import Hash from "../Logic/Web/Hash"; export class DefaultGuiState { @@ -46,18 +45,19 @@ export class DefaultGuiState { "false", "Whether or not the current view box is shown" ) - if (Hash.hash.data === "download") { - this.downloadControlIsOpened.setData(true) + const states = { + download: this.downloadControlIsOpened, + filters: this.filterViewIsOpened, + copyright: this.copyrightViewIsOpened, + currentview: this.currentViewControlIsOpened, + welcome: this.welcomeMessageIsOpened } - if (Hash.hash.data === "filters") { - this.filterViewIsOpened.setData(true) - } - if (Hash.hash.data === "copyright") { - this.copyrightViewIsOpened.setData(true) - }if (Hash.hash.data === "currentview") { - this.currentViewControlIsOpened.setData(true) - } - if (Hash.hash.data === "" || Hash.hash.data === undefined || Hash.hash.data === "welcome") { + Hash.hash.addCallbackAndRunD(hash => { + hash = hash.toLowerCase() + states[hash]?.setData(true) + }) + + if (Hash.hash.data === "" || Hash.hash.data === undefined) { this.welcomeMessageIsOpened.setData(true) } diff --git a/UI/NewPoint/ConfirmLocationOfPoint.ts b/UI/NewPoint/ConfirmLocationOfPoint.ts index 5e41ed7f4..7bdbb79bd 100644 --- a/UI/NewPoint/ConfirmLocationOfPoint.ts +++ b/UI/NewPoint/ConfirmLocationOfPoint.ts @@ -29,6 +29,7 @@ export default class ConfirmLocationOfPoint extends Combine { loc: { lon: number, lat: number }, confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, cancel: () => void, + closePopup: () => void ) { let preciseInput: LocationInput = undefined @@ -137,33 +138,26 @@ export default class ConfirmLocationOfPoint extends Combine { ] ).SetClass("flex flex-col") ).onClick(() => { - preset.layerToAddTo.appliedFilters.setData([]) + + const appliedFilters = preset.layerToAddTo.appliedFilters; + appliedFilters.data.forEach((_, k) => appliedFilters.data.set(k, undefined)) + appliedFilters.ping() 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( openLayerOrConfirm, - disableFilter, - preset.layerToAddTo.appliedFilters.map(filters => { - 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 - - }) - ) + disableFilter, + hasActiveFilter) + const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection); diff --git a/UI/Popup/ImportButton.ts b/UI/Popup/ImportButton.ts index 4800dce61..9b587c09e 100644 --- a/UI/Popup/ImportButton.ts +++ b/UI/Popup/ImportButton.ts @@ -520,7 +520,8 @@ export class ImportPointButton extends AbstractImportButton { guiState: DefaultGuiState, originalFeatureTags: UIEventSource, feature: any, - onCancel: () => void): BaseUIElement { + onCancel: () => void, + close: () => void): BaseUIElement { 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), { lon, lat - }, confirm, onCancel) + }, confirm, onCancel, close) } @@ -567,7 +568,7 @@ export class ImportPointButton extends AbstractImportButton { originalFeatureTags, guiState, feature, - onCancel): BaseUIElement { + onCancel: () => void): BaseUIElement { const geometry = feature.geometry @@ -579,7 +580,11 @@ export class ImportPointButton extends AbstractImportButton { guiState, originalFeatureTags, feature, - onCancel + onCancel, + () => { + // Close the current popup + state.selectedElement.setData(undefined) + } )) } diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index b30821758..9a8afa609 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -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 {OpenIdEditor} from "./BigComponents/CopyrightPanel"; import Toggle from "./Input/Toggle"; +import Img from "./Base/Img"; +import ValidatedTextField from "./Input/ValidatedTextField"; +import Link from "./Base/Link"; export interface SpecialVisualization { funcName: string, @@ -53,8 +56,41 @@ export default class SpecialVisualizations { public static specialVisualizations = SpecialVisualizations.init() - private static init(){ - const specialVisualizations: SpecialVisualization[] = + 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() { + const specialVisualizations: SpecialVisualization[] = [ { funcName: "all_tags", @@ -590,12 +626,12 @@ export default class SpecialVisualizations { funcName: "open_in_iD", docs: "Opens the current view in the iD-editor", args: [], - constr: (state, feature ) => { + constr: (state, feature) => { return new OpenIdEditor(state, undefined, feature.data.id) } }, - - + + { funcName: "clear_location_history", docs: "A button to remove the travelled track information from the device", @@ -611,29 +647,51 @@ export default class SpecialVisualizations { }, { funcName: "close_note", - docs: "Button to close a note", - args:[ + docs: "Button to close a note - eventually with a prefixed text", + args: [ { - name:"text", + name: "text", doc: "Text to show on this button", }, { - name:"Id-key", + 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; - const closeButton = new SubtleButton( Svg.checkmark_svg(), t.closeNote) - const isClosed = new UIEventSource(false); + + 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){ + if (state.featureSwitchIsTesting.data) { console.log("Not actually closing note...") return; } - state.osmConnection.closeNote(id).then(_ => isClosed.setData(true)) + state.osmConnection.closeNote(id, args[3]).then(_ => { + tags.data["closed_at"] = new Date().toISOString(); + tags.ping() + }) }) return new Toggle( t.isClosed.SetClass("thanks"), @@ -641,46 +699,157 @@ export default class SpecialVisualizations { 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", + doc: "The property name where the ID of the note to close can be found", + defaultValue: "id" + } + ], + constr: (state, tags, args, guiState) => { + + const t = Translations.t.notes; + const textField = ValidatedTextField.InputForType("text", {placeholder: t.addCommentPlaceholder}) + textField.SetClass("rounded-l border border-grey") + 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"] + if (state.featureSwitchIsTesting.data) { + console.log("Testmode: Not actually closing note...") + return; + } + 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") + }) + ) + } } ] - + specialVisualizations.push(new AutoApplyButton(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"); - } } \ No newline at end of file diff --git a/assets/svg/license_info.json b/assets/svg/license_info.json index c66e82696..2d7f5689b 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -823,6 +823,14 @@ "authors": [], "sources": [] }, + { + "path": "note.svg", + "license": "CC0", + "authors": [ + "Pieter Vander Vennet" + ], + "sources": [] + }, { "path": "osm-logo-us.svg", "license": "Logo", @@ -965,6 +973,14 @@ ], "sources": [] }, + { + "path": "resolved.svg", + "license": "CC0", + "authors": [ + "Pieter Vander Vennet" + ], + "sources": [] + }, { "path": "ring.svg", "license": "CC0; trivial", diff --git a/assets/themes/notes/note.svg b/assets/svg/note.svg similarity index 100% rename from assets/themes/notes/note.svg rename to assets/svg/note.svg diff --git a/assets/themes/notes/resolved.svg b/assets/svg/resolved.svg similarity index 100% rename from assets/themes/notes/resolved.svg rename to assets/svg/resolved.svg diff --git a/assets/themes/notes/license_info.json b/assets/themes/notes/license_info.json deleted file mode 100644 index 79bc48e8e..000000000 --- a/assets/themes/notes/license_info.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "path": "note.svg", - "license": "CC0", - "authors": [ - "Pieter Vander Vennet" - ], - "sources": [] - }, - { - "path": "resolved.svg", - "license": "CC0", - "authors": [ - "Pieter Vander Vennet" - ], - "sources": [] - } -] \ No newline at end of file diff --git a/assets/themes/notes/notes.json b/assets/themes/notes/notes.json index 2649a7efe..880c6a66e 100644 --- a/assets/themes/notes/notes.json +++ b/assets/themes/notes/notes.json @@ -9,8 +9,8 @@ "startZoom": 0, "title": "Notes on OpenStreetMap", "version": "0.1", - "description": "Notes from OpenStreetMap", - "icon": "./assets/themes/notes/resolved.svg", + "description": "A note is a pin on the map with some text to indicate something wrong.

Make sure to checkout the filter view to search for users and text.", + "icon": "./assets/svg/resolved.svg", "clustering": false, "enableDownload": true, "layers": [ @@ -19,7 +19,7 @@ "name": { "en": "OpenStreetMap notes" }, - "description": "Notes on OpenStreetMap.org", + "description": "This layer shows notes on OpenStreetMap.", "source": { "osmTags": "id~*", "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": [ "_first_comment:=feat.get('comments')[0].text.toLowerCase()", - "_conversation=feat.get('comments').map(c => { let user = 'anonymous user'; if(c.user_url !== undefined){user = ''+c.user+''}; return c.html +'
' + user + ' '+c.date+'
' }).join('')" + "_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": [ { @@ -52,18 +55,18 @@ "tagRenderings": [ { "id": "conversation", - "render": "{_conversation}" + "render": "{visualize_note_comments()}" }, { - "id": "date_created", + "id": "comment", + "render": "{add_note_comment()}" + }, + { + "id": "Spam", "render": { - "en": "Opened on {date_created}" - } - }, - { - "id": "close", - "render": "{close_note()}", - "condition": "closed_at=" + "en": "Report {_first_user} as spam" + }, + "condition": "_opened_by_anonymous_user=false" } ], "mapRendering": [ @@ -73,11 +76,11 @@ "centroid" ], "icon": { - "render": "./assets/themes/notes/note.svg", + "render": "./assets/svg/note.svg", "mappings": [ { "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": "Not opened by {search}" + } + } + ] + }, + { + "id": "anonymous", + "options": [ + { + "osmTags": "_opened_by_anonymous_user=true", + "question": { + "en": "Opened by anonymous user" + } + } + ] } ] } diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 954a299ab..592b02f86 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -864,14 +864,18 @@ video { margin-top: 1rem; } -.mt-1 { - margin-top: 0.25rem; +.mr-2 { + margin-right: 0.5rem; } .mr-4 { margin-right: 1rem; } +.mt-1 { + margin-top: 0.25rem; +} + .mt-2 { margin-top: 0.5rem; } @@ -888,10 +892,6 @@ video { margin-left: 2rem; } -.mr-2 { - margin-right: 0.5rem; -} - .mb-10 { margin-bottom: 2.5rem; } @@ -1068,6 +1068,10 @@ video { width: 2rem; } +.w-6 { + width: 1.5rem; +} + .w-0 { width: 0px; } @@ -1080,10 +1084,6 @@ video { width: 2.75rem; } -.w-6 { - width: 1.5rem; -} - .w-16 { width: 4rem; } @@ -1283,26 +1283,31 @@ video { border-radius: 0.25rem; } +.rounded-xl { + border-radius: 0.75rem; +} + .rounded-lg { border-radius: 0.5rem; } -.rounded-xl { - border-radius: 0.75rem; +.rounded-l { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; } .border { border-width: 1px; } -.border-4 { - border-width: 4px; -} - .border-2 { border-width: 2px; } +.border-4 { + border-width: 4px; +} + .border-l-4 { border-left-width: 4px; } diff --git a/langs/en.json b/langs/en.json index 58776bfe5..a9483281e 100644 --- a/langs/en.json +++ b/langs/en.json @@ -425,7 +425,12 @@ }, "notes": { "isClosed": "This note is resolved", - "closeNote": - "Close this note" + "addCommentPlaceholder": "Add a comment...", + "addComment": "Add comment", + "addCommentAndClose": "Add comment and close", + "closeNote": "Close note", + "reopenNote": "Reopen note", + "reopenNoteAndComment": "Reopen note and comment", + "anonymous": "Anonymous user" } } diff --git a/langs/themes/en.json b/langs/themes/en.json index 3b13d509b..4c1556112 100644 --- a/langs/themes/en.json +++ b/langs/themes/en.json @@ -947,11 +947,56 @@ "notes": { "layers": { "0": { + "filter": { + "0": { + "options": { + "0": { + "question": "Should mention {search} in the first comment" + } + } + }, + "1": { + "options": { + "0": { + "question": "Should not mention {search} in the first comment" + } + } + }, + "2": { + "options": { + "0": { + "question": "Opened by {search}" + } + } + }, + "3": { + "options": { + "0": { + "question": "Not opened by {search}" + } + } + }, + "4": { + "options": { + "0": { + "question": "Opened by anonymous user" + } + } + } + }, "name": "OpenStreetMap notes", "tagRenderings": { - "date_created": { - "render": "Opened on {date_created}" + "Spam": { + "render": "Report {_first_user} as spam" } + }, + "title": { + "mappings": { + "0": { + "then": "Closed note" + } + }, + "render": "Note" } } }