diff --git a/Customizations/AllSharedLayers.ts b/Customizations/AllSharedLayers.ts index 7e4cf71a1..43c18e981 100644 --- a/Customizations/AllSharedLayers.ts +++ b/Customizations/AllSharedLayers.ts @@ -2,7 +2,6 @@ import LayerConfig from "../Models/ThemeConfig/LayerConfig" import { Utils } from "../Utils" import known_themes from "../assets/generated/known_layers.json" import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson" -import { ALL } from "dns" import { AllKnownLayouts } from "./AllKnownLayouts" export class AllSharedLayers { public static sharedLayers: Map = AllSharedLayers.getSharedLayers() diff --git a/Logic/Actors/TitleHandler.ts b/Logic/Actors/TitleHandler.ts index f2228fa45..d8af466c8 100644 --- a/Logic/Actors/TitleHandler.ts +++ b/Logic/Actors/TitleHandler.ts @@ -1,12 +1,13 @@ import { Store, UIEventSource } from "../UIEventSource" import Locale from "../../UI/i18n/Locale" -import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer" import Combine from "../../UI/Base/Combine" import { Utils } from "../../Utils" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import { Feature } from "geojson" import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" +import SvelteUIElement from "../../UI/Base/SvelteUIElement" +import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer.svelte" export default class TitleHandler { constructor( @@ -32,7 +33,7 @@ export default class TitleHandler { const tagsSource = allElements.getStore(tags.id) ?? new UIEventSource>(tags) - const title = new TagRenderingAnswer(tagsSource, layer.title, {}) + const title = new SvelteUIElement(TagRenderingAnswer, { tags: tagsSource }) return ( new Combine([defaultTitle, " | ", title]).ConstructElement() ?.textContent ?? defaultTitle diff --git a/Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage.ts b/Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage.ts index 29519b6bb..5e113aaff 100644 --- a/Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage.ts +++ b/Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage.ts @@ -1,12 +1,4 @@ -import FeatureSource, { Tiled } from "../FeatureSource" -import { Tiles } from "../../../Models/TileRange" -import { IdbLocalStorage } from "../../Web/IdbLocalStorage" -import { UIEventSource } from "../../UIEventSource" -import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" -import { BBox } from "../../BBox" -import SimpleFeatureSource from "../Sources/SimpleFeatureSource" -import FilteredLayer from "../../../Models/FilteredLayer" -import Loc from "../../../Models/Loc" +import FeatureSource from "../FeatureSource" import { Feature } from "geojson" import TileLocalStorage from "./TileLocalStorage" import { GeoOperations } from "../../GeoOperations" diff --git a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts index 543a26ad5..c280bebcb 100644 --- a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts +++ b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts @@ -1,7 +1,6 @@ import { UIEventSource } from "../../UIEventSource" import FilteredLayer from "../../../Models/FilteredLayer" -import { FeatureSourceForLayer, Tiled } from "../FeatureSource" -import { BBox } from "../../BBox" +import { FeatureSourceForLayer } from "../FeatureSource" import { Feature } from "geojson" export default class SimpleFeatureSource implements FeatureSourceForLayer { diff --git a/Logic/FeatureSource/Sources/SnappingFeatureSource.ts b/Logic/FeatureSource/Sources/SnappingFeatureSource.ts new file mode 100644 index 000000000..855e935ea --- /dev/null +++ b/Logic/FeatureSource/Sources/SnappingFeatureSource.ts @@ -0,0 +1,52 @@ +import FeatureSource from "../FeatureSource" +import { Store } from "../../UIEventSource" +import { Feature, Point } from "geojson" +import { GeoOperations } from "../../GeoOperations" + +export interface SnappingOptions { + /** + * If the distance is bigger then this amount, don't snap. + * In meter + */ + maxDistance?: number +} + +export default class SnappingFeatureSource implements FeatureSource { + public readonly features: Store[]> + + constructor( + snapTo: FeatureSource, + location: Store<{ lon: number; lat: number }>, + options?: SnappingOptions + ) { + const simplifiedFeatures = snapTo.features.mapD((features) => + features + .filter((feature) => feature.geometry.type !== "Point") + .map((f) => GeoOperations.forceLineString(f)) + ) + + location.mapD( + ({ lon, lat }) => { + const features = snapTo.features.data + const loc: [number, number] = [lon, lat] + const maxDistance = (options?.maxDistance ?? 1000) * 1000 + let bestSnap: Feature = undefined + for (const feature of features) { + const snapped = GeoOperations.nearestPoint(feature, loc) + if (snapped.properties.dist > maxDistance) { + continue + } + if ( + bestSnap === undefined || + bestSnap.properties.dist > snapped.properties.dist + ) { + snapped.properties["snapped-to"] = feature.properties.id + bestSnap = snapped + } + } + return bestSnap + }, + [snapTo.features] + ) + } +} diff --git a/Logic/FeatureSource/Sources/TouchesBboxFeatureSource.ts b/Logic/FeatureSource/Sources/TouchesBboxFeatureSource.ts index 2fb3fd0a2..6384a3cfd 100644 --- a/Logic/FeatureSource/Sources/TouchesBboxFeatureSource.ts +++ b/Logic/FeatureSource/Sources/TouchesBboxFeatureSource.ts @@ -2,7 +2,6 @@ import FeatureSource, { FeatureSourceForLayer } from "../FeatureSource" import StaticFeatureSource from "./StaticFeatureSource" import { GeoOperations } from "../../GeoOperations" import { BBox } from "../../BBox" -import exp from "constants" import FilteredLayer from "../../../Models/FilteredLayer" /** diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index 302e809a3..3b5df2add 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -7,6 +7,7 @@ import { GeoJSON, Geometry, LineString, + MultiLineString, MultiPolygon, Point, Polygon, @@ -272,17 +273,42 @@ export class GeoOperations { * @param point Point defined as [lon, lat] */ public static nearestPoint( - way: Feature, + way: Feature, point: [number, number] - ): Feature { + ): Feature< + Point, + { + index: number + dist: number + location: number + } + > { + return ( + turf.nearestPointOnLine(>way, point, { units: "kilometers" }) + ) + } + + /** + * Helper method to reuse the coordinates of the way as LineString. + * Mostly used as helper for 'nearestPoint' + * @param way + */ + public static forceLineString( + way: Feature + ): Feature { if (way.geometry.type === "Polygon") { way = { ...way } way.geometry = { ...way.geometry } way.geometry.type = "LineString" way.geometry.coordinates = (way.geometry).coordinates[0] + } else if (way.geometry.type === "MultiPolygon") { + way = { ...way } + way.geometry = { ...way.geometry } + way.geometry.type = "MultiLineString" + way.geometry.coordinates = (way.geometry).coordinates[0] } - return turf.nearestPointOnLine(>way, point, { units: "kilometers" }) + return way } public static toCSV(features: any[]): string { diff --git a/Logic/ImageProviders/AllImageProviders.ts b/Logic/ImageProviders/AllImageProviders.ts index 770da2fbf..6099d0ee4 100644 --- a/Logic/ImageProviders/AllImageProviders.ts +++ b/Logic/ImageProviders/AllImageProviders.ts @@ -5,6 +5,7 @@ import GenericImageProvider from "./GenericImageProvider" import { Store, UIEventSource } from "../UIEventSource" import ImageProvider, { ProvidedImage } from "./ImageProvider" import { WikidataImageProvider } from "./WikidataImageProvider" +import { OsmTags } from "../../Models/OsmFeature" /** * A generic 'from the interwebz' image picker, without attribution @@ -44,7 +45,7 @@ export default class AllImageProviders { UIEventSource >() - public static LoadImagesFor(tags: Store, tagKey?: string[]): Store { + public static LoadImagesFor(tags: Store, tagKey?: string[]): Store { if (tags.data.id === undefined) { return undefined } diff --git a/Logic/Osm/Actions/ChangeLocationAction.ts b/Logic/Osm/Actions/ChangeLocationAction.ts index 0ec12bc7b..a0bcf97db 100644 --- a/Logic/Osm/Actions/ChangeLocationAction.ts +++ b/Logic/Osm/Actions/ChangeLocationAction.ts @@ -24,7 +24,7 @@ export default class ChangeLocationAction extends OsmChangeAction { this._meta = meta } - protected async CreateChangeDescriptions(changes: Changes): Promise { + protected async CreateChangeDescriptions(): Promise { const d: ChangeDescription = { changes: { lat: this._newLonLat[1], diff --git a/Logic/Osm/Actions/ChangeTagAction.ts b/Logic/Osm/Actions/ChangeTagAction.ts index 63d8c2320..2e28a788b 100644 --- a/Logic/Osm/Actions/ChangeTagAction.ts +++ b/Logic/Osm/Actions/ChangeTagAction.ts @@ -71,7 +71,7 @@ export default class ChangeTagAction extends OsmChangeAction { return { k: key.trim(), v: value.trim() } } - async CreateChangeDescriptions(changes: Changes): Promise { + async CreateChangeDescriptions(): Promise { const changedTags: { k: string; v: string }[] = this._tagsFilter .asChange(this._currentTags) .map(ChangeTagAction.checkChange) diff --git a/Logic/State/UserRelatedState.ts b/Logic/State/UserRelatedState.ts index 8f80a5046..06996e9e5 100644 --- a/Logic/State/UserRelatedState.ts +++ b/Logic/State/UserRelatedState.ts @@ -3,7 +3,6 @@ import { OsmConnection } from "../Osm/OsmConnection" import { MangroveIdentity } from "../Web/MangroveReviews" import { Store, Stores, UIEventSource } from "../UIEventSource" import Locale from "../../UI/i18n/Locale" -import { Changes } from "../Osm/Changes" import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource" import FeatureSource from "../FeatureSource/FeatureSource" import { Feature } from "geojson" diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index 945bd49b2..bab74dc6d 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -122,7 +122,7 @@ export class Tag extends TagsFilter { return [this] } - asChange(properties: any): { k: string; v: string }[] { + asChange(): { k: string; v: string }[] { return [{ k: this.key, v: this.value }] } diff --git a/Models/MapProperties.ts b/Models/MapProperties.ts index cbb34e7f3..39a49adcc 100644 --- a/Models/MapProperties.ts +++ b/Models/MapProperties.ts @@ -1,4 +1,4 @@ -import { Store, UIEventSource } from "../Logic/UIEventSource" +import { UIEventSource } from "../Logic/UIEventSource" import { BBox } from "../Logic/BBox" import { RasterLayerPolygon } from "./RasterLayers" diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index db15a5df4..05fc634bf 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -26,7 +26,6 @@ import Table from "../../UI/Base/Table" import FilterConfigJson from "./Json/FilterConfigJson" import { And } from "../../Logic/Tags/And" import { Overpass } from "../../Logic/Osm/Overpass" -import Constants from "../Constants" import { FixedUiElement } from "../../UI/Base/FixedUiElement" import Svg from "../../Svg" import { ImmutableStore } from "../../Logic/UIEventSource" diff --git a/Models/ThemeViewState.ts b/Models/ThemeViewState.ts index f2f36e34f..f175a6e94 100644 --- a/Models/ThemeViewState.ts +++ b/Models/ThemeViewState.ts @@ -108,7 +108,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location) this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id) - const indexedElements = new LayoutSource( + this.indexedFeatures = new LayoutSource( layout.layers, this.featureSwitches, new StaticFeatureSource([]), @@ -116,6 +116,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.osmConnection.Backend(), (id) => this.layerState.filteredLayers.get(id).isDisplayed ) + const indexedElements = this.indexedFeatures this.featureProperties = new FeaturePropertiesStore(indexedElements) const perLayer = new PerLayerFeatureSourceSplitter( Array.from(this.layerState.filteredLayers.values()), diff --git a/UI/AllThemesGui.ts b/UI/AllThemesGui.ts index c55e188c0..605f78fcc 100644 --- a/UI/AllThemesGui.ts +++ b/UI/AllThemesGui.ts @@ -15,7 +15,6 @@ import { OsmConnection } from "../Logic/Osm/OsmConnection" export default class AllThemesGui { setup() { try { - new FixedUiElement("").AttachTo("centermessage") const osmConnection = new OsmConnection() const state = new UserRelatedState(osmConnection) const intro = new Combine([ @@ -38,15 +37,14 @@ export default class AllThemesGui { new FixedUiElement("v" + Constants.vNumber), ]) .SetClass("block m-5 lg:w-3/4 lg:ml-40") - .SetStyle("pointer-events: all;") - .AttachTo("top-left") + .AttachTo("main") } catch (e) { console.error(">>>> CRITICAL", e) new FixedUiElement( "Seems like no layers are compiled - check the output of `npm run generate:layeroverview`. Is this visible online? Contact pietervdvn immediately!" ) .SetClass("alert") - .AttachTo("centermessage") + .AttachTo("main") } } } diff --git a/UI/Base/AsyncLazy.ts b/UI/Base/AsyncLazy.ts index d09c9769f..71c6ab34e 100644 --- a/UI/Base/AsyncLazy.ts +++ b/UI/Base/AsyncLazy.ts @@ -1,6 +1,6 @@ import BaseUIElement from "../BaseUIElement" import { VariableUiElement } from "./VariableUIElement" -import { Stores, UIEventSource } from "../../Logic/UIEventSource" +import { Stores } from "../../Logic/UIEventSource" import Loading from "./Loading" export default class AsyncLazy extends BaseUIElement { diff --git a/UI/Base/DivContainer.ts b/UI/Base/DivContainer.ts index bc6c9e68e..f36b041d6 100644 --- a/UI/Base/DivContainer.ts +++ b/UI/Base/DivContainer.ts @@ -1,4 +1,3 @@ -import { UIElement } from "../UIElement" import BaseUIElement from "../BaseUIElement" /** diff --git a/UI/Base/DragInvitation.svelte b/UI/Base/DragInvitation.svelte new file mode 100644 index 000000000..853a0ca06 --- /dev/null +++ b/UI/Base/DragInvitation.svelte @@ -0,0 +1,89 @@ + + + +
+
+ +
+
+ + diff --git a/UI/Base/FromHtml.svelte b/UI/Base/FromHtml.svelte new file mode 100644 index 000000000..af07db545 --- /dev/null +++ b/UI/Base/FromHtml.svelte @@ -0,0 +1,19 @@ + + +{#if src !== undefined} + +{/if} + diff --git a/UI/Base/Link.ts b/UI/Base/Link.ts index 76c025dc6..25f107a47 100644 --- a/UI/Base/Link.ts +++ b/UI/Base/Link.ts @@ -1,6 +1,6 @@ import Translations from "../i18n/Translations" import BaseUIElement from "../BaseUIElement" -import { Store, UIEventSource } from "../../Logic/UIEventSource" +import { Store } from "../../Logic/UIEventSource" export default class Link extends BaseUIElement { private readonly _href: string | Store diff --git a/UI/Base/Tr.svelte b/UI/Base/Tr.svelte new file mode 100644 index 000000000..53e92bbae --- /dev/null +++ b/UI/Base/Tr.svelte @@ -0,0 +1,30 @@ + + + diff --git a/UI/BigComponents/ActionButtons.ts b/UI/BigComponents/ActionButtons.ts index fad2a0224..beba67986 100644 --- a/UI/BigComponents/ActionButtons.ts +++ b/UI/BigComponents/ActionButtons.ts @@ -13,7 +13,6 @@ import { OpenIdEditor, OpenJosm } from "./CopyrightPanel" import Toggle from "../Input/Toggle" import ScrollableFullScreen from "../Base/ScrollableFullScreen" import { DefaultGuiState } from "../DefaultGuiState" -import DefaultGUI from "../DefaultGUI" export class BackToThemeOverview extends Toggle { constructor( diff --git a/UI/BigComponents/DownloadPanel.ts b/UI/BigComponents/DownloadPanel.ts index 98ffad07e..a47703ba7 100644 --- a/UI/BigComponents/DownloadPanel.ts +++ b/UI/BigComponents/DownloadPanel.ts @@ -14,7 +14,6 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import { BBox } from "../../Logic/BBox" import FilteredLayer, { FilterState } from "../../Models/FilteredLayer" import geojson2svg from "geojson2svg" -import Constants from "../../Models/Constants" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" export class DownloadPanel extends Toggle { diff --git a/UI/BigComponents/FullWelcomePaneWithTabs.ts b/UI/BigComponents/FullWelcomePaneWithTabs.ts index 5637a05d1..6318595c0 100644 --- a/UI/BigComponents/FullWelcomePaneWithTabs.ts +++ b/UI/BigComponents/FullWelcomePaneWithTabs.ts @@ -17,7 +17,6 @@ import UserRelatedState from "../../Logic/State/UserRelatedState" import Loc from "../../Models/Loc" import BaseLayer from "../../Models/BaseLayer" import FilteredLayer from "../../Models/FilteredLayer" -import CopyrightPanel from "./CopyrightPanel" import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline" import PrivacyPolicy from "./PrivacyPolicy" import Hotkeys from "../Base/Hotkeys" diff --git a/UI/BigComponents/Geosearch.svelte b/UI/BigComponents/Geosearch.svelte index fe40cb7c7..bc1f30f30 100644 --- a/UI/BigComponents/Geosearch.svelte +++ b/UI/BigComponents/Geosearch.svelte @@ -15,8 +15,6 @@ Translations.t; export let bounds: UIEventSource - export let layout: LayoutConfig; - export let perLayer: ReadonlyMap export let selectedElement: UIEventSource; export let selectedLayer: UIEventSource; diff --git a/UI/BigComponents/SelectedElementView.svelte b/UI/BigComponents/SelectedElementView.svelte index 5c6715326..ee380537f 100644 --- a/UI/BigComponents/SelectedElementView.svelte +++ b/UI/BigComponents/SelectedElementView.svelte @@ -1,23 +1,15 @@
-
selectedElement.setData(undefined)}>close
- new VariableUiElement(tags.mapD(tags => new TagRenderingAnswer(tags, layer.data.title, specialVisState), [layer]))}> +

+ +

- - {#each $layer.titleIcons as titleIconConfig (titleIconConfig.id)} + {#each layer.titleIcons as titleIconConfig (titleIconConfig.id)}
- new VariableUiElement(tags.mapD(tags => new TagRenderingAnswer(tags, titleIconConfig, specialVisState)))}> +
- {/each}
-
    - - {#each Object.keys($_tags) as key} -
  • {key}={$_tags[key]}
  • +
    + {#each layer.tagRenderings as config (config.id)} + {/each} -
+
+ diff --git a/UI/BigComponents/ThemeButton.svelte b/UI/BigComponents/ThemeButton.svelte index 556ba4fc6..2b3814ec0 100644 --- a/UI/BigComponents/ThemeButton.svelte +++ b/UI/BigComponents/ThemeButton.svelte @@ -7,6 +7,7 @@ import Constants from "../../Models/Constants" import type Loc from "../../Models/Loc" import type { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"; + import Tr from "../Base/Tr.svelte"; export let theme: LayoutInformation export let isCustom: boolean = false @@ -16,8 +17,8 @@ $: title = new Translation( theme.title, !isCustom && !theme.mustHaveLanguage ? "themes:" + theme.id + ".title" : undefined - ).toString() - $: description = new Translation(theme.shortDescription).toString() + ) + $: description = new Translation(theme.shortDescription) // TODO: Improve this function function createUrl( @@ -83,8 +84,10 @@ - {title} - {description} + + + + diff --git a/UI/DefaultGUI.ts b/UI/DefaultGUI.ts index af3cc05dc..5de8eb26b 100644 --- a/UI/DefaultGUI.ts +++ b/UI/DefaultGUI.ts @@ -5,7 +5,6 @@ import FullWelcomePaneWithTabs from "./BigComponents/FullWelcomePaneWithTabs" import MapControlButton from "./MapControlButton" import Svg from "../Svg" import Toggle from "./Input/Toggle" -import SearchAndGo from "./BigComponents/SearchAndGo" import BaseUIElement from "./BaseUIElement" import LeftControls from "./BigComponents/LeftControls" import RightControls from "./BigComponents/RightControls" @@ -26,7 +25,6 @@ import UserInformationPanel from "./BigComponents/UserInformation" import { LoginToggle } from "./Popup/LoginButton" import { FixedUiElement } from "./Base/FixedUiElement" import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler" -import { GeoLocationState } from "../Logic/State/GeoLocationState" import Hotkeys from "./Base/Hotkeys" import CopyrightPanel from "./BigComponents/CopyrightPanel" import SvelteUIElement from "./Base/SvelteUIElement" diff --git a/UI/Image/SlideShow.ts b/UI/Image/SlideShow.ts index 51bc6edb6..ee07cb208 100644 --- a/UI/Image/SlideShow.ts +++ b/UI/Image/SlideShow.ts @@ -1,4 +1,4 @@ -import { Store, UIEventSource } from "../../Logic/UIEventSource" +import { Store } from "../../Logic/UIEventSource" import BaseUIElement from "../BaseUIElement" import { Utils } from "../../Utils" import Combine from "../Base/Combine" diff --git a/UI/ImportFlow/ConflationChecker.ts b/UI/ImportFlow/ConflationChecker.ts index 70ba96c4d..c1ab25f13 100644 --- a/UI/ImportFlow/ConflationChecker.ts +++ b/UI/ImportFlow/ConflationChecker.ts @@ -17,7 +17,6 @@ import Minimap from "../Base/Minimap" import BaseLayer from "../../Models/BaseLayer" import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers" import Loc from "../../Models/Loc" -import Attribution from "../BigComponents/Attribution" import ShowDataLayer from "../ShowDataLayer/ShowDataLayer" import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" import ValidatedTextField from "../Input/ValidatedTextField" diff --git a/UI/ImportFlow/FlowStep.ts b/UI/ImportFlow/FlowStep.ts index b99b2829e..1da6fdce9 100644 --- a/UI/ImportFlow/FlowStep.ts +++ b/UI/ImportFlow/FlowStep.ts @@ -7,7 +7,6 @@ import Translations from "../i18n/Translations" import { VariableUiElement } from "../Base/VariableUIElement" import Toggle from "../Input/Toggle" import { UIElement } from "../UIElement" -import { FixedUiElement } from "../Base/FixedUiElement" export interface FlowStep extends BaseUIElement { readonly IsValid: Store diff --git a/UI/ImportFlow/MapPreview.ts b/UI/ImportFlow/MapPreview.ts index ce8c2ae51..56d9736bb 100644 --- a/UI/ImportFlow/MapPreview.ts +++ b/UI/ImportFlow/MapPreview.ts @@ -4,7 +4,6 @@ import { BBox } from "../../Logic/BBox" import UserRelatedState from "../../Logic/State/UserRelatedState" import Translations from "../i18n/Translations" import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts" -import Constants from "../../Models/Constants" import { DropDown } from "../Input/DropDown" import { Utils } from "../../Utils" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" diff --git a/UI/Input/DirectionInput.ts b/UI/Input/DirectionInput.ts deleted file mode 100644 index 2df6adf23..000000000 --- a/UI/Input/DirectionInput.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" -import Combine from "../Base/Combine" -import Svg from "../../Svg" -import BaseUIElement from "../BaseUIElement" -import { FixedUiElement } from "../Base/FixedUiElement" -import { Utils } from "../../Utils" -import Loc from "../../Models/Loc" -import Minimap from "../Base/Minimap" - -/** - * Selects a direction in degrees - */ -export default class DirectionInput extends InputElement { - public readonly IsSelected: UIEventSource = new UIEventSource(false) - private readonly _location: UIEventSource - private readonly value: UIEventSource - private background - - constructor( - mapBackground: UIEventSource, - location: UIEventSource, - value?: UIEventSource - ) { - super() - this._location = location - this.value = value ?? new UIEventSource(undefined) - this.background = mapBackground - } - - GetValue(): UIEventSource { - return this.value - } - - IsValid(str: string): boolean { - const t = Number(str) - return !isNaN(t) && t >= 0 && t <= 360 - } - - protected InnerConstructElement(): HTMLElement { - let map: BaseUIElement = new FixedUiElement("") - if (!Utils.runningFromConsole) { - map = Minimap.createMiniMap({ - background: this.background, - allowMoving: false, - location: this._location, - }) - } - - const element = new Combine([ - Svg.direction_stroke_svg() - .SetStyle( - `position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${ - this.value.data ?? 0 - }deg);` - ) - .SetClass("direction-svg relative") - .SetStyle("z-index: 1000"), - map.SetStyle(`position: absolute;top: 0;left: 0;width: 100%;height: 100%;`), - ]) - .SetStyle("width: min(100%, 25em); height: 0; padding-bottom: 100%") // A bit a weird CSS , see https://stackoverflow.com/questions/13851940/pure-css-solution-square-elements#19448481 - .SetClass("relative block bg-white border border-black overflow-hidden rounded-full") - .ConstructElement() - - this.value.addCallbackAndRunD((rotation) => { - const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement - cone.style.transform = `rotate(${rotation}deg)` - }) - - this.RegisterTriggers(element) - element.style.overflow = "hidden" - element.style.display = "block" - - return element - } - - private RegisterTriggers(htmlElement: HTMLElement) { - const self = this - - function onPosChange(x: number, y: number) { - const rect = htmlElement.getBoundingClientRect() - const dx = -(rect.left + rect.right) / 2 + x - const dy = (rect.top + rect.bottom) / 2 - y - const angle = (180 * Math.atan2(dy, dx)) / Math.PI - const angleGeo = Math.floor((450 - angle) % 360) - self.value.setData("" + angleGeo) - } - - htmlElement.ontouchmove = (ev: TouchEvent) => { - onPosChange(ev.touches[0].clientX, ev.touches[0].clientY) - ev.preventDefault() - } - - htmlElement.ontouchstart = (ev: TouchEvent) => { - onPosChange(ev.touches[0].clientX, ev.touches[0].clientY) - } - - let isDown = false - - htmlElement.onmousedown = (ev: MouseEvent) => { - isDown = true - onPosChange(ev.clientX, ev.clientY) - ev.preventDefault() - } - - htmlElement.onmouseup = (ev) => { - isDown = false - ev.preventDefault() - } - - htmlElement.onmousemove = (ev: MouseEvent) => { - if (isDown) { - onPosChange(ev.clientX, ev.clientY) - } - ev.preventDefault() - } - } -} diff --git a/UI/Input/FloorLevelInputElement.ts b/UI/Input/FloorLevelInputElement.ts index 8b01b7783..6d88eb6d9 100644 --- a/UI/Input/FloorLevelInputElement.ts +++ b/UI/Input/FloorLevelInputElement.ts @@ -1,5 +1,5 @@ import { InputElement } from "./InputElement" -import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource" +import { Store, UIEventSource } from "../../Logic/UIEventSource" import Combine from "../Base/Combine" import Slider from "./Slider" import { ClickableToggle } from "./Toggle" diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index 38518392c..209b1f986 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -1,24 +1,17 @@ import { ReadonlyInputElement } from "./InputElement" import Loc from "../../Models/Loc" import { Store, UIEventSource } from "../../Logic/UIEventSource" -import Minimap, { MinimapObj } from "../Base/Minimap" -import BaseLayer from "../../Models/BaseLayer" import Combine from "../Base/Combine" import Svg from "../../Svg" import { GeoOperations } from "../../Logic/GeoOperations" -import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer" import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import { BBox } from "../../Logic/BBox" import { FixedUiElement } from "../Base/FixedUiElement" -import ShowDataLayer from "../ShowDataLayer/ShowDataLayer" import BaseUIElement from "../BaseUIElement" -import Toggle from "./Toggle" import matchpoint from "../../assets/layers/matchpoint/matchpoint.json" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import FilteredLayer from "../../Models/FilteredLayer" -import { ElementStorage } from "../../Logic/ElementStorage" -import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers" import { RelationId, WayId } from "../../Models/OsmFeature" import { Feature, LineString, Polygon } from "geojson" import { OsmObject, OsmWay } from "../../Logic/Osm/OsmObject" @@ -313,10 +306,6 @@ export default class LocationInput [this.map.leafletMap] ) - const animatedHand = Svg.hand_ui() - .SetStyle("width: 2rem; height: unset;") - .SetClass("hand-drag-animation block pointer-events-none") - return new Combine([ new Combine([ Svg.move_arrows_ui() @@ -328,10 +317,6 @@ export default class LocationInput "background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%; opacity: 0.5" ), - new Toggle(undefined, animatedHand, hasMoved) - .SetClass("block w-0 h-0 z-10 relative") - .SetStyle("left: calc(50% + 3rem); top: calc(50% + 2rem); opacity: 0.7"), - this.map.SetClass("z-0 relative block w-full h-full bg-gray-100"), ]).ConstructElement() } catch (e) { @@ -341,11 +326,4 @@ export default class LocationInput .ConstructElement() } } - - TakeScreenshot(format: "image"): Promise - TakeScreenshot(format: "blob"): Promise - TakeScreenshot(format: "image" | "blob"): Promise - TakeScreenshot(format: "image" | "blob"): Promise { - return this.map.TakeScreenshot(format) - } } diff --git a/UI/Input/README.md b/UI/Input/README.md new file mode 100644 index 000000000..459e6c856 --- /dev/null +++ b/UI/Input/README.md @@ -0,0 +1 @@ +This is the old, deprecated directory. New, SVelte-based items go into `InputElement` diff --git a/UI/Input/SimpleDatePicker.ts b/UI/Input/SimpleDatePicker.ts index 2c40242b9..860083082 100644 --- a/UI/Input/SimpleDatePicker.ts +++ b/UI/Input/SimpleDatePicker.ts @@ -2,7 +2,6 @@ import { InputElement } from "./InputElement" import { UIEventSource } from "../../Logic/UIEventSource" export default class SimpleDatePicker extends InputElement { - IsSelected: UIEventSource = new UIEventSource(false) private readonly value: UIEventSource private readonly _element: HTMLElement diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index 291906c2f..7b7dc5bd5 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -50,10 +50,6 @@ export class TextField extends InputElement { return this.value } - GetRawValue(): UIEventSource { - return this._rawValue - } - IsValid(t: string): boolean { if (t === undefined || t === null) { return false diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts deleted file mode 100644 index 4cc8864f6..000000000 --- a/UI/Input/ValidatedTextField.ts +++ /dev/null @@ -1,1008 +0,0 @@ -import { DropDown } from "./DropDown" -import * as EmailValidator from "email-validator" -import { parsePhoneNumberFromString } from "libphonenumber-js" -import { InputElement } from "./InputElement" -import { TextField } from "./TextField" -import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" -import CombinedInputElement from "./CombinedInputElement" -import SimpleDatePicker from "./SimpleDatePicker" -import OpeningHoursInput from "../OpeningHours/OpeningHoursInput" -import DirectionInput from "./DirectionInput" -import ColorPicker from "./ColorPicker" -import { Utils } from "../../Utils" -import Loc from "../../Models/Loc" -import BaseUIElement from "../BaseUIElement" -import LengthInput from "./LengthInput" -import { GeoOperations } from "../../Logic/GeoOperations" -import { Unit } from "../../Models/Unit" -import { FixedInputElement } from "./FixedInputElement" -import WikidataSearchBox from "../Wikipedia/WikidataSearchBox" -import Wikidata from "../../Logic/Web/Wikidata" -import Table from "../Base/Table" -import Combine from "../Base/Combine" -import Title from "../Base/Title" -import InputElementMap from "./InputElementMap" -import Translations from "../i18n/Translations" -import { Translation } from "../i18n/Translation" -import Locale from "../i18n/Locale" -import { RasterLayerPolygon } from "../../Models/RasterLayers" - -export class TextFieldDef { - public readonly name: string - /* - * An explanation for the theme builder. - * This can indicate which special input element is used, ... - * */ - public readonly explanation: string - protected inputmode?: string = undefined - - constructor(name: string, explanation: string | BaseUIElement) { - this.name = name - if (this.name.endsWith("textfield")) { - this.name = this.name.substr(0, this.name.length - "TextField".length) - } - if (this.name.endsWith("textfielddef")) { - this.name = this.name.substr(0, this.name.length - "TextFieldDef".length) - } - if (typeof explanation === "string") { - this.explanation = explanation - } else { - this.explanation = explanation.AsMarkdown() - } - } - - public getFeedback(s: string): Translation { - const tr = Translations.t.validation[this.name] - if (tr !== undefined) { - return tr["feedback"] - } - } - - public ConstructInputElement( - options: { - value?: UIEventSource - inputStyle?: string - feedback?: UIEventSource - placeholder?: string | Translation | UIEventSource - country?: () => string - location?: [number /*lat*/, number /*lon*/] - mapBackgroundLayer?: UIEventSource - unit?: Unit - args?: (string | number | boolean | any)[] // Extra arguments for the inputHelper, - feature?: any - } = {} - ): InputElement { - if (options.placeholder === undefined) { - options.placeholder = Translations.t.validation[this.name]?.description ?? this.name - } - - options["textArea"] = this.name === "text" - if (this.name === "text") { - options["htmlType"] = "area" - } - - const self = this - - if (options.unit !== undefined) { - // Reformatting is handled by the unit in this case - options["isValid"] = (str) => { - const denom = options.unit.findDenomination(str, options?.country) - if (denom === undefined) { - return false - } - const stripped = denom[0] - return self.isValid(stripped, options.country) - } - } else { - options["isValid"] = (str) => self.isValid(str, options.country) - } - options["cssText"] = "width: 100%;" - - options["inputMode"] = this.inputmode - if (this.inputmode === "text") { - options["htmlType"] = "area" - options["textAreaRows"] = 4 - } - - const textfield = new TextField(options) - let input: InputElement = textfield - if (options.feedback) { - textfield.GetRawValue().addCallback((v) => { - if (self.isValid(v, options.country)) { - options.feedback.setData(undefined) - } else { - options.feedback.setData(self.getFeedback(v)) - } - }) - } - - if (this.reformat && options.unit === undefined) { - input.GetValue().addCallbackAndRun((str) => { - if (!options["isValid"](str, options.country)) { - return - } - const formatted = this.reformat(str, options.country) - input.GetValue().setData(formatted) - }) - } - - if (options.unit) { - // We need to apply a unit. - // This implies: - // We have to create a dropdown with applicable denominations, and fuse those values - const unit = options.unit - - const isSingular = input.GetValue().map((str) => str?.trim() === "1") - - const unitDropDown = - unit.denominations.length === 1 - ? new FixedInputElement( - unit.denominations[0].getToggledHuman(isSingular), - unit.denominations[0] - ) - : new DropDown( - "", - unit.denominations.map((denom) => { - return { - shown: denom.getToggledHuman(isSingular), - value: denom, - } - }) - ) - unitDropDown.GetValue().setData(unit.getDefaultInput(options.country)) - unitDropDown.SetClass("w-min") - - const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined - input = new CombinedInputElement( - input, - unitDropDown, - // combine the value from the textfield and the dropdown into the resulting value that should go into OSM - (text, denom) => { - if (denom === undefined) { - return text - } - return denom?.canonicalValue(text, true) - }, - (valueWithDenom: string) => { - // Take the value from OSM and feed it into the textfield and the dropdown - const withDenom = unit.findDenomination(valueWithDenom, options?.country) - if (withDenom === undefined) { - // Not a valid value at all - we give it undefined and leave the details up to the other elements (but we keep the previous denomination) - return [undefined, fixedDenom] - } - const [strippedText, denom] = withDenom - if (strippedText === undefined) { - return [undefined, fixedDenom] - } - return [strippedText, denom] - } - ).SetClass("flex") - } - const helper = this.inputHelper(input.GetValue(), { - location: options.location, - mapBackgroundLayer: options.mapBackgroundLayer, - args: options.args, - feature: options.feature, - })?.SetClass("block") - if (helper !== undefined) { - input = new CombinedInputElement( - input, - helper, - (a, _) => a, // We can ignore b, as they are linked earlier - (a) => [a, a] - ).SetClass("block w-full") - } - if (this.postprocess !== undefined) { - input = new InputElementMap( - input, - (a, b) => a === b, - this.postprocess, - this.undoPostprocess - ) - } - - return input - } - - protected isValid(string: string, requestCountry: () => string): boolean { - return true - } - - protected reformat(s: string, country?: () => string): string { - return s - } - - /** - * Modification to make before the string is uploaded to OSM - */ - protected postprocess(s: string): string { - return s - } - - protected undoPostprocess(s: string): string { - return s - } - - protected inputHelper( - value: UIEventSource, - options?: { - location: [number, number] - mapBackgroundLayer?: UIEventSource - args: (string | number | boolean | any)[] - feature?: any - } - ): InputElement { - return undefined - } -} - -class WikidataTextField extends TextFieldDef { - 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] - }] - } -\`\`\` -`, - ]) - ) - } - - public isValid(str): boolean { - if (str === undefined) { - return false - } - if (str.length <= 2) { - return false - } - return !str.split(";").some((str) => Wikidata.ExtractKey(str) === undefined) - } - - public reformat(str) { - if (str === undefined) { - return undefined - } - let out = str - .split(";") - .map((str) => Wikidata.ExtractKey(str)) - .join("; ") - if (str.endsWith(";")) { - out = out + ";" - } - return out - } - - public inputHelper(currentValue, inputHelperOptions) { - 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: currentValue, - searchText: searchForValue, - instanceOf, - notInstanceOf, - }) - } -} - -class OpeningHoursTextField extends TextFieldDef { - constructor() { - super( - "opening_hours", - new Combine([ - "Has extra elements to easily input when a POI is opened.", - new Title("Helper arguments"), - new Table( - ["name", "doc"], - [ - [ - "options", - new Combine([ - "A JSON-object of type `{ prefix: string, postfix: string }`. ", - new Table( - ["subarg", "doc"], - [ - [ - "prefix", - "Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse.", - ], - [ - "postfix", - "Piece of text that will always be added to the end of the generated opening hours", - ], - ] - ), - ]), - ], - ] - ), - new Title("Example usage"), - "To add a conditional (based on time) access restriction:\n\n```\n" + - ` -"freeform": { - "key": "access:conditional", - "type": "opening_hours", - "helperArgs": [ - { - "prefix":"no @ (", - "postfix":")" - } - ] -}` + - "\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`", - ]) - ) - } - - isValid() { - return true - } - - reformat(str) { - return str - } - - inputHelper( - value: UIEventSource, - inputHelperOptions: { - location: [number, number] - mapBackgroundLayer?: UIEventSource - args: (string | number | boolean | any)[] - feature?: any - } - ) { - const args = (inputHelperOptions.args ?? [])[0] - const prefix = args?.prefix ?? "" - const postfix = args?.postfix ?? "" - - return new OpeningHoursInput(value, prefix, postfix) - } -} - -class UrlTextfieldDef extends TextFieldDef { - declare inputmode: "url" - - constructor() { - super( - "url", - "The validatedTextField will format URLs to always be valid and have a https://-header (even though the 'https'-part will be hidden from the user" - ) - } - - postprocess(str: string) { - if (str === undefined) { - return undefined - } - if (!str.startsWith("http://") || !str.startsWith("https://")) { - return "https://" + str - } - return str - } - - undoPostprocess(str: string) { - if (str === undefined) { - return undefined - } - if (str.startsWith("http://")) { - return str.substr("http://".length) - } - if (str.startsWith("https://")) { - return str.substr("https://".length) - } - return str - } - - reformat(str: string): string { - try { - let url: URL - // str = str.toLowerCase() // URLS are case sensitive. Lowercasing them might break some URLS. See #763 - if ( - !str.startsWith("http://") && - !str.startsWith("https://") && - !str.startsWith("http:") - ) { - url = new URL("https://" + str) - } else { - url = new URL(str) - } - const blacklistedTrackingParams = [ - "fbclid", // Oh god, how I hate the fbclid. Let it burn, burn in hell! - "gclid", - "cmpid", - "agid", - "utm", - "utm_source", - "utm_medium", - "campaignid", - "campaign", - "AdGroupId", - "AdGroup", - "TargetId", - "msclkid", - ] - for (const dontLike of blacklistedTrackingParams) { - url.searchParams.delete(dontLike.toLowerCase()) - } - let cleaned = url.toString() - if (cleaned.endsWith("/") && !str.endsWith("/")) { - // Do not add a trailing '/' if it wasn't typed originally - cleaned = cleaned.substr(0, cleaned.length - 1) - } - - if (cleaned.startsWith("https://")) { - cleaned = cleaned.substr("https://".length) - } - - return cleaned - } catch (e) { - console.error(e) - return undefined - } - } - - isValid(str: string): boolean { - try { - if ( - !str.startsWith("http://") && - !str.startsWith("https://") && - !str.startsWith("http:") - ) { - str = "https://" + str - } - const url = new URL(str) - const dotIndex = url.host.indexOf(".") - return dotIndex > 0 && url.host[url.host.length - 1] !== "." - } catch (e) { - return false - } - } -} - -class StringTextField extends TextFieldDef { - constructor() { - super("string", "A simple piece of text") - } -} - -class TextTextField extends TextFieldDef { - declare inputmode: "text" - constructor() { - super("text", "A longer piece of text. Uses an textArea instead of a textField") - } -} - -class DateTextField extends TextFieldDef { - constructor() { - super("date", "A date with date picker") - } - - isValid = (str) => { - return !isNaN(new Date(str).getTime()) - } - - reformat(str) { - const d = new Date(str) - let month = "" + (d.getMonth() + 1) - let day = "" + d.getDate() - const year = d.getFullYear() - - if (month.length < 2) month = "0" + month - if (day.length < 2) day = "0" + day - - return [year, month, day].join("-") - } - - inputHelper(value) { - return new SimpleDatePicker(value) - } -} - -class LengthTextField extends TextFieldDef { - inputMode: "decimal" - - constructor() { - super( - "distance", - 'A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]' - ) - } - - isValid = (str) => { - const t = Number(str) - return !isNaN(t) - } - - inputHelper = ( - value: UIEventSource, - options: { - location?: [number, number] - args?: string[] - feature?: any - mapBackgroundLayer?: Store - } - ) => { - options = options ?? {} - options.location = options.location ?? [0, 0] - - const args = options.args ?? [] - let zoom = 19 - if (args[0]) { - zoom = Number(args[0]) - if (isNaN(zoom)) { - console.error( - "Invalid zoom level for argument at 'length'-input. The offending argument is: ", - args[0], - " (using 19 instead)" - ) - zoom = 19 - } - } - - // Bit of a hack: we project the centerpoint to the closes point on the road - if available - if (options?.feature !== undefined && options.feature.geometry.type !== "Point") { - const lonlat = <[number, number]>[...options.location] - lonlat.reverse(/*Changes a clone, this is safe */) - options.location = <[number, number]>( - GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates - ) - options.location.reverse(/*Changes a clone, this is safe */) - } - - const location = new UIEventSource({ - lat: options.location[0], - lon: options.location[1], - zoom: zoom, - }) - if (args[1]) { - // The arguments indicate the preferred background type - options.mapBackgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo( - location, - new ImmutableStore(args[1].split(",")) - ) - } - const background = options?.mapBackgroundLayer - const li = new LengthInput( - new UIEventSource(background.data), - location, - value - ) - li.SetStyle("height: 20rem;") - return li - } -} - -class FloatTextField extends TextFieldDef { - inputmode = "decimal" - - constructor(name?: string, explanation?: string) { - super(name ?? "float", explanation ?? "A decimal") - } - - isValid(str) { - return !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",") - } - - reformat(str): string { - return "" + Number(str) - } - - getFeedback(s: string): Translation { - if (isNaN(Number(s))) { - return Translations.t.validation.nat.notANumber - } - - return undefined - } -} - -class IntTextField extends FloatTextField { - inputMode = "numeric" - - constructor(name?: string, explanation?: string) { - super(name ?? "int", explanation ?? "A number") - } - - isValid(str): boolean { - str = "" + str - return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) - } - - getFeedback(s: string): Translation { - const n = Number(s) - if (isNaN(n)) { - return Translations.t.validation.nat.notANumber - } - if (Math.floor(n) !== n) { - return Translations.t.validation.nat.mustBeWhole - } - return undefined - } -} - -class NatTextField extends IntTextField { - inputMode = "numeric" - - constructor(name?: string, explanation?: string) { - super(name ?? "nat", explanation ?? "A positive number or zero") - } - - isValid(str): boolean { - if (str === undefined) { - return false - } - str = "" + str - - return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 - } - - getFeedback(s: string): Translation { - const spr = super.getFeedback(s) - if (spr !== undefined) { - return spr - } - const n = Number(s) - if (n < 0) { - return Translations.t.validation.nat.mustBePositive - } - return undefined - } -} - -class PNatTextField extends NatTextField { - inputmode = "numeric" - - constructor() { - super("pnat", "A strict positive number") - } - - getFeedback(s: string): Translation { - const spr = super.getFeedback(s) - if (spr !== undefined) { - return spr - } - if (Number(s) === 0) { - return Translations.t.validation.pnat.noZero - } - return undefined - } - - isValid = (str) => { - if (!super.isValid(str)) { - return false - } - return Number(str) > 0 - } -} - -class PFloatTextField extends FloatTextField { - inputmode = "decimal" - - constructor() { - super("pfloat", "A positive decimal (inclusive zero)") - } - - isValid = (str) => - !isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",") - - getFeedback(s: string): Translation { - const spr = super.getFeedback(s) - if (spr !== undefined) { - return spr - } - if (Number(s) < 0) { - return Translations.t.validation.nat.mustBePositive - } - return undefined - } -} - -class EmailTextField extends TextFieldDef { - inputmode = "email" - - constructor() { - super("email", "An email adress") - } - - isValid = (str) => { - if (str === undefined) { - return false - } - str = str.trim() - if (str.startsWith("mailto:")) { - str = str.substring("mailto:".length) - } - return EmailValidator.validate(str) - } - - reformat = (str) => { - if (str === undefined) { - return undefined - } - str = str.trim() - if (str.startsWith("mailto:")) { - str = str.substring("mailto:".length) - } - return str - } - - getFeedback(s: string): Translation { - if (s.indexOf("@") < 0) { - return Translations.t.validation.email.noAt - } - - return super.getFeedback(s) - } -} - -class PhoneTextField extends TextFieldDef { - inputmode = "tel" - - constructor() { - super("phone", "A phone number") - } - - isValid(str, country: () => string): boolean { - if (str === undefined) { - return false - } - if (str.startsWith("tel:")) { - str = str.substring("tel:".length) - } - let countryCode = undefined - if (country !== undefined) { - countryCode = country()?.toUpperCase() - } - return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false - } - - reformat = (str, country: () => string) => { - if (str.startsWith("tel:")) { - str = str.substring("tel:".length) - } - return parsePhoneNumberFromString( - str, - country()?.toUpperCase() as any - )?.formatInternational() - } -} - -class ColorTextField extends TextFieldDef { - constructor() { - super("color", "Shows a color picker") - } - - inputHelper = (value) => { - return new ColorPicker( - value.map( - (color) => { - return Utils.ColourNameToHex(color ?? "") - }, - [], - (str) => Utils.HexToColourName(str) - ) - ) - } -} - -class DirectionTextField extends IntTextField { - inputMode = "numeric" - - constructor() { - super( - "direction", - "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)" - ) - } - - reformat(str): string { - const n = Number(str) % 360 - return "" + n - } - - inputHelper = (value, options) => { - const args = options.args ?? [] - options.location = options.location ?? [0, 0] - let zoom = 19 - if (args[0]) { - zoom = Number(args[0]) - if (isNaN(zoom)) { - throw "Invalid zoom level for argument at 'length'-input" - } - } - const location = new UIEventSource({ - lat: options.location[0], - lon: options.location[1], - zoom: zoom, - }) - if (args[1]) { - // We have a prefered map! - options.mapBackgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo( - location, - new UIEventSource(args[1].split(",")) - ) - } - const di = new DirectionInput(options.mapBackgroundLayer, location, value) - di.SetStyle("max-width: 25rem;") - - return di - } -} - -export default class ValidatedTextField { - private static AllTextfieldDefs: TextFieldDef[] = [ - new StringTextField(), - new TextTextField(), - new DateTextField(), - new NatTextField(), - new IntTextField(), - new LengthTextField(), - new DirectionTextField(), - new WikidataTextField(), - new PNatTextField(), - new FloatTextField(), - new PFloatTextField(), - new EmailTextField(), - new UrlTextfieldDef(), - new PhoneTextField(), - new OpeningHoursTextField(), - new ColorTextField(), - ] - public static allTypes: Map = ValidatedTextField.allTypesDict() - public static ForType(type: string = "string"): TextFieldDef { - const def = ValidatedTextField.allTypes.get(type) - if (def === undefined) { - console.warn( - "Something tried to load a validated text field named", - type, - "but this type does not exist" - ) - return this.ForType() - } - return def - } - - public static HelpText(): BaseUIElement { - const explanations: BaseUIElement[] = ValidatedTextField.AllTextfieldDefs.map((type) => - new Combine([new Title(type.name, 3), type.explanation]).SetClass("flex flex-col") - ) - return new Combine([ - new Title("Available types for text fields", 1), - "The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them", - ...explanations, - ]).SetClass("flex flex-col") - } - - public static AvailableTypes(): string[] { - return ValidatedTextField.AllTextfieldDefs.map((tp) => tp.name) - } - - private static allTypesDict(): Map { - const types = new Map() - for (const tp of ValidatedTextField.AllTextfieldDefs) { - types[tp.name] = tp - types.set(tp.name, tp) - } - return types - } -} diff --git a/UI/InputElement/Helpers/DirectionInput.svelte b/UI/InputElement/Helpers/DirectionInput.svelte new file mode 100644 index 000000000..cf63be378 --- /dev/null +++ b/UI/InputElement/Helpers/DirectionInput.svelte @@ -0,0 +1,70 @@ + + +
onPosChange(e.x, e.y)} + on:mousedown={e => { + isDown = true + onPosChange(e.clientX, e.clientY) + } } + on:mousemove={e => { + if(isDown){ + onPosChange(e.clientX, e.clientY) + + }}} + + on:mouseup={() => { + isDown = false + } } + on:touchmove={e => onPosChange(e.touches[0].clientX, e.touches[0].clientY)} + + + on:touchstart={e => onPosChange(e.touches[0].clientX, e.touches[0].clientY)}> +
+ +
+ +
+ + + + +
+
diff --git a/UI/InputElement/Helpers/LocationInput.svelte b/UI/InputElement/Helpers/LocationInput.svelte new file mode 100644 index 000000000..c6767293b --- /dev/null +++ b/UI/InputElement/Helpers/LocationInput.svelte @@ -0,0 +1,42 @@ + + +
+
+ +
+ +
+ Svg.move_arrows_svg().SetClass("h-full")}> +
+ + + +
diff --git a/UI/InputElement/InputHelper.svelte b/UI/InputElement/InputHelper.svelte new file mode 100644 index 000000000..e64305a67 --- /dev/null +++ b/UI/InputElement/InputHelper.svelte @@ -0,0 +1,13 @@ + diff --git a/UI/InputElement/InputHelpers.ts b/UI/InputElement/InputHelpers.ts new file mode 100644 index 000000000..ff6c14f8b --- /dev/null +++ b/UI/InputElement/InputHelpers.ts @@ -0,0 +1,16 @@ +import { AvailableRasterLayers } from "../../Models/RasterLayers" + +export type AvailableInputHelperType = typeof InputHelpers.AvailableInputHelpers[number] + +export default class InputHelpers { + public static readonly AvailableInputHelpers = [] as const + /** + * To port + * direction + * opening_hours + * color + * length + * date + * wikidata + */ +} diff --git a/UI/InputElement/ValidatedTextField.ts b/UI/InputElement/ValidatedTextField.ts new file mode 100644 index 000000000..3ed4946e0 --- /dev/null +++ b/UI/InputElement/ValidatedTextField.ts @@ -0,0 +1,119 @@ +import BaseUIElement from "../BaseUIElement" +import Combine from "../Base/Combine" +import Title from "../Base/Title" +import Translations from "../i18n/Translations" +import { Translation } from "../i18n/Translation" +import WikidataValidator from "./Validators/WikidataValidator" +import StringValidator from "./Validators/StringValidator" +import TextValidator from "./Validators/TextValidator" +import DateValidator from "./Validators/DateValidator" +import LengthValidator from "./Validators/LengthValidator" +import IntValidator from "./Validators/IntValidator" +import EmailValidator from "./Validators/EmailValidator" +import DirectionValidator from "./Validators/DirectionValidator" +import NatValidator from "./Validators/NatValidator" +import OpeningHoursValidator from "./Validators/OpeningHoursValidator" +import PFloatValidator from "./Validators/PFloatValidator" +import ColorValidator from "./Validators/ColorValidator" +import PhoneValidator from "./Validators/PhoneValidator" +import UrlValidator from "./Validators/UrlValidator" +import FloatValidator from "./Validators/FloatValidator" +import PNatValidator from "./Validators/PNatValidator" + +/** + * A 'TextFieldValidator' contains various methods to check and cleanup an entered value or to give feedback. + * They also double as an index of supported types for textfields in MapComplete + */ +export abstract class Validator { + public readonly name: string + /* + * An explanation for the theme builder. + * This can indicate which special input element is used, ... + * */ + public readonly explanation: string + /** + * What HTML-inputmode to use + */ + public readonly inputmode?: string + + constructor(name: string, explanation: string | BaseUIElement, inputmode?: string) { + this.name = name + this.inputmode = inputmode + if (this.name.endsWith("textfield")) { + this.name = this.name.substr(0, this.name.length - "TextField".length) + } + if (this.name.endsWith("textfielddef")) { + this.name = this.name.substr(0, this.name.length - "TextFieldDef".length) + } + if (typeof explanation === "string") { + this.explanation = explanation + } else { + this.explanation = explanation.AsMarkdown() + } + } + + /** + * Gets a piece of feedback. By default, validation. will be used, resulting in a generic 'not a valid '. + * However, inheritors might overwrite this to give more specific feedback + * @param s + */ + public getFeedback(s: string): Translation { + const tr = Translations.t.validation[this.name] + if (tr !== undefined) { + return tr["feedback"] + } + } + + public isValid(string: string, requestCountry: () => string): boolean { + return true + } + + public reformat(s: string, country?: () => string): string { + return s + } +} + +export default class Validators { + private static readonly AllValidators: ReadonlyArray = [ + new StringValidator(), + new TextValidator(), + new DateValidator(), + new NatValidator(), + new IntValidator(), + new LengthValidator(), + new DirectionValidator(), + new WikidataValidator(), + new PNatValidator(), + new FloatValidator(), + new PFloatValidator(), + new EmailValidator(), + new UrlValidator(), + new PhoneValidator(), + new OpeningHoursValidator(), + new ColorValidator(), + ] + public static allTypes: Map = Validators.allTypesDict() + + public static HelpText(): BaseUIElement { + const explanations: BaseUIElement[] = Validators.AllValidators.map((type) => + new Combine([new Title(type.name, 3), type.explanation]).SetClass("flex flex-col") + ) + return new Combine([ + new Title("Available types for text fields", 1), + "The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them", + ...explanations, + ]).SetClass("flex flex-col") + } + + public static AvailableTypes(): string[] { + return Validators.AllValidators.map((tp) => tp.name) + } + + private static allTypesDict(): Map { + const types = new Map() + for (const tp of Validators.AllValidators) { + types.set(tp.name, tp) + } + return types + } +} diff --git a/UI/InputElement/Validators/ColorValidator.ts b/UI/InputElement/Validators/ColorValidator.ts new file mode 100644 index 000000000..2022edecb --- /dev/null +++ b/UI/InputElement/Validators/ColorValidator.ts @@ -0,0 +1,7 @@ +import { Validator } from "../ValidatedTextField" + +export default class ColorValidator extends Validator { + constructor() { + super("color", "Shows a color picker") + } +} diff --git a/UI/InputElement/Validators/DateValidator.ts b/UI/InputElement/Validators/DateValidator.ts new file mode 100644 index 000000000..1ebbd67bc --- /dev/null +++ b/UI/InputElement/Validators/DateValidator.ts @@ -0,0 +1,23 @@ +import { Validator } from "../ValidatedTextField" + +export default class DateValidator extends Validator { + constructor() { + super("date", "A date with date picker") + } + + isValid(str: string): boolean { + return !isNaN(new Date(str).getTime()) + } + + reformat(str: string) { + const d = new Date(str) + let month = "" + (d.getMonth() + 1) + let day = "" + d.getDate() + const year = d.getFullYear() + + if (month.length < 2) month = "0" + month + if (day.length < 2) day = "0" + day + + return [year, month, day].join("-") + } +} diff --git a/UI/InputElement/Validators/DirectionValidator.ts b/UI/InputElement/Validators/DirectionValidator.ts new file mode 100644 index 000000000..582edf58a --- /dev/null +++ b/UI/InputElement/Validators/DirectionValidator.ts @@ -0,0 +1,17 @@ +import { Validator } from "../ValidatedTextField" +import IntValidator from "./IntValidator"; + +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)" + ) + } + + reformat(str): string { + const n = Number(str) % 360 + return "" + n + } + +} diff --git a/UI/InputElement/Validators/EmailValidator.ts b/UI/InputElement/Validators/EmailValidator.ts new file mode 100644 index 000000000..8702bdff8 --- /dev/null +++ b/UI/InputElement/Validators/EmailValidator.ts @@ -0,0 +1,39 @@ +import { Validator } from "../ValidatedTextField.js" +import { Translation } from "../../i18n/Translation.js" +import Translations from "../../i18n/Translations.js" +import * as emailValidatorLibrary from "email-validator" +export default class EmailValidator extends Validator { + constructor() { + super("email", "An email adress", "email") + } + + isValid = (str) => { + if (str === undefined) { + return false + } + str = str.trim() + if (str.startsWith("mailto:")) { + str = str.substring("mailto:".length) + } + return emailValidatorLibrary.validate(str) + } + + reformat = (str) => { + if (str === undefined) { + return undefined + } + str = str.trim() + if (str.startsWith("mailto:")) { + str = str.substring("mailto:".length) + } + return str + } + + getFeedback(s: string): Translation { + if (s.indexOf("@") < 0) { + return Translations.t.validation.email.noAt + } + + return super.getFeedback(s) + } +} diff --git a/UI/InputElement/Validators/FloatValidator.ts b/UI/InputElement/Validators/FloatValidator.ts new file mode 100644 index 000000000..1bcf55404 --- /dev/null +++ b/UI/InputElement/Validators/FloatValidator.ts @@ -0,0 +1,27 @@ +import { Translation } from "../../i18n/Translation" +import Translations from "../../i18n/Translations" +import { Validator } from "../ValidatedTextField" + +export default class FloatValidator extends Validator { + inputmode = "decimal" + + constructor(name?: string, explanation?: string) { + super(name ?? "float", explanation ?? "A decimal number", "decimal") + } + + isValid(str) { + return !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",") + } + + reformat(str): string { + return "" + Number(str) + } + + getFeedback(s: string): Translation { + if (isNaN(Number(s))) { + return Translations.t.validation.nat.notANumber + } + + return undefined + } +} diff --git a/UI/InputElement/Validators/IntValidator.ts b/UI/InputElement/Validators/IntValidator.ts new file mode 100644 index 000000000..0b349cbe8 --- /dev/null +++ b/UI/InputElement/Validators/IntValidator.ts @@ -0,0 +1,29 @@ +import { Translation } from "../../i18n/Translation" +import Translations from "../../i18n/Translations" +import { Validator } from "../ValidatedTextField" + +export default class IntValidator extends Validator { + constructor(name?: string, explanation?: string) { + super( + name ?? "int", + explanation ?? "A whole number, either positive, negative or zero", + "numeric" + ) + } + + isValid(str): boolean { + str = "" + str + return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) + } + + getFeedback(s: string): Translation { + const n = Number(s) + if (isNaN(n)) { + return Translations.t.validation.nat.notANumber + } + if (Math.floor(n) !== n) { + return Translations.t.validation.nat.mustBeWhole + } + return undefined + } +} diff --git a/UI/InputElement/Validators/LengthValidator.ts b/UI/InputElement/Validators/LengthValidator.ts new file mode 100644 index 000000000..c9e879953 --- /dev/null +++ b/UI/InputElement/Validators/LengthValidator.ts @@ -0,0 +1,16 @@ +import { Validator } from "../ValidatedTextField" + +export default class LengthValidator extends Validator { + constructor() { + super( + "distance", + 'A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]', + "decimal" + ) + } + + isValid = (str) => { + const t = Number(str) + return !isNaN(t) + } +} diff --git a/UI/InputElement/Validators/NatValidator.ts b/UI/InputElement/Validators/NatValidator.ts new file mode 100644 index 000000000..fbc77758c --- /dev/null +++ b/UI/InputElement/Validators/NatValidator.ts @@ -0,0 +1,30 @@ +import IntValidator from "./IntValidator" +import { Translation } from "../../i18n/Translation" +import Translations from "../../i18n/Translations" + +export default class NatValidator extends IntValidator { + constructor(name?: string, explanation?: string) { + super(name ?? "nat", explanation ?? "A whole, positive number or zero") + } + + isValid(str): boolean { + if (str === undefined) { + return false + } + str = "" + str + + return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 + } + + getFeedback(s: string): Translation { + const spr = super.getFeedback(s) + if (spr !== undefined) { + return spr + } + const n = Number(s) + if (n < 0) { + return Translations.t.validation.nat.mustBePositive + } + return undefined + } +} diff --git a/UI/InputElement/Validators/OpeningHoursValidator.ts b/UI/InputElement/Validators/OpeningHoursValidator.ts new file mode 100644 index 000000000..85760df64 --- /dev/null +++ b/UI/InputElement/Validators/OpeningHoursValidator.ts @@ -0,0 +1,54 @@ +import { Validator } from "../ValidatedTextField" +import Combine from "../../Base/Combine" +import Title from "../../Base/Title" +import Table from "../../Base/Table" + +export default class OpeningHoursValidator extends Validator { + constructor() { + super( + "opening_hours", + new Combine([ + "Has extra elements to easily input when a POI is opened.", + new Title("Helper arguments"), + new Table( + ["name", "doc"], + [ + [ + "options", + new Combine([ + "A JSON-object of type `{ prefix: string, postfix: string }`. ", + new Table( + ["subarg", "doc"], + [ + [ + "prefix", + "Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse.", + ], + [ + "postfix", + "Piece of text that will always be added to the end of the generated opening hours", + ], + ] + ), + ]), + ], + ] + ), + new Title("Example usage"), + "To add a conditional (based on time) access restriction:\n\n```\n" + + ` +"freeform": { + "key": "access:conditional", + "type": "opening_hours", + "helperArgs": [ + { + "prefix":"no @ (", + "postfix":")" + } + ] +}` + + "\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`", + ]) + ) + } +} diff --git a/UI/InputElement/Validators/PFloatValidator.ts b/UI/InputElement/Validators/PFloatValidator.ts new file mode 100644 index 000000000..9501631a8 --- /dev/null +++ b/UI/InputElement/Validators/PFloatValidator.ts @@ -0,0 +1,23 @@ +import { Translation } from "../../i18n/Translation" +import Translations from "../../i18n/Translations" +import { Validator } from "../ValidatedTextField" + +export default class PFloatValidator extends Validator { + constructor() { + super("pfloat", "A positive decimal number or zero") + } + + isValid = (str) => + !isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",") + + getFeedback(s: string): Translation { + const spr = super.getFeedback(s) + if (spr !== undefined) { + return spr + } + if (Number(s) < 0) { + return Translations.t.validation.nat.mustBePositive + } + return undefined + } +} diff --git a/UI/InputElement/Validators/PNatValidator.ts b/UI/InputElement/Validators/PNatValidator.ts new file mode 100644 index 000000000..e1b024f63 --- /dev/null +++ b/UI/InputElement/Validators/PNatValidator.ts @@ -0,0 +1,27 @@ +import { Translation } from "../../i18n/Translation" +import Translations from "../../i18n/Translations" +import NatValidator from "./NatValidator" + +export default class PNatValidator extends NatValidator { + constructor() { + super("pnat", "A strict positive number") + } + + getFeedback(s: string): Translation { + const spr = super.getFeedback(s) + if (spr !== undefined) { + return spr + } + if (Number(s) === 0) { + return Translations.t.validation.pnat.noZero + } + return undefined + } + + isValid = (str) => { + if (!super.isValid(str)) { + return false + } + return Number(str) > 0 + } +} diff --git a/UI/InputElement/Validators/PhoneValidator.ts b/UI/InputElement/Validators/PhoneValidator.ts new file mode 100644 index 000000000..db4fa3d26 --- /dev/null +++ b/UI/InputElement/Validators/PhoneValidator.ts @@ -0,0 +1,32 @@ +import { Validator } from "../ValidatedTextField" +import { parsePhoneNumberFromString } from "libphonenumber-js" + +export default class PhoneValidator extends Validator { + constructor() { + super("phone", "A phone number", "tel") + } + + isValid(str, country: () => string): boolean { + if (str === undefined) { + return false + } + if (str.startsWith("tel:")) { + str = str.substring("tel:".length) + } + let countryCode = undefined + if (country !== undefined) { + countryCode = country()?.toUpperCase() + } + return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false + } + + reformat = (str, country: () => string) => { + if (str.startsWith("tel:")) { + str = str.substring("tel:".length) + } + return parsePhoneNumberFromString( + str, + country()?.toUpperCase() as any + )?.formatInternational() + } +} diff --git a/UI/InputElement/Validators/StringValidator.ts b/UI/InputElement/Validators/StringValidator.ts new file mode 100644 index 000000000..b05808680 --- /dev/null +++ b/UI/InputElement/Validators/StringValidator.ts @@ -0,0 +1,8 @@ +import { Validator } from "../ValidatedTextField" + +export default class StringValidator extends Validator { + constructor() { + super("string", "A simple piece of text") + } + +} diff --git a/UI/InputElement/Validators/TextValidator.ts b/UI/InputElement/Validators/TextValidator.ts new file mode 100644 index 000000000..cad3b997a --- /dev/null +++ b/UI/InputElement/Validators/TextValidator.ts @@ -0,0 +1,7 @@ +import { Validator } from "../ValidatedTextField" + +export default class TextValidator extends Validator { + constructor() { + super("text", "A longer piece of text. Uses an textArea instead of a textField", "text") + } +} diff --git a/UI/InputElement/Validators/UrlValidator.ts b/UI/InputElement/Validators/UrlValidator.ts new file mode 100644 index 000000000..16e9172f7 --- /dev/null +++ b/UI/InputElement/Validators/UrlValidator.ts @@ -0,0 +1,75 @@ +import { Validator } from "../ValidatedTextField" + +export default class UrlValidator extends Validator { + constructor() { + super( + "url", + "The validatedTextField will format URLs to always be valid and have a https://-header (even though the 'https'-part will be hidden from the user. Furthermore, some tracking parameters will be removed", + "url" + ) + } + reformat(str: string): string { + try { + let url: URL + // str = str.toLowerCase() // URLS are case sensitive. Lowercasing them might break some URLS. See #763 + if ( + !str.startsWith("http://") && + !str.startsWith("https://") && + !str.startsWith("http:") + ) { + url = new URL("https://" + str) + } else { + url = new URL(str) + } + const blacklistedTrackingParams = [ + "fbclid", // Oh god, how I hate the fbclid. Let it burn, burn in hell! + "gclid", + "cmpid", + "agid", + "utm", + "utm_source", + "utm_medium", + "campaignid", + "campaign", + "AdGroupId", + "AdGroup", + "TargetId", + "msclkid", + ] + for (const dontLike of blacklistedTrackingParams) { + url.searchParams.delete(dontLike.toLowerCase()) + } + let cleaned = url.toString() + if (cleaned.endsWith("/") && !str.endsWith("/")) { + // Do not add a trailing '/' if it wasn't typed originally + cleaned = cleaned.substr(0, cleaned.length - 1) + } + + if (!str.startsWith("http") && cleaned.startsWith("https://")) { + cleaned = cleaned.substr("https://".length) + } + + return cleaned + } catch (e) { + console.error(e) + return undefined + } + } + + isValid(str: string): boolean { + try { + if ( + !str.startsWith("http://") && + !str.startsWith("https://") && + !str.startsWith("http:") + ) { + str = "https://" + str + } + const url = new URL(str) + const dotIndex = url.host.indexOf(".") + return dotIndex > 0 && url.host[url.host.length - 1] !== "." + } catch (e) { + return false + } + } +} diff --git a/UI/InputElement/Validators/WikidataValidator.ts b/UI/InputElement/Validators/WikidataValidator.ts new file mode 100644 index 000000000..fa1588e37 --- /dev/null +++ b/UI/InputElement/Validators/WikidataValidator.ts @@ -0,0 +1,179 @@ +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" +import { Utils } from "../../../Utils" +import WikidataSearchBox from "../../Wikipedia/WikidataSearchBox" +import { Validator } from "../ValidatedTextField" + +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] + }] + } +\`\`\` +`, + ]) + ) + } + + public isValid(str): boolean { + if (str === undefined) { + return false + } + if (str.length <= 2) { + return false + } + return !str.split(";").some((str) => Wikidata.ExtractKey(str) === undefined) + } + + public reformat(str) { + if (str === undefined) { + return undefined + } + let out = str + .split(";") + .map((str) => Wikidata.ExtractKey(str)) + .join("; ") + if (str.endsWith(";")) { + out = out + ";" + } + return out + } + + public inputHelper(currentValue, inputHelperOptions) { + 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: currentValue, + searchText: searchForValue, + instanceOf, + notInstanceOf, + }) + } +} diff --git a/UI/Map/MapLibreAdaptor.ts b/UI/Map/MapLibreAdaptor.ts index f3232168b..f6c5c95c6 100644 --- a/UI/Map/MapLibreAdaptor.ts +++ b/UI/Map/MapLibreAdaptor.ts @@ -43,7 +43,7 @@ export class MapLibreAdaptor implements MapProperties { */ private _currentRasterLayer: string - constructor(maplibreMap: Store, state?: Partial>) { + constructor(maplibreMap: Store, state?: Partial) { this._maplibreMap = maplibreMap this.location = state?.location ?? new UIEventSource({ lon: 0, lat: 0 }) diff --git a/UI/Map/MaplibreMap.svelte b/UI/Map/MaplibreMap.svelte index dcd1a1cd6..7ebaa56b2 100644 --- a/UI/Map/MaplibreMap.svelte +++ b/UI/Map/MaplibreMap.svelte @@ -16,6 +16,7 @@ */ export let map: Writable + export let attribution = true let center = {}; onMount(() => { @@ -28,6 +29,9 @@
diff --git a/UI/Map/ShowDataLayer.ts b/UI/Map/ShowDataLayer.ts index 3032a4eff..6724680c2 100644 --- a/UI/Map/ShowDataLayer.ts +++ b/UI/Map/ShowDataLayer.ts @@ -106,7 +106,7 @@ class PointRenderingLayer { store = new ImmutableStore(feature.properties) } const { html, iconAnchor } = this._config.RenderIcon(store, true) - html.SetClass("marker") + html.SetClass("marker cursor-pointer") const el = html.ConstructElement() if (this._onClick) { @@ -244,7 +244,7 @@ class LineRenderingLayer { }, }) - this._visibility.addCallbackAndRunD((visible) => { + this._visibility?.addCallbackAndRunD((visible) => { map.setLayoutProperty(linelayer, "visibility", visible ? "visible" : "none") map.setLayoutProperty(polylayer, "visibility", visible ? "visible" : "none") }) diff --git a/UI/Map/ShowDataLayerOptions.ts b/UI/Map/ShowDataLayerOptions.ts index c20960a72..b81175d90 100644 --- a/UI/Map/ShowDataLayerOptions.ts +++ b/UI/Map/ShowDataLayerOptions.ts @@ -1,6 +1,5 @@ import FeatureSource from "../../Logic/FeatureSource/FeatureSource" import { Store, UIEventSource } from "../../Logic/UIEventSource" -import { OsmTags } from "../../Models/OsmFeature" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import { Feature } from "geojson" diff --git a/UI/Popup/HistogramViz.ts b/UI/Popup/HistogramViz.ts index d83d1dcf6..fffc451d4 100644 --- a/UI/Popup/HistogramViz.ts +++ b/UI/Popup/HistogramViz.ts @@ -1,12 +1,13 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" import Histogram from "../BigComponents/Histogram" +import { Feature } from "geojson" export class HistogramViz implements SpecialVisualization { funcName = "histogram" docs = "Create a histogram for a list of given values, read from the properties." example = - "`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram" + '`{histogram(\'some_key\')}` with properties being `{some_key: ["a","b","a","c"]} to create a histogram' args = [ { name: "key", @@ -29,6 +30,22 @@ export class HistogramViz implements SpecialVisualization { }, ] + structuredExamples(): { feature: Feature; args: string[] }[] { + return [ + { + feature: { + type: "Feature", + properties: { values: `["a","b","a","b","b","b","c","c","c","d","d"]` }, + geometry: { + type: "Point", + coordinates: [0, 0], + }, + }, + args: ["values"], + }, + ] + } + constr( state: SpecialVisualizationState, tagSource: UIEventSource>, diff --git a/UI/Popup/MinimapViz.ts b/UI/Popup/MinimapViz.ts index ccf061f68..6daa6e97a 100644 --- a/UI/Popup/MinimapViz.ts +++ b/UI/Popup/MinimapViz.ts @@ -5,10 +5,7 @@ import { Feature } from "geojson" import { MapLibreAdaptor } from "../Map/MapLibreAdaptor" import SvelteUIElement from "../Base/SvelteUIElement" import MaplibreMap from "../Map/MaplibreMap.svelte" -import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" -import FilteredLayer from "../../Models/FilteredLayer" import ShowDataLayer from "../Map/ShowDataLayer" -import { stat } from "fs" export class MinimapViz implements SpecialVisualization { funcName = "minimap" diff --git a/UI/Popup/MoveWizard.ts b/UI/Popup/MoveWizard.ts index d643b799e..ee93d78f9 100644 --- a/UI/Popup/MoveWizard.ts +++ b/UI/Popup/MoveWizard.ts @@ -54,11 +54,6 @@ export default class MoveWizard extends Toggle { options: MoveConfig ) { const t = Translations.t.move - const loginButton = new Toggle( - t.loginToMove.SetClass("btn").onClick(() => state.osmConnection.AttemptLogin()), - undefined, - state.featureSwitchUserbadge - ) const reasons: MoveReason[] = [] if (options.enableRelocation) { diff --git a/UI/Popup/SpecialTranslation.svelte b/UI/Popup/SpecialTranslation.svelte new file mode 100644 index 000000000..cb0a6674d --- /dev/null +++ b/UI/Popup/SpecialTranslation.svelte @@ -0,0 +1,34 @@ + + +{#each specs as specpart} + {#if typeof specpart === "string"} + + {:else if $tags !== undefined } + + {/if} +{/each} diff --git a/UI/Popup/TagRenderingAnswer.svelte b/UI/Popup/TagRenderingAnswer.svelte index e69de29bb..c25cc206c 100644 --- a/UI/Popup/TagRenderingAnswer.svelte +++ b/UI/Popup/TagRenderingAnswer.svelte @@ -0,0 +1,34 @@ + + +{#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} diff --git a/UI/Popup/TagRenderingAnswer.ts b/UI/Popup/TagRenderingAnswer.ts index 338be0c07..75ef9fd84 100644 --- a/UI/Popup/TagRenderingAnswer.ts +++ b/UI/Popup/TagRenderingAnswer.ts @@ -6,7 +6,7 @@ import { SubstitutedTranslation } from "../SubstitutedTranslation" import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig" import Combine from "../Base/Combine" import Img from "../Base/Img" -import { SpecialVisualisationState } from "../SpecialVisualization" +import { SpecialVisualizationState } from "../SpecialVisualization" /*** * Displays the correct value for a known tagrendering @@ -15,7 +15,7 @@ export default class TagRenderingAnswer extends VariableUiElement { constructor( tagsSource: UIEventSource, configuration: TagRenderingConfig, - state: SpecialVisualisationState, + state: SpecialVisualizationState, contentClasses: string = "", contentStyle: string = "", options?: { diff --git a/UI/Popup/TagRenderingMapping.svelte b/UI/Popup/TagRenderingMapping.svelte new file mode 100644 index 000000000..c7e496633 --- /dev/null +++ b/UI/Popup/TagRenderingMapping.svelte @@ -0,0 +1,32 @@ + + +{#if mapping.icon !== undefined} +
+ + +
+{:else if mapping.then !== undefined} + +{/if} + diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 2732f2105..72c1f80da 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -1,7 +1,6 @@ import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource" import Combine from "../Base/Combine" import { InputElement, ReadonlyInputElement } from "../Input/InputElement" -import ValidatedTextField from "../Input/ValidatedTextField" import { FixedInputElement } from "../Input/FixedInputElement" import { RadioButton } from "../Input/RadioButton" import { Utils } from "../../Utils" diff --git a/UI/Reviews/SingleReview.ts b/UI/Reviews/SingleReview.ts index 5e1e050d4..b1b1c89af 100644 --- a/UI/Reviews/SingleReview.ts +++ b/UI/Reviews/SingleReview.ts @@ -6,7 +6,6 @@ import BaseUIElement from "../BaseUIElement" import Img from "../Base/Img" import { Review } from "mangrove-reviews-typescript" import { Store } from "../../Logic/UIEventSource" -import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox" export default class SingleReview extends Combine { constructor(review: Review & { madeByLoggedInUser: Store }) { diff --git a/UI/SpecialVisualization.ts b/UI/SpecialVisualization.ts index 4e46c0008..728f51d33 100644 --- a/UI/SpecialVisualization.ts +++ b/UI/SpecialVisualization.ts @@ -2,17 +2,13 @@ import { Store, UIEventSource } from "../Logic/UIEventSource" import BaseUIElement from "./BaseUIElement" import { DefaultGuiState } from "./DefaultGuiState" import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" -import FeatureSource, { - IndexedFeatureSource, - WritableFeatureSource, -} from "../Logic/FeatureSource/FeatureSource" +import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource" import { OsmConnection } from "../Logic/Osm/OsmConnection" import { Changes } from "../Logic/Osm/Changes" import { MapProperties } from "../Models/MapProperties" import LayerState from "../Logic/State/LayerState" -import { Feature } from "geojson" +import { Feature, Geometry } from "geojson" import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource" -import UserRelatedState from "../Logic/State/UserRelatedState" import { MangroveIdentity } from "../Logic/Web/MangroveReviews" import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore" @@ -58,6 +54,8 @@ export interface SpecialVisualization { funcName: string docs: string | BaseUIElement example?: string + + structuredExamples?(): { feature: Feature>; args: string[] }[] args: { name: string; defaultValue?: string; doc: string; required?: false | boolean }[] getLayerDependencies?: (argument: string[]) => string[] @@ -68,3 +66,11 @@ export interface SpecialVisualization { feature: Feature ): BaseUIElement } + +export type RenderingSpecification = + | string + | { + func: SpecialVisualization + args: string[] + style: string + } diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index db4d1bd35..871d49b2a 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -3,7 +3,11 @@ import { FixedUiElement } from "./Base/FixedUiElement" import BaseUIElement from "./BaseUIElement" import Title from "./Base/Title" import Table from "./Base/Table" -import { SpecialVisualization } from "./SpecialVisualization" +import { + RenderingSpecification, + SpecialVisualization, + SpecialVisualizationState, +} from "./SpecialVisualization" import { HistogramViz } from "./Popup/HistogramViz" import { StealViz } from "./Popup/StealViz" import { MinimapViz } from "./Popup/MinimapViz" @@ -51,10 +55,97 @@ import FeatureReviews from "../Logic/Web/MangroveReviews" import Maproulette from "../Logic/Maproulette" import SvelteUIElement from "./Base/SvelteUIElement" import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" +import { Feature } from "geojson" export default class SpecialVisualizations { public static specialVisualizations: SpecialVisualization[] = SpecialVisualizations.initList() + /** + * + * For a given string, returns a specification what parts are fixed and what parts are special renderings. + * Note that _normal_ substitutions are ignored. + * + * // Return empty list on empty input + * SubstitutedTranslation.ExtractSpecialComponents("") // => [] + * + * // Advanced cases with commas, braces and newlines should be handled without problem + * const templates = SubstitutedTranslation.ExtractSpecialComponents("{send_email(&LBRACEemail&RBRACE,Broken bicycle pump,Hello&COMMA\n\nWith this email&COMMA I'd like to inform you that the bicycle pump located at https://mapcomplete.osm.be/cyclofix?lat=&LBRACE_lat&RBRACE&lon=&LBRACE_lon&RBRACE&z=18#&LBRACEid&RBRACE is broken.\n\n Kind regards,Report this bicycle pump as broken)}") + * const templ = templates[0] + * templ.special.func.funcName // => "send_email" + * templ.special.args[0] = "{email}" + */ + public static constructSpecification( + template: string, + extraMappings: SpecialVisualization[] = [] + ): RenderingSpecification[] { + if (template === "") { + return [] + } + + const allKnownSpecials = extraMappings.concat(SpecialVisualizations.specialVisualizations) + for (const knownSpecial of allKnownSpecials) { + // Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way' + const matched = template.match( + new RegExp(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`, "s") + ) + if (matched != null) { + // We found a special component that should be brought to live + const partBefore = SpecialVisualizations.constructSpecification( + matched[1], + extraMappings + ) + const argument = matched[2].trim() + const style = matched[3]?.substring(1) ?? "" + const partAfter = SpecialVisualizations.constructSpecification( + matched[4], + extraMappings + ) + const args = knownSpecial.args.map((arg) => arg.defaultValue ?? "") + if (argument.length > 0) { + const realArgs = argument.split(",").map((str) => + str + .trim() + .replace(/&LPARENS/g, "(") + .replace(/&RPARENS/g, ")") + .replace(/&LBRACE/g, "{") + .replace(/&RBRACE/g, "}") + .replace(/&COMMA/g, ",") + ) + for (let i = 0; i < realArgs.length; i++) { + if (args.length <= i) { + args.push(realArgs[i]) + } else { + args[i] = realArgs[i] + } + } + } + + const element: RenderingSpecification = { + args: args, + style: style, + func: knownSpecial, + } + return [...partBefore, element, ...partAfter] + } + } + + // Let's to a small sanity check to help the theme designers: + if (template.search(/{[^}]+\([^}]*\)}/) >= 0) { + // Hmm, we might have found an invalid rendering name + console.warn( + "Found a suspicious special rendering value in: ", + template, + " did you mean one of: " + /*SpecialVisualizations.specialVisualizations + .map((sp) => sp.funcName + "()") + .join(", ")*/ + ) + } + + // IF we end up here, no changes have to be made - except to remove any resting {} + return [template] + } + public static DocumentationFor(viz: string | SpecialVisualization): BaseUIElement | undefined { if (typeof viz === "string") { viz = SpecialVisualizations.specialVisualizations.find((sv) => sv.funcName === viz) @@ -649,7 +740,7 @@ export default class SpecialVisualizations { defaultValue: "mr_taskId", }, ], - constr: (state, tagsSource, args, guistate) => { + constr: (state, tagsSource, args) => { let [message, image, message_closed, status, maproulette_id_key] = args if (image === "") { image = "confirm" @@ -720,7 +811,7 @@ export default class SpecialVisualizations { funcName: "statistics", docs: "Show general statistics about the elements currently in view. Intended to use on the `current_view`-layer", args: [], - constr: (state, tagsSource, args, guiState) => { + constr: (state) => { return new Combine( state.layout.layers .filter((l) => l.name !== null) @@ -852,4 +943,23 @@ export default class SpecialVisualizations { return specialVisualizations } + + // noinspection JSUnusedGlobalSymbols + public static renderExampleOfSpecial( + state: SpecialVisualizationState, + s: SpecialVisualization + ): BaseUIElement { + const examples = + s.structuredExamples === undefined + ? [] + : s.structuredExamples().map((e) => { + return s.constr( + state, + new UIEventSource>(e.feature.properties), + e.args, + e.feature + ) + }) + return new Combine([new Title(s.funcName), s.docs, ...examples]) + } } diff --git a/UI/SubstitutedTranslation.ts b/UI/SubstitutedTranslation.ts index 65e4cd077..b14348ccc 100644 --- a/UI/SubstitutedTranslation.ts +++ b/UI/SubstitutedTranslation.ts @@ -7,10 +7,10 @@ import { Utils } from "../Utils" import { VariableUiElement } from "./Base/VariableUIElement" import Combine from "./Base/Combine" import BaseUIElement from "./BaseUIElement" -import { DefaultGuiState } from "./DefaultGuiState" -import FeaturePipelineState from "../Logic/State/FeaturePipelineState" import LinkToWeblate from "./Base/LinkToWeblate" import { SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization" +import SpecialVisualizations from "./SpecialVisualizations" +import { Feature } from "geojson" export class SubstitutedTranslation extends VariableUiElement { public constructor( @@ -21,10 +21,10 @@ export class SubstitutedTranslation extends VariableUiElement { string, | BaseUIElement | (( - state: FeaturePipelineState, + state: SpecialVisualizationState, tagSource: UIEventSource>, argument: string[], - guistate: DefaultGuiState + feature: Feature ) => BaseUIElement) > = undefined ) { @@ -55,19 +55,23 @@ export class SubstitutedTranslation extends VariableUiElement { txt = txt.replace(new RegExp(`{${key}}`, "g"), `{${key}()}`) }) - const allElements = SubstitutedTranslation.ExtractSpecialComponents( + const allElements = SpecialVisualizations.constructSpecification( txt, extraMappings ).map((proto) => { - if (proto.fixed !== undefined) { + if (typeof proto === "string") { if (tagsSource === undefined) { - return Utils.SubstituteKeys(proto.fixed, undefined) + return Utils.SubstituteKeys(proto, undefined) } return new VariableUiElement( - tagsSource.map((tags) => Utils.SubstituteKeys(proto.fixed, tags)) + tagsSource.map((tags) => Utils.SubstituteKeys(proto, tags)) ) } - const viz = proto.special + const viz: { + func: SpecialVisualization + args: string[] + style: string + } = proto if (viz === undefined) { console.error( "SPECIALRENDERING UNDEFINED for", @@ -77,9 +81,12 @@ export class SubstitutedTranslation extends VariableUiElement { return undefined } try { + const feature = state.indexedFeatures.featuresById.data.get( + tagsSource.data.id + ) return viz.func - .constr(state, tagsSource, proto.special.args) - ?.SetStyle(proto.special.style) + .constr(state, tagsSource, proto.args, feature) + ?.SetStyle(proto.style) } catch (e) { console.error("SPECIALRENDERING FAILED for", tagsSource.data?.id, e) return new FixedUiElement( @@ -97,98 +104,4 @@ export class SubstitutedTranslation extends VariableUiElement { this.SetClass("w-full") } - - /** - * - * // Return empty list on empty input - * SubstitutedTranslation.ExtractSpecialComponents("") // => [] - * - * // Advanced cases with commas, braces and newlines should be handled without problem - * const templates = SubstitutedTranslation.ExtractSpecialComponents("{send_email(&LBRACEemail&RBRACE,Broken bicycle pump,Hello&COMMA\n\nWith this email&COMMA I'd like to inform you that the bicycle pump located at https://mapcomplete.osm.be/cyclofix?lat=&LBRACE_lat&RBRACE&lon=&LBRACE_lon&RBRACE&z=18#&LBRACEid&RBRACE is broken.\n\n Kind regards,Report this bicycle pump as broken)}") - * const templ = templates[0] - * templ.special.func.funcName // => "send_email" - * templ.special.args[0] = "{email}" - */ - public static ExtractSpecialComponents( - template: string, - extraMappings: SpecialVisualization[] = [] - ): { - fixed?: string - special?: { - func: SpecialVisualization - args: string[] - style: string - } - }[] { - if (template === "") { - return [] - } - - for (const knownSpecial of extraMappings.concat( - [] // TODO enable SpecialVisualizations.specialVisualizations - )) { - // Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way' - const matched = template.match( - new RegExp(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`, "s") - ) - if (matched != null) { - // We found a special component that should be brought to live - const partBefore = SubstitutedTranslation.ExtractSpecialComponents( - matched[1], - extraMappings - ) - const argument = matched[2].trim() - const style = matched[3]?.substring(1) ?? "" - const partAfter = SubstitutedTranslation.ExtractSpecialComponents( - matched[4], - extraMappings - ) - const args = knownSpecial.args.map((arg) => arg.defaultValue ?? "") - if (argument.length > 0) { - const realArgs = argument.split(",").map((str) => - str - .trim() - .replace(/&LPARENS/g, "(") - .replace(/&RPARENS/g, ")") - .replace(/&LBRACE/g, "{") - .replace(/&RBRACE/g, "}") - .replace(/&COMMA/g, ",") - ) - for (let i = 0; i < realArgs.length; i++) { - if (args.length <= i) { - args.push(realArgs[i]) - } else { - args[i] = realArgs[i] - } - } - } - - let element - element = { - special: { - args: args, - style: style, - func: knownSpecial, - }, - } - return [...partBefore, element, ...partAfter] - } - } - - // Let's to a small sanity check to help the theme designers: - if (template.search(/{[^}]+\([^}]*\)}/) >= 0) { - // Hmm, we might have found an invalid rendering name - console.warn( - "Found a suspicious special rendering value in: ", - template, - " did you mean one of: " - /*SpecialVisualizations.specialVisualizations - .map((sp) => sp.funcName + "()") - .join(", ")*/ - ) - } - - // IF we end up here, no changes have to be made - except to remove any resting {} - return [{ fixed: template }] - } } diff --git a/UI/ThemeViewGUI.svelte b/UI/ThemeViewGUI.svelte index 90daaae91..63018f0c9 100644 --- a/UI/ThemeViewGUI.svelte +++ b/UI/ThemeViewGUI.svelte @@ -20,6 +20,7 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@rgossiaux/svelte-headlessui"; import Translations from "./i18n/Translations"; import { MenuIcon } from "@rgossiaux/svelte-heroicons/solid"; + import Tr from "./Base/Tr.svelte"; export let layout: LayoutConfig; const state = new ThemeViewState(layout); @@ -48,7 +49,7 @@
- {layout.title} +
@@ -58,9 +59,7 @@
- state.guistate.filterViewIsOpened.setData(true)}> - - +
@@ -86,17 +85,6 @@
- -
-
state.guistate.filterViewIsOpened.setData(false)}>Close
- - {#each layout.layers as layer} - - {/each} - - -
-
@@ -105,31 +93,47 @@
state.guistate.welcomeMessageIsOpened.setData(false)}>Close
- selected ? "tab-selected" : "tab-unselected"}>About - selected ? "tab-selected" : "tab-unselected"}>Tab 2 + selected ? "tab-selected" : "tab-unselected"}> + + + selected ? "tab-selected" : "tab-unselected"}> + + selected ? "tab-selected" : "tab-unselected"}>Tab 3 - layout.description}> - {Translations.t.general.welcomeExplanation.general} + + {#if layout.layers.some((l) => l.presets?.length > 0)} - {Translations.t.general.welcomeExplanation.addNew} + {/if} - layout.descriptionTail}> +
- +
- +
- Content 2 + +
+ + {#each layout.layers as layer} + + {/each} + + +
+
Content 3
@@ -163,15 +167,14 @@
- +{#if $selectedElement !== undefined && $selectedLayer !== undefined}
- +
-
- +{/if}