diff --git a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index 2449814963..f437a90d69 100644 --- a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -25,7 +25,6 @@ export default class PerLayerFeatureSourceSplitter< const knownLayers = new Map() this.perLayer = knownLayers const layerSources = new Map>() - console.log("PerLayerFeatureSourceSplitter got layers", layers) const constructStore = options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store)) for (const layer of layers) { diff --git a/UI/Input/ColorPicker.ts b/UI/Input/ColorPicker.ts deleted file mode 100644 index 3960c6ccbb..0000000000 --- a/UI/Input/ColorPicker.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" - -export default class ColorPicker extends InputElement { - IsSelected: UIEventSource = new UIEventSource(false) - private readonly value: UIEventSource - private readonly _element: HTMLElement - - constructor(value: UIEventSource = new UIEventSource(undefined)) { - super() - this.value = value - - const el = document.createElement("input") - this._element = el - - el.type = "color" - - this.value.addCallbackAndRunD((v) => { - el.value = v - }) - - el.oninput = () => { - const hex = el.value - value.setData(hex) - } - } - - GetValue(): UIEventSource { - return this.value - } - - IsValid(t: string): boolean { - return false - } - - protected InnerConstructElement(): HTMLElement { - return this._element - } -} diff --git a/UI/Input/CombinedInputElement.ts b/UI/Input/CombinedInputElement.ts deleted file mode 100644 index 2af732833e..0000000000 --- a/UI/Input/CombinedInputElement.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" -import Combine from "../Base/Combine" -import BaseUIElement from "../BaseUIElement" - -export default class CombinedInputElement extends InputElement { - private readonly _a: InputElement - private readonly _b: InputElement - private readonly _combined: BaseUIElement - private readonly _value: UIEventSource - private readonly _split: (x: X) => [T, J] - - constructor( - a: InputElement, - b: InputElement, - combine: (t: T, j: J) => X, - split: (x: X) => [T, J] - ) { - super() - this._a = a - this._b = b - this._split = split - this._combined = new Combine([this._a, this._b]) - this._value = this._a.GetValue().sync( - (t) => combine(t, this._b?.GetValue()?.data), - [this._b.GetValue()], - (x) => { - const [t, j] = split(x) - this._b.GetValue()?.setData(j) - return t - } - ) - } - - GetValue(): UIEventSource { - return this._value - } - - IsValid(x: X): boolean { - const [t, j] = this._split(x) - return this._a.IsValid(t) && this._b.IsValid(j) - } - - protected InnerConstructElement(): HTMLElement { - return this._combined.ConstructElement() - } -} diff --git a/UI/Input/FileSelectorButton.ts b/UI/Input/FileSelectorButton.ts index ae0b0ba429..5248cf51c6 100644 --- a/UI/Input/FileSelectorButton.ts +++ b/UI/Input/FileSelectorButton.ts @@ -4,7 +4,6 @@ import { UIEventSource } from "../../Logic/UIEventSource" export default class FileSelectorButton extends InputElement { private static _nextid - IsSelected: UIEventSource private readonly _value = new UIEventSource(undefined) private readonly _label: BaseUIElement private readonly _acceptType: string diff --git a/UI/Input/LengthInput.ts b/UI/Input/LengthInput.ts deleted file mode 100644 index 4a28e5ea1b..0000000000 --- a/UI/Input/LengthInput.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { InputElement } from "./InputElement"; -import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"; -import Combine from "../Base/Combine"; -import Svg from "../../Svg"; -import Loc from "../../Models/Loc"; -import { GeoOperations } from "../../Logic/GeoOperations"; -import BaseUIElement from "../BaseUIElement"; -import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"; - -/** - * Selects a length after clicking on the minimap, in meters - */ -export default class LengthInput extends InputElement { - private readonly _location: Store - private readonly value: UIEventSource - private readonly background: Store - - constructor( - location: UIEventSource, - mapBackground?: UIEventSource, - value?: UIEventSource - ) { - super() - this._location = location - this.value = value ?? new UIEventSource(undefined) - this.background = mapBackground ?? new ImmutableStore(AvailableRasterLayers.osmCarto) - this.SetClass("block") - } - - GetValue(): UIEventSource { - return this.value - } - - IsValid(str: string): boolean { - const t = Number(str) - return !isNaN(t) && t >= 0 - } - - protected InnerConstructElement(): HTMLElement { - let map: BaseUIElement = undefined - let layerControl: BaseUIElement = undefined - map = Minimap.createMiniMap({ - background: this.background, - allowMoving: false, - location: this._location, - attribution: true, - leafletOptions: { - tap: true, - }, - }) - - const crosshair = new Combine([ - Svg.length_crosshair_svg().SetStyle( - `position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);` - ), - ]) - .SetClass("block length-crosshair-svg relative pointer-events-none") - .SetStyle("z-index: 1000; visibility: hidden") - - const element = new Combine([ - crosshair, - map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"), - ]) - .SetClass("relative block bg-white border border-black rounded-xl overflow-hidden") - .ConstructElement() - - this.RegisterTriggers( - map?.ConstructElement(), - map?.leafletMap, - crosshair.ConstructElement() - ) - element.style.overflow = "hidden" - element.style.display = "block" - - return element - } - - private RegisterTriggers( - htmlElement: HTMLElement, - leafletMap: UIEventSource, - measurementCrosshair: HTMLElement - ) { - let firstClickXY: [number, number] = undefined - let lastClickXY: [number, number] = undefined - const self = this - - function onPosChange(x: number, y: number, isDown: boolean, isUp?: boolean) { - if (x === undefined || y === undefined) { - // Touch end - firstClickXY = undefined - lastClickXY = undefined - return - } - - const rect = htmlElement.getBoundingClientRect() - // From the central part of location - const dx = x - rect.left - const dy = y - rect.top - if (isDown) { - if (lastClickXY === undefined && firstClickXY === undefined) { - firstClickXY = [dx, dy] - } else if (firstClickXY !== undefined && lastClickXY === undefined) { - lastClickXY = [dx, dy] - } else if (firstClickXY !== undefined && lastClickXY !== undefined) { - // we measure again - firstClickXY = [dx, dy] - lastClickXY = undefined - } - } - - if (firstClickXY === undefined) { - measurementCrosshair.style.visibility = "hidden" - return - } - - const distance = Math.sqrt( - (dy - firstClickXY[1]) * (dy - firstClickXY[1]) + - (dx - firstClickXY[0]) * (dx - firstClickXY[0]) - ) - if (isUp) { - if (distance > 15) { - lastClickXY = [dx, dy] - } - } else if (lastClickXY !== undefined) { - return - } - measurementCrosshair.style.visibility = "unset" - measurementCrosshair.style.left = firstClickXY[0] + "px" - measurementCrosshair.style.top = firstClickXY[1] + "px" - - const angle = (180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx)) / Math.PI - const angleGeo = (angle + 270) % 360 - const measurementCrosshairInner: HTMLElement = ( - measurementCrosshair.firstChild - ) - measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)` - - measurementCrosshairInner.style.width = distance * 2 + "px" - measurementCrosshairInner.style.marginLeft = -distance + "px" - measurementCrosshairInner.style.marginTop = -distance + "px" - - const leaflet = leafletMap?.data - if (leaflet) { - const first = leaflet.layerPointToLatLng(firstClickXY) - const last = leaflet.layerPointToLatLng([dx, dy]) - const geoDist = - Math.floor( - GeoOperations.distanceBetween( - [first.lng, first.lat], - [last.lng, last.lat] - ) * 10 - ) / 10 - self.value.setData("" + geoDist) - } - } - - htmlElement.ontouchstart = (ev: TouchEvent) => { - onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true) - ev.preventDefault() - } - - htmlElement.ontouchmove = (ev: TouchEvent) => { - onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false) - ev.preventDefault() - } - - htmlElement.ontouchend = (ev: TouchEvent) => { - onPosChange(undefined, undefined, false, true) - ev.preventDefault() - } - - htmlElement.onmousedown = (ev: MouseEvent) => { - onPosChange(ev.clientX, ev.clientY, true) - ev.preventDefault() - } - - htmlElement.onmouseup = (ev) => { - onPosChange(ev.clientX, ev.clientY, false, true) - ev.preventDefault() - } - - htmlElement.onmousemove = (ev: MouseEvent) => { - onPosChange(ev.clientX, ev.clientY, false) - ev.preventDefault() - } - } -} diff --git a/UI/Input/SimpleDatePicker.ts b/UI/Input/SimpleDatePicker.ts deleted file mode 100644 index 8600830821..0000000000 --- a/UI/Input/SimpleDatePicker.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" - -export default class SimpleDatePicker extends InputElement { - private readonly value: UIEventSource - private readonly _element: HTMLElement - - constructor(value?: UIEventSource) { - super() - this.value = value ?? new UIEventSource(undefined) - const self = this - - const el = document.createElement("input") - this._element = el - el.type = "date" - el.oninput = () => { - // Already in YYYY-MM-DD value! - self.value.setData(el.value) - } - - this.value.addCallbackAndRunD((v) => { - el.value = v - }) - } - - GetValue(): UIEventSource { - return this.value - } - - IsValid(t: string): boolean { - return !isNaN(new Date(t).getTime()) - } - - protected InnerConstructElement(): HTMLElement { - return this._element - } -} diff --git a/UI/InputElement/Helpers/ColorInput.svelte b/UI/InputElement/Helpers/ColorInput.svelte new file mode 100644 index 0000000000..ee736d703e --- /dev/null +++ b/UI/InputElement/Helpers/ColorInput.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/UI/InputElement/Helpers/DateInput.svelte b/UI/InputElement/Helpers/DateInput.svelte new file mode 100644 index 0000000000..2f8c2d21c3 --- /dev/null +++ b/UI/InputElement/Helpers/DateInput.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/UI/InputElement/Helpers/DirectionInput.svelte b/UI/InputElement/Helpers/DirectionInput.svelte index cf63be378c..ea1c6ec3dd 100644 --- a/UI/InputElement/Helpers/DirectionInput.svelte +++ b/UI/InputElement/Helpers/DirectionInput.svelte @@ -8,9 +8,9 @@ import Svg from "../../../Svg.js"; /** - * A visualisation to pick a direction on a map background + * A visualisation to pick a direction on a map background. */ - export let value: UIEventSource; + export let value: UIEventSource; export let mapProperties: Partial & { readonly location: UIEventSource<{ lon: number; lat: number }> }; let map: UIEventSource = new UIEventSource(undefined); let mla = new MapLibreAdaptor(map, mapProperties); @@ -18,7 +18,6 @@ mla.allowZooming.setData(false) let directionElem: HTMLElement | undefined; $: value.addCallbackAndRunD(degrees => { - console.log("Degrees are", degrees, directionElem); if (directionElem === undefined) { return; } @@ -32,7 +31,7 @@ const dy = (rect.top + rect.bottom) / 2 - y; const angle = (180 * Math.atan2(dy, dx)) / Math.PI; const angleGeo = Math.floor((450 - angle) % 360); - value.setData(angleGeo); + value.setData(""+angleGeo); } let isDown = false; @@ -61,7 +60,7 @@ -
+
diff --git a/UI/InputElement/InputHelper.svelte b/UI/InputElement/InputHelper.svelte index e64305a67c..7d480abfca 100644 --- a/UI/InputElement/InputHelper.svelte +++ b/UI/InputElement/InputHelper.svelte @@ -3,11 +3,24 @@ * Constructs an input helper element for the given type. * Note that all values are stringified */ - - import { AvailableInputHelperType } from "./InputHelpers"; - import { UIEventSource } from "../../Logic/UIEventSource"; - export let type : AvailableInputHelperType - export let value : UIEventSource + import { UIEventSource } from "../../Logic/UIEventSource"; + import type { ValidatorType } from "./Validators"; + import InputHelpers from "./InputHelpers"; + import ToSvelte from "../Base/ToSvelte.svelte"; + import type { Feature } from "geojson"; + + export let type: ValidatorType; + export let value: UIEventSource; + export let feature: Feature; + export let args: (string | number | boolean)[] = undefined; + + let properties = { feature, args: args ?? [] }; + let construct = InputHelpers.AvailableInputHelpers[type]; + + +{#if construct !== undefined} + construct(value, properties)} /> +{/if} diff --git a/UI/InputElement/InputHelpers.ts b/UI/InputElement/InputHelpers.ts index ff6c14f8bb..3090713904 100644 --- a/UI/InputElement/InputHelpers.ts +++ b/UI/InputElement/InputHelpers.ts @@ -1,16 +1,151 @@ -import { AvailableRasterLayers } from "../../Models/RasterLayers" +import { ValidatorType } from "./Validators" +import { UIEventSource } from "../../Logic/UIEventSource" +import SvelteUIElement from "../Base/SvelteUIElement" +import DirectionInput from "./Helpers/DirectionInput.svelte" +import { MapProperties } from "../../Models/MapProperties" +import DateInput from "./Helpers/DateInput.svelte" +import ColorInput from "./Helpers/ColorInput.svelte" +import BaseUIElement from "../BaseUIElement" +import OpeningHoursInput from "../OpeningHours/OpeningHoursInput" +import WikidataSearchBox from "../Wikipedia/WikidataSearchBox" +import Wikidata from "../../Logic/Web/Wikidata" +import { Utils } from "../../Utils" +import Locale from "../i18n/Locale" +import { Feature } from "geojson" +import { GeoOperations } from "../../Logic/GeoOperations" -export type AvailableInputHelperType = typeof InputHelpers.AvailableInputHelpers[number] +export interface InputHelperProperties { + /** + * Extra arguments which might be used by the helper component + */ + args?: (string | number | boolean)[] + + /** + * Used for map-based helpers, such as 'direction' + */ + mapProperties?: Partial & { + readonly location: UIEventSource<{ lon: number; lat: number }> + } + /** + * The feature that this question is about + * Used by the wikidata-input to read properties, which in turn is used to read the name to pre-populate the text field. + * Additionally, used for direction input to set the default location if no mapProperties with location are given + */ + feature?: Feature +} export default class InputHelpers { - public static readonly AvailableInputHelpers = [] as const + public static readonly AvailableInputHelpers: Readonly< + Partial< + Record< + ValidatorType, + ( + value: UIEventSource, + extraProperties?: InputHelperProperties + ) => BaseUIElement + > + > + > = { + direction: (value, properties) => + new SvelteUIElement(DirectionInput, { + value, + mapProperties: InputHelpers.constructMapProperties(properties), + }), + date: (value) => new SvelteUIElement(DateInput, { value }), + color: (value) => new SvelteUIElement(ColorInput, { value }), + opening_hours: (value) => new OpeningHoursInput(value), + wikidata: InputHelpers.constructWikidataHelper, + } as const + /** - * To port - * direction - * opening_hours - * color - * length - * date - * wikidata + * Constructs a mapProperties-object for the given properties. + * Assumes that the first helper-args contains the desired zoom-level + * @param properties + * @private */ + private static constructMapProperties( + properties: InputHelperProperties + ): Partial { + let location = properties?.mapProperties?.location + if (!location) { + const [lon, lat] = GeoOperations.centerpointCoordinates(properties.feature) + location = new UIEventSource<{ lon: number; lat: number }>({ lon, lat }) + } + let mapProperties: Partial = properties?.mapProperties ?? { location } + if (!mapProperties.location) { + mapProperties = { ...mapProperties, location } + } + let zoom = 17 + if (properties.args[0]) { + zoom = Number(properties.args[0]) + if (isNaN(zoom)) { + throw "Invalid zoom level for argument at 'length'-input" + } + } + if (!mapProperties.zoom) { + mapProperties = { ...mapProperties, zoom: new UIEventSource(zoom) } + } + return mapProperties + } + private static constructWikidataHelper( + value: UIEventSource, + props: InputHelperProperties + ) { + const inputHelperOptions = props + const args = inputHelperOptions.args ?? [] + const searchKey = args[0] ?? "name" + + const searchFor = ( + (inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "") + ) + + let searchForValue: UIEventSource = new UIEventSource(searchFor) + const options: any = args[1] + if (searchFor !== undefined && options !== undefined) { + const prefixes = >options["removePrefixes"] ?? [] + const postfixes = >options["removePostfixes"] ?? [] + const defaultValueCandidate = Locale.language.map((lg) => { + const prefixesUnrwapped: RegExp[] = ( + Array.isArray(prefixes) ? prefixes : prefixes[lg] ?? [] + ).map((s) => new RegExp("^" + s, "i")) + const postfixesUnwrapped: RegExp[] = ( + Array.isArray(postfixes) ? postfixes : postfixes[lg] ?? [] + ).map((s) => new RegExp(s + "$", "i")) + let clipped = searchFor + + for (const postfix of postfixesUnwrapped) { + const match = searchFor.match(postfix) + if (match !== null) { + clipped = searchFor.substring(0, searchFor.length - match[0].length) + break + } + } + + for (const prefix of prefixesUnrwapped) { + const match = searchFor.match(prefix) + if (match !== null) { + clipped = searchFor.substring(match[0].length) + break + } + } + return clipped + }) + + defaultValueCandidate.addCallbackAndRun((clipped) => searchForValue.setData(clipped)) + } + + let instanceOf: number[] = Utils.NoNull( + (options?.instanceOf ?? []).map((i) => Wikidata.QIdToNumber(i)) + ) + let notInstanceOf: number[] = Utils.NoNull( + (options?.notInstanceOf ?? []).map((i) => Wikidata.QIdToNumber(i)) + ) + + return new WikidataSearchBox({ + value, + searchText: searchForValue, + instanceOf, + notInstanceOf, + }) + } } diff --git a/UI/InputElement/ValidatedInput.svelte b/UI/InputElement/ValidatedInput.svelte index 91b55755ce..761ef01e2d 100644 --- a/UI/InputElement/ValidatedInput.svelte +++ b/UI/InputElement/ValidatedInput.svelte @@ -5,15 +5,16 @@ import Validators from "./Validators"; import { ExclamationIcon } from "@rgossiaux/svelte-heroicons/solid"; import { Translation } from "../i18n/Translation"; - import { createEventDispatcher } from "svelte"; + import { createEventDispatcher, onDestroy } from "svelte"; export let value: UIEventSource; // Internal state, only copied to 'value' so that no invalid values leak outside let _value = new UIEventSource(value.data ?? ""); + onDestroy(value.addCallbackAndRun(v => _value.setData(v ?? ""))); export let type: ValidatorType; let validator = Validators.get(type); export let feedback: UIEventSource | undefined = undefined; - _value.addCallbackAndRun(v => { + onDestroy(_value.addCallbackAndRun(v => { if (validator.isValid(v)) { feedback?.setData(undefined); value.setData(v); @@ -21,7 +22,7 @@ } value.setData(undefined); feedback?.setData(validator.getFeedback(v)); - }); + })) if (validator === undefined) { throw "Not a valid type for a validator:" + type; @@ -46,7 +47,7 @@ {#if validator.textArea} {:else } - + {#if !$isValid} diff --git a/UI/InputElement/Validators/DirectionValidator.ts b/UI/InputElement/Validators/DirectionValidator.ts index 5f96184544..4cf14d9ad4 100644 --- a/UI/InputElement/Validators/DirectionValidator.ts +++ b/UI/InputElement/Validators/DirectionValidator.ts @@ -1,11 +1,14 @@ import IntValidator from "./IntValidator" -import { Validator } from "../Validator" export default class DirectionValidator extends IntValidator { constructor() { super( "direction", - "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)" + [ + "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl).", + "### Input helper", + "This element has an input helper showing a map and 'viewport' indicating the direction. By default, this map is zoomed to zoomlevel 17, but this can be changed with the first argument", + ].join("\n\n") ) } diff --git a/UI/InputElement/Validators/WikidataValidator.ts b/UI/InputElement/Validators/WikidataValidator.ts index 6a6cf12dfe..cb031f4477 100644 --- a/UI/InputElement/Validators/WikidataValidator.ts +++ b/UI/InputElement/Validators/WikidataValidator.ts @@ -1,6 +1,4 @@ import Combine from "../../Base/Combine" -import Title from "../../Base/Title" -import Table from "../../Base/Table" import Wikidata from "../../../Logic/Web/Wikidata" import { UIEventSource } from "../../../Logic/UIEventSource" import Locale from "../../i18n/Locale" @@ -10,89 +8,7 @@ import { Validator } from "../Validator" export default class WikidataValidator extends Validator { constructor() { - super( - "wikidata", - new Combine([ - "A wikidata identifier, e.g. Q42.", - new Title("Helper arguments"), - new Table( - ["name", "doc"], - [ - ["key", "the value of this tag will initialize search (default: name)"], - [ - "options", - new Combine([ - "A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.", - new Table( - ["subarg", "doc"], - [ - [ - "removePrefixes", - "remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes", - ], - [ - "removePostfixes", - "remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes.", - ], - [ - "instanceOf", - "A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans", - ], - [ - "notInstanceof", - "A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results", - ], - ] - ), - ]), - ], - ] - ), - new Title("Example usage"), - `The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name - -\`\`\`json -"freeform": { - "key": "name:etymology:wikidata", - "type": "wikidata", - "helperArgs": [ - "name", - { - "removePostfixes": {"en": [ - "street", - "boulevard", - "path", - "square", - "plaza", - ], - "nl": ["straat","plein","pad","weg",laan"], - "fr":["route (de|de la|de l'| de le)"] - }, - - "#": "Remove streets and parks from the search results:" - "notInstanceOf": ["Q79007","Q22698"] - } - - ] -} -\`\`\` - -Another example is to search for species and trees: - -\`\`\`json - "freeform": { - "key": "species:wikidata", - "type": "wikidata", - "helperArgs": [ - "species", - { - "instanceOf": [10884, 16521] - }] - } -\`\`\` -`, - ]) - ) + super("wikidata", new Combine(["A wikidata identifier, e.g. Q42.", WikidataSearchBox.docs])) } public isValid(str): boolean { diff --git a/UI/Map/MapLibreAdaptor.ts b/UI/Map/MapLibreAdaptor.ts index b05d3c0bef..74a9aa0f95 100644 --- a/UI/Map/MapLibreAdaptor.ts +++ b/UI/Map/MapLibreAdaptor.ts @@ -67,6 +67,18 @@ export class MapLibreAdaptor implements MapProperties { const lastClickLocation = new UIEventSource<{ lon: number; lat: number }>(undefined) this.lastClickLocation = lastClickLocation const self = this + + function handleClick(e) { + if (e.originalEvent["consumed"]) { + // Workaround, 'ShowPointLayer' sets this flag + return + } + console.log(e) + const lon = e.lngLat.lng + const lat = e.lngLat.lat + lastClickLocation.setData({ lon, lat }) + } + maplibreMap.addCallbackAndRunD((map) => { map.on("load", () => { this.updateStores() @@ -87,14 +99,13 @@ export class MapLibreAdaptor implements MapProperties { this.updateStores() map.on("moveend", () => this.updateStores()) map.on("click", (e) => { - if (e.originalEvent["consumed"]) { - // Workaround, 'ShowPointLayer' sets this flag - return - } - console.log(e) - const lon = e.lngLat.lng - const lat = e.lngLat.lat - lastClickLocation.setData({ lon, lat }) + handleClick(e) + }) + map.on("contextmenu", (e) => { + handleClick(e) + }) + map.on("dblclick", (e) => { + handleClick(e) }) }) diff --git a/UI/OpeningHours/OpeningHoursInput.ts b/UI/OpeningHours/OpeningHoursInput.ts index d301a19cdc..94b0089daa 100644 --- a/UI/OpeningHours/OpeningHoursInput.ts +++ b/UI/OpeningHours/OpeningHoursInput.ts @@ -1,7 +1,7 @@ /** * The full opening hours element, including the table, opening hours picker. * Keeps track of unparsed rules - * Exports everything conventiently as a string, for direct use + * Exports everything conveniently as a string, for direct use */ import OpeningHoursPicker from "./OpeningHoursPicker" import { Store, UIEventSource } from "../../Logic/UIEventSource" @@ -15,7 +15,6 @@ import Translations from "../i18n/Translations" import BaseUIElement from "../BaseUIElement" export default class OpeningHoursInput extends InputElement { - public readonly IsSelected: UIEventSource = new UIEventSource(false) private readonly _value: UIEventSource private readonly _element: BaseUIElement diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts index 84f0d552ed..2cd325c6ff 100644 --- a/UI/Popup/DeleteWizard.ts +++ b/UI/Popup/DeleteWizard.ts @@ -24,6 +24,8 @@ import TagRenderingQuestion from "./TagRenderingQuestion" import { OsmId, OsmTags } from "../../Models/OsmFeature" import { LoginToggle } from "./LoginButton" import { SpecialVisualizationState } from "../SpecialVisualization" +import SvelteUIElement from "../Base/SvelteUIElement"; +import TagHint from "./TagHint.svelte"; export default class DeleteWizard extends Toggle { /** @@ -225,11 +227,7 @@ export default class DeleteWizard extends Toggle { // This is a retagging, not a deletion of any kind return new Combine([ t.explanations.retagNoOtherThemes, - TagRenderingQuestion.CreateTagExplanation( - new UIEventSource(retag), - currentTags, - state - ).SetClass("subtle"), + new SvelteUIElement(TagHint, {osmConnection: state.osmConnection, tags: retag}) ]) } diff --git a/UI/Popup/TagHint.svelte b/UI/Popup/TagHint.svelte index 377d0736e3..8cd6a286ee 100644 --- a/UI/Popup/TagHint.svelte +++ b/UI/Popup/TagHint.svelte @@ -11,13 +11,13 @@ * A 'TagHint' will show the given tags in a human readable form. * Depending on the options, it'll link through to the wiki or might be completely hidden */ + export let tags: TagsFilter; export let osmConnection: OsmConnection; /** * If given, this function will be called to embed the given tags hint into this translation */ export let embedIn: (() => Translation) | undefined = undefined; const userDetails = osmConnection.userDetails; - export let tags: TagsFilter; let linkToWiki = false; onDestroy(osmConnection.userDetails.addCallbackAndRunD(userdetails => { linkToWiki = userdetails.csCount > Constants.userJourney.tagsVisibleAndWikiLinked; diff --git a/UI/Popup/TagRendering/FreeformInput.svelte b/UI/Popup/TagRendering/FreeformInput.svelte index 77362785ac..88df41b521 100644 --- a/UI/Popup/TagRendering/FreeformInput.svelte +++ b/UI/Popup/TagRendering/FreeformInput.svelte @@ -5,28 +5,36 @@ import Tr from "../../Base/Tr.svelte"; import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; import Inline from "./Inline.svelte"; - import { createEventDispatcher } from "svelte"; + import { createEventDispatcher, onDestroy } from "svelte"; + import InputHelper from "../../InputElement/InputHelper.svelte"; + import type { Feature } from "geojson"; export let value: UIEventSource; export let config: TagRenderingConfig; export let tags: UIEventSource>; + export let feature: Feature = undefined; + let feedback: UIEventSource = new UIEventSource(undefined); let dispatch = createEventDispatcher<{ "selected" }>(); + onDestroy(value.addCallbackD(() => {dispatch("selected")})) -{#if config.freeform.inline} - +
+ + {#if config.freeform.inline} + + dispatch("selected")} + type={config.freeform.type} {value}> + + {:else} dispatch("selected")} type={config.freeform.type} {value}> - -{:else} - dispatch("selected")} - type={config.freeform.type} {value}> - -{/if} + {/if} + +
{#if $feedback !== undefined}
diff --git a/UI/Popup/TagRendering/Questionbox.svelte b/UI/Popup/TagRendering/Questionbox.svelte index 8bfc71a019..2daecabd72 100644 --- a/UI/Popup/TagRendering/Questionbox.svelte +++ b/UI/Popup/TagRendering/Questionbox.svelte @@ -41,13 +41,11 @@ return true; } - let baseQuestions = [] - $: { - baseQuestions = (layer.tagRenderings ?? [])?.filter(tr => allowed(tr.labels) && tr.question !== undefined); - } let skippedQuestions = new UIEventSource>(new Set()); let questionsToAsk = tags.map(tags => { + const baseQuestions = (layer.tagRenderings ?? [])?.filter(tr => allowed(tr.labels) && tr.question !== undefined); + console.log("Determining questions for", baseQuestions) const questionsToAsk: TagRenderingConfig[] = []; for (const baseQuestion of baseQuestions) { if (skippedQuestions.data.has(baseQuestion.id) > 0) { @@ -64,6 +62,7 @@ return questionsToAsk; }, [skippedQuestions]); + let _questionsToAsk: TagRenderingConfig[]; let _firstQuestion: TagRenderingConfig; onDestroy(questionsToAsk.subscribe(qta => { diff --git a/UI/Popup/TagRendering/TagRenderingAnswer.svelte b/UI/Popup/TagRendering/TagRenderingAnswer.svelte index bcc58f715f..1a583e282e 100644 --- a/UI/Popup/TagRendering/TagRenderingAnswer.svelte +++ b/UI/Popup/TagRendering/TagRenderingAnswer.svelte @@ -17,25 +17,27 @@ export let state: SpecialVisualizationState; export let selectedElement: Feature; export let config: TagRenderingConfig; - if(config === undefined){ - throw "Config is undefined in tagRenderingAnswer" + if (config === undefined) { + throw "Config is undefined in tagRenderingAnswer"; } - export let layer: LayerConfig + export let layer: LayerConfig; let trs: { then: Translation; icon?: string; iconClass?: string }[]; $: trs = Utils.NoNull(config?.GetRenderValues(_tags)); {#if config !== undefined && (config?.condition === undefined || config.condition.matchesProperties(_tags))} - {#if trs.length === 1} - - {/if} - {#if trs.length > 1} -
    - {#each trs as mapping} -
  • - -
  • - {/each} -
- {/if} +
+ {#if trs.length === 1} + + {/if} + {#if trs.length > 1} +
    + {#each trs as mapping} +
  • + +
  • + {/each} +
+ {/if} +
{/if} diff --git a/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/UI/Popup/TagRendering/TagRenderingQuestion.svelte index 38e681ae50..d26468d669 100644 --- a/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -11,7 +11,7 @@ import FreeformInput from "./FreeformInput.svelte"; import Translations from "../../i18n/Translations.js"; import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"; - import { createEventDispatcher } from "svelte"; + import { createEventDispatcher, onDestroy } from "svelte"; import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; import { ExclamationIcon } from "@rgossiaux/svelte-heroicons/solid"; import SpecialTranslation from "./SpecialTranslation.svelte"; @@ -25,6 +25,12 @@ // Will be bound if a freeform is available let freeformInput = new UIEventSource(undefined); + onDestroy(tags.addCallbackAndRunD(tags => { + // initialize with the previous value + if (config.freeform?.key) { + freeformInput.setData(tags[config.freeform.key]); + } + })); let selectedMapping: number = undefined; let checkedMappings: boolean[]; $: { @@ -126,7 +132,7 @@ {#if config.freeform?.key && !(mappings?.length > 0)} - + {:else if mappings !== undefined && !config.multiAnswer}
@@ -143,7 +149,7 @@ {/if} @@ -162,7 +168,7 @@ {/if} @@ -180,7 +186,7 @@ {:else }
- +
{/if}
diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 72c1f80dad..f2ea087f3f 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -25,10 +25,8 @@ import TagRenderingConfig, { Mapping } from "../../Models/ThemeConfig/TagRenderi import { Unit } from "../../Models/Unit" import VariableInputElement from "../Input/VariableInputElement" import Toggle from "../Input/Toggle" -import Img from "../Base/Img" import FeaturePipelineState from "../../Logic/State/FeaturePipelineState" import Title from "../Base/Title" -import { OsmConnection } from "../../Logic/Osm/OsmConnection" import { GeoOperations } from "../../Logic/GeoOperations" import { SearchablePillsSelector } from "../Input/SearchableMappingsSelector" import { OsmTags } from "../../Models/OsmFeature" @@ -47,7 +45,6 @@ export default class TagRenderingQuestion extends Combine { afterSave?: () => void cancelButton?: BaseUIElement saveButtonConstr?: (src: Store) => BaseUIElement - bottomText?: (src: Store) => BaseUIElement } ) { const applicableMappingsSrc = Stores.ListStabilized( @@ -134,26 +131,15 @@ export default class TagRenderingQuestion extends Combine { const saveButton = new Combine([options.saveButtonConstr(inputElement.GetValue())]) - let bottomTags: BaseUIElement - if (options.bottomText !== undefined) { - bottomTags = options.bottomText(inputElement.GetValue()) - } else { - bottomTags = TagRenderingQuestion.CreateTagExplanation( - inputElement.GetValue(), - tags, - state - ) - } super([ question, questionHint, inputElement, new VariableUiElement( - feedback.map( - (t) => - t - ?.SetStyle("padding-left: 0.75rem; padding-right: 0.75rem") - ?.SetClass("alert flex") ?? bottomTags + feedback.map((t) => + t + ?.SetStyle("padding-left: 0.75rem; padding-right: 0.75rem") + ?.SetClass("alert flex") ) ), new Combine([options.cancelButton, saveButton]).SetClass( @@ -634,14 +620,7 @@ export default class TagRenderingQuestion extends Combine { tagsSource: UIEventSource, state: FeaturePipelineState ): BaseUIElement { - const text = new SubstitutedTranslation(mapping.then, tagsSource, state) - if (mapping.icon === undefined) { - return text - } - return new Combine([ - new Img(mapping.icon).SetClass("mr-1 mapping-icon-" + (mapping.iconClass ?? "small")), - text, - ]).SetClass("flex items-center") + return undefined } private static GenerateFreeform( @@ -703,9 +682,6 @@ export default class TagRenderingQuestion extends Combine { feedback, }) - // Init with correct value - input?.GetValue().setData(tagsData[freeform.key] ?? freeform.default) - // Add a length check input?.GetValue().addCallbackD((v: string | undefined) => { if (v?.length >= 255) { @@ -734,32 +710,4 @@ export default class TagRenderingQuestion extends Combine { return inputTagsFilter } - - public static CreateTagExplanation( - selectedValue: Store, - tags: Store, - state?: { osmConnection?: OsmConnection } - ) { - return new VariableUiElement( - selectedValue.map( - (tagsFilter: TagsFilter) => { - const csCount = - state?.osmConnection?.userDetails?.data?.csCount ?? - Constants.userJourney.tagsVisibleAndWikiLinked + 1 - if (csCount < Constants.userJourney.tagsVisibleAt) { - return "" - } - if (tagsFilter === undefined) { - return Translations.t.general.noTagsSelected.SetClass("subtle") - } - if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) { - const tagsStr = tagsFilter.asHumanString(false, true, tags.data) - return new FixedUiElement(tagsStr).SetClass("subtle") - } - return tagsFilter.asHumanString(true, true, tags.data) - }, - [state?.osmConnection?.userDetails] - ) - ).SetClass("block break-all") - } } diff --git a/UI/Wikipedia/WikidataSearchBox.ts b/UI/Wikipedia/WikidataSearchBox.ts index 5c7f2ba3b2..d411031aaa 100644 --- a/UI/Wikipedia/WikidataSearchBox.ts +++ b/UI/Wikipedia/WikidataSearchBox.ts @@ -11,15 +11,96 @@ import Title from "../Base/Title" import WikipediaBox from "./WikipediaBox" import Svg from "../../Svg" import Loading from "../Base/Loading" +import Table from "../Base/Table" export default class WikidataSearchBox extends InputElement { private static readonly _searchCache = new Map>() - IsSelected: UIEventSource = new UIEventSource(false) private readonly wikidataId: UIEventSource private readonly searchText: UIEventSource private readonly instanceOf?: number[] private readonly notInstanceOf?: number[] + public static docs = new Combine([ + , + new Title("Helper arguments"), + new Table( + ["name", "doc"], + [ + ["key", "the value of this tag will initialize search (default: name)"], + [ + "options", + new Combine([ + "A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.", + new Table( + ["subarg", "doc"], + [ + [ + "removePrefixes", + "remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes", + ], + [ + "removePostfixes", + "remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes.", + ], + [ + "instanceOf", + "A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans", + ], + [ + "notInstanceof", + "A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results", + ], + ] + ), + ]), + ], + ] + ), + new Title("Example usage"), + `The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name + +\`\`\`json +"freeform": { + "key": "name:etymology:wikidata", + "type": "wikidata", + "helperArgs": [ + "name", + { + "removePostfixes": {"en": [ + "street", + "boulevard", + "path", + "square", + "plaza", + ], + "nl": ["straat","plein","pad","weg",laan"], + "fr":["route (de|de la|de l'| de le)"] + }, + + "#": "Remove streets and parks from the search results:" + "notInstanceOf": ["Q79007","Q22698"] + } + + ] +} +\`\`\` + +Another example is to search for species and trees: + +\`\`\`json + "freeform": { + "key": "species:wikidata", + "type": "wikidata", + "helperArgs": [ + "species", + { + "instanceOf": [10884, 16521] + }] + } +\`\`\` +`, + ]) + constructor(options?: { searchText?: UIEventSource value?: UIEventSource diff --git a/test.ts b/test.ts index bed47cf8bb..0b18421a94 100644 --- a/test.ts +++ b/test.ts @@ -3,6 +3,12 @@ import * as theme from "./assets/generated/themes/shops.json" import ThemeViewState from "./Models/ThemeViewState" import Combine from "./UI/Base/Combine" import SpecialVisualizations from "./UI/SpecialVisualizations" +import InputHelpers from "./UI/InputElement/InputHelpers" +import BaseUIElement from "./UI/BaseUIElement" +import { UIEventSource } from "./Logic/UIEventSource" +import { VariableUiElement } from "./UI/Base/VariableUIElement" +import { FixedUiElement } from "./UI/Base/FixedUiElement" +import Title from "./UI/Base/Title" function testspecial() { const layout = new LayoutConfig(theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data) @@ -14,4 +20,26 @@ function testspecial() { new Combine(all).AttachTo("maindiv") } -testspecial() +function testinput() { + const els: BaseUIElement[] = [] + for (const key in InputHelpers.AvailableInputHelpers) { + const value = new UIEventSource(undefined) + const helper = InputHelpers.AvailableInputHelpers[key](value, { + mapProperties: { + zoom: new UIEventSource(16), + location: new UIEventSource({ lat: 51.1, lon: 3.2 }), + }, + }) + + els.push( + new Combine([ + new Title(key), + helper, + new VariableUiElement(value.map((v) => new FixedUiElement(v))), + ]).SetClass("flex flex-col p-1 border-3 border-gray-500") + ) + } + new Combine(els).SetClass("flex flex-col").AttachTo("maindiv") +} +testinput() +// testspecial()