Refactoring: fix most of the custom input elements, support right click/long tap/double click to add a new element

This commit is contained in:
Pieter Vander Vennet 2023-04-16 03:42:26 +02:00
parent b0052d3a36
commit 1123a72c5e
25 changed files with 390 additions and 531 deletions

View file

@ -25,7 +25,6 @@ export default class PerLayerFeatureSourceSplitter<
const knownLayers = new Map<string, T>()
this.perLayer = knownLayers
const layerSources = new Map<string, UIEventSource<Feature[]>>()
console.log("PerLayerFeatureSourceSplitter got layers", layers)
const constructStore =
options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store))
for (const layer of layers) {

View file

@ -1,39 +0,0 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class ColorPicker extends InputElement<string> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly value: UIEventSource<string>
private readonly _element: HTMLElement
constructor(value: UIEventSource<string> = new UIEventSource<string>(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<string> {
return this.value
}
IsValid(t: string): boolean {
return false
}
protected InnerConstructElement(): HTMLElement {
return this._element
}
}

View file

@ -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<T, J, X> extends InputElement<X> {
private readonly _a: InputElement<T>
private readonly _b: InputElement<J>
private readonly _combined: BaseUIElement
private readonly _value: UIEventSource<X>
private readonly _split: (x: X) => [T, J]
constructor(
a: InputElement<T>,
b: InputElement<J>,
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<X> {
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()
}
}

View file

@ -4,7 +4,6 @@ import { UIEventSource } from "../../Logic/UIEventSource"
export default class FileSelectorButton extends InputElement<FileList> {
private static _nextid
IsSelected: UIEventSource<boolean>
private readonly _value = new UIEventSource<FileList>(undefined)
private readonly _label: BaseUIElement
private readonly _acceptType: string

View file

@ -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<string> {
private readonly _location: Store<Loc>
private readonly value: UIEventSource<string>
private readonly background: Store<RasterLayerPolygon>
constructor(
location: UIEventSource<Loc>,
mapBackground?: UIEventSource<RasterLayerPolygon>,
value?: UIEventSource<string>
) {
super()
this._location = location
this.value = value ?? new UIEventSource<string>(undefined)
this.background = mapBackground ?? new ImmutableStore(AvailableRasterLayers.osmCarto)
this.SetClass("block")
}
GetValue(): UIEventSource<string> {
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<L.Map>,
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 = <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()
}
}
}

View file

@ -1,37 +0,0 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class SimpleDatePicker extends InputElement<string> {
private readonly value: UIEventSource<string>
private readonly _element: HTMLElement
constructor(value?: UIEventSource<string>) {
super()
this.value = value ?? new UIEventSource<string>(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<string> {
return this.value
}
IsValid(t: string): boolean {
return !isNaN(new Date(t).getTime())
}
protected InnerConstructElement(): HTMLElement {
return this._element
}
}

View file

@ -0,0 +1,12 @@
<script lang="ts">
/**
* Simple wrapper around the HTML-color field.
*/
import { UIEventSource } from "../../../Logic/UIEventSource";
export let value: UIEventSource<undefined | string>;
</script>
<input bind:value={$value} type="color">

View file

@ -0,0 +1,12 @@
<script lang="ts">
/**
* Simple wrapper around the HTML-date field.
*/
import { UIEventSource } from "../../../Logic/UIEventSource";
export let value: UIEventSource<undefined | string>;
</script>
<input bind:value={$value} type="date">

View file

@ -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<undefined | number>;
export let value: UIEventSource<undefined | string>;
export let mapProperties: Partial<MapProperties> & { readonly location: UIEventSource<{ lon: number; lat: number }> };
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(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 @@
<MaplibreMap {map} attribution={false}></MaplibreMap>
</div>
<div bind:this={directionElem} class="absolute w-full h-full top-0 left-0 border border-red-500">
<div bind:this={directionElem} class="absolute w-full h-full top-0 left-0">
<ToSvelte construct={ Svg.direction_stroke_svg}>

View file

@ -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<string>
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<string>;
export let feature: Feature;
export let args: (string | number | boolean)[] = undefined;
let properties = { feature, args: args ?? [] };
let construct = InputHelpers.AvailableInputHelpers[type];
</script>
{#if construct !== undefined}
<ToSvelte construct={() => construct(value, properties)} />
{/if}

View file

@ -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<MapProperties> & {
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<string>,
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<MapProperties> {
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<MapProperties> = 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<number>(zoom) }
}
return mapProperties
}
private static constructWikidataHelper(
value: UIEventSource<string>,
props: InputHelperProperties
) {
const inputHelperOptions = props
const args = inputHelperOptions.args ?? []
const searchKey = <string>args[0] ?? "name"
const searchFor = <string>(
(inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "")
)
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
const options: any = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[] | Record<string, string[]>>options["removePrefixes"] ?? []
const postfixes = <string[] | Record<string, string[]>>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,
})
}
}

View file

@ -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<string>;
// 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<Translation> | 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}
<textarea class="w-full" bind:value={$_value} inputmode={validator.inputmode ?? "text"}></textarea>
{:else }
<span class="flex">
<span class="inline-flex">
<input bind:this={htmlElem} bind:value={$_value} inputmode={validator.inputmode ?? "text"}>
{#if !$isValid}
<ExclamationIcon class="h-6 w-6 -ml-6"></ExclamationIcon>

View file

@ -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")
)
}

View file

@ -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 {

View file

@ -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)
})
})

View file

@ -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<string> {
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly _value: UIEventSource<string>
private readonly _element: BaseUIElement

View file

@ -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<TagsFilter>(retag),
currentTags,
state
).SetClass("subtle"),
new SvelteUIElement(TagHint, {osmConnection: state.osmConnection, tags: retag})
])
}

View file

@ -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;

View file

@ -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<string>;
export let config: TagRenderingConfig;
export let tags: UIEventSource<Record<string, string>>;
export let feature: Feature = undefined;
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined);
let dispatch = createEventDispatcher<{ "selected" }>();
onDestroy(value.addCallbackD(() => {dispatch("selected")}))
</script>
{#if config.freeform.inline}
<Inline key={config.freeform.key} {tags} template={config.render}>
<div class="inline-flex flex-col">
{#if config.freeform.inline}
<Inline key={config.freeform.key} {tags} template={config.render}>
<ValidatedInput {feedback} on:selected={() => dispatch("selected")}
type={config.freeform.type} {value}></ValidatedInput>
</Inline>
{:else}
<ValidatedInput {feedback} on:selected={() => dispatch("selected")}
type={config.freeform.type} {value}></ValidatedInput>
</Inline>
{:else}
<ValidatedInput {feedback} on:selected={() => dispatch("selected")}
type={config.freeform.type} {value}></ValidatedInput>
{/if}
{/if}
<InputHelper args={config.freeform.helperArgs} {config} {feature} type={config.freeform.type} {value}></InputHelper>
</div>
{#if $feedback !== undefined}
<div class="alert">

View file

@ -41,13 +41,11 @@
return true;
}
let baseQuestions = []
$: {
baseQuestions = (layer.tagRenderings ?? [])?.filter(tr => allowed(tr.labels) && tr.question !== undefined);
}
let skippedQuestions = new UIEventSource<Set<string>>(new Set<string>());
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 => {

View file

@ -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));
</script>
{#if config !== undefined && (config?.condition === undefined || config.condition.matchesProperties(_tags))}
{#if trs.length === 1}
<TagRenderingMapping mapping={trs[0]} {tags} {state} {selectedElement} {layer}></TagRenderingMapping>
{/if}
{#if trs.length > 1}
<ul>
{#each trs as mapping}
<li>
<TagRenderingMapping {mapping} {tags} {state} {selectedElement} {layer}></TagRenderingMapping>
</li>
{/each}
</ul>
{/if}
<div class="flex flex-col w-full">
{#if trs.length === 1}
<TagRenderingMapping mapping={trs[0]} {tags} {state} {selectedElement} {layer}></TagRenderingMapping>
{/if}
{#if trs.length > 1}
<ul>
{#each trs as mapping}
<li>
<TagRenderingMapping {mapping} {tags} {state} {selectedElement} {layer}></TagRenderingMapping>
</li>
{/each}
</ul>
{/if}
</div>
{/if}

View file

@ -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<string>(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)}
<!-- There are no options to choose from, simply show the input element: fill out the text field -->
<FreeformInput {config} {tags} value={freeformInput} />
<FreeformInput {config} {tags} feature={selectedElement} value={freeformInput} />
{:else if mappings !== undefined && !config.multiAnswer}
<!-- Simple radiobuttons as mapping -->
<div class="flex flex-col">
@ -143,7 +149,7 @@
<label>
<input type="radio" bind:group={selectedMapping} name={"mappings-radio-"+config.id}
value={config.mappings.length}>
<FreeformInput {config} {tags} value={freeformInput}
<FreeformInput {config} {tags} feature={selectedElement} value={freeformInput}
on:selected={() => selectedMapping = config.mappings.length } />
</label>
{/if}
@ -162,7 +168,7 @@
<label>
<input type="checkbox" name={"mappings-checkbox-"+config.id+"-"+config.mappings.length}
bind:checked={checkedMappings[config.mappings.length]}>
<FreeformInput {config} {tags} value={freeformInput}
<FreeformInput {config} {tags} feature={selectedElement} value={freeformInput}
on:selected={() => checkedMappings[config.mappings.length] = true} />
</label>
{/if}
@ -180,7 +186,7 @@
{:else }
<div class="w-6 h-6">
<!-- Invalid value; show an inactive button or something like that-->
<ExclamationIcon></ExclamationIcon>
<ExclamationIcon/>
</div>
{/if}
</div>

View file

@ -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<TagsFilter>) => BaseUIElement
bottomText?: (src: Store<UploadableTag>) => 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<any>,
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<TagsFilter>,
tags: Store<object>,
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")
}
}

View file

@ -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<string> {
private static readonly _searchCache = new Map<string, Promise<WikidataResponse[]>>()
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly wikidataId: UIEventSource<string>
private readonly searchText: UIEventSource<string>
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<string>
value?: UIEventSource<string>

30
test.ts
View file

@ -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(<any>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<string>(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()