From 5095bffc5065e1c311b85e9af0f537073cb82f92 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Tue, 8 Apr 2025 02:42:30 +0200 Subject: [PATCH] Feature(distancePicker): revive geographical distance picker --- assets/layers/barrier/barrier.json | 36 +++---- .../cycleways_and_roads.json | 16 +-- assets/themes/width/width.json | 8 +- langs/en.json | 5 + src/Models/RasterLayers.ts | 40 +++---- .../Conversion/MiscTagRenderingChecks.ts | 13 ++- .../InputElement/Helpers/DistanceInput.svelte | 100 ++++++++++++++++++ src/UI/InputElement/InputHelper.svelte | 5 +- src/UI/InputElement/InputHelpers.ts | 1 - src/UI/InputElement/Validator.ts | 13 ++- src/UI/InputElement/Validators.ts | 4 +- .../Validators/DistanceValidator.ts | 55 ++++++++++ .../InputElement/Validators/FloatValidator.ts | 2 +- .../Validators/LengthValidator.ts | 16 --- .../NameSuggestionIndexValidator.ts | 36 +------ src/UI/Map/ShowDataLayer.ts | 2 +- 16 files changed, 245 insertions(+), 107 deletions(-) create mode 100644 src/UI/InputElement/Helpers/DistanceInput.svelte create mode 100644 src/UI/InputElement/Validators/DistanceValidator.ts delete mode 100644 src/UI/InputElement/Validators/LengthValidator.ts diff --git a/assets/layers/barrier/barrier.json b/assets/layers/barrier/barrier.json index 69fb252743..a57566d654 100644 --- a/assets/layers/barrier/barrier.json +++ b/assets/layers/barrier/barrier.json @@ -494,6 +494,7 @@ "id": "Cycle barrier type" }, { + "id": "MaxWidth", "render": { "en": "Maximum width: {maxwidth:physical} m", "nl": "Maximumbreedte: {maxwidth:physical} m", @@ -532,12 +533,11 @@ "freeform": { "key": "maxwidth:physical", "type": "distance", - "helperArgs": [ - "20", - "map" - ] - }, - "id": "MaxWidth" + "helperArgs": { + "zoom": 20, + "background": "map" + } + } }, { "render": { @@ -575,10 +575,10 @@ "freeform": { "key": "width:separation", "type": "distance", - "helperArgs": [ - "21", - "map" - ] + "helperArgs": { + "zoom": 21, + "background": "map" + } }, "id": "Space between barrier (cyclebarrier)" }, @@ -618,10 +618,10 @@ "freeform": { "key": "width:opening", "type": "distance", - "helperArgs": [ - "21", - "map" - ] + "helperArgs": { + "zoom": 21, + "background": "map" + } }, "id": "Width of opening (cyclebarrier)" }, @@ -661,10 +661,10 @@ "freeform": { "key": "overlap", "type": "distance", - "helperArgs": [ - "21", - "map" - ] + "helperArgs": { + "zoom": 21, + "background": "map" + } }, "id": "Overlap (cyclebarrier)" } diff --git a/assets/layers/cycleways_and_roads/cycleways_and_roads.json b/assets/layers/cycleways_and_roads/cycleways_and_roads.json index 087e021da0..e77a7bd52e 100644 --- a/assets/layers/cycleways_and_roads/cycleways_and_roads.json +++ b/assets/layers/cycleways_and_roads/cycleways_and_roads.json @@ -1283,10 +1283,10 @@ "freeform": { "key": "width", "type": "distance", - "helperArgs": [ - "20", - "map" - ] + "helperArgs": { + "zoom": 20, + "background": "map" + } }, "question": { "en": "What is the carriage width of this road (in meters)?", @@ -1808,10 +1808,10 @@ "freeform": { "key": "cycleway:buffer", "type": "distance", - "helperArgs": [ - "20", - "map" - ] + "helperArgs": { + "background": "map", + "zoom": 20 + } }, "id": "cycleways_and_roads-cycleway:buffer" }, diff --git a/assets/themes/width/width.json b/assets/themes/width/width.json index 59a07a5dbb..0b69f1d293 100644 --- a/assets/themes/width/width.json +++ b/assets/themes/width/width.json @@ -69,10 +69,10 @@ "freeform": { "key": "width:carriageway", "type": "distance", - "helperArgs": [ - 21, - "map" - ] + "helperArgs": { + "zoom": 21, + "background": "map" + } } }, { diff --git a/langs/en.json b/langs/en.json index 60d0cc1d22..ccccca532a 100644 --- a/langs/en.json +++ b/langs/en.json @@ -628,6 +628,11 @@ "recentThemes": "Recently visited themes", "title": "MapComplete" }, + "input_helpers": { + "distance": { + "setFirst": "Measure from current location" + } + }, "inspector": { "aggregateView": "Aggregate", "answeredCountTimes": "Answered {count} times", diff --git a/src/Models/RasterLayers.ts b/src/Models/RasterLayers.ts index de192084d5..8ccd517cfc 100644 --- a/src/Models/RasterLayers.ts +++ b/src/Models/RasterLayers.ts @@ -32,6 +32,27 @@ export class AvailableRasterLayers { public static readonly globalLayers: ReadonlyArray = AvailableRasterLayers.initGlobalLayers() + public static bing = bingJson + public static readonly osmCartoProperties: RasterLayerProperties = { + id: "osm", + name: "OpenStreetMap", + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: { + text: "OpenStreetMap", + url: "https://openStreetMap.org/copyright" + }, + best: true, + max_zoom: 19, + min_zoom: 0, + category: "osmbasedmap" + } + public static readonly osmCarto: RasterLayerPolygon = { + type: "Feature", + properties: AvailableRasterLayers.osmCartoProperties, + geometry: BBox.global.asGeometry() + } + + public static allAvailableGlobalLayers = new Set([...AvailableRasterLayers.globalLayers, AvailableRasterLayers.osmCarto, AvailableRasterLayers.bing]) private static initGlobalLayers(): RasterLayerPolygon[] { const gl: RasterLayerProperties[] = (globallayers["default"] ?? globallayers).layers.filter( @@ -54,26 +75,7 @@ export class AvailableRasterLayers { ) } - public static bing = bingJson - public static readonly osmCartoProperties: RasterLayerProperties = { - id: "osm", - name: "OpenStreetMap", - url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution: { - text: "OpenStreetMap", - url: "https://openStreetMap.org/copyright", - }, - best: true, - max_zoom: 19, - min_zoom: 0, - category: "osmbasedmap", - } - public static readonly osmCarto: RasterLayerPolygon = { - type: "Feature", - properties: AvailableRasterLayers.osmCartoProperties, - geometry: BBox.global.asGeometry(), - } /** * The default background layer that any theme uses which does not explicitly define a background diff --git a/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts b/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts index eab32b2211..7b2ea274c3 100644 --- a/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts +++ b/src/Models/ThemeConfig/Conversion/MiscTagRenderingChecks.ts @@ -1,10 +1,7 @@ import { DesugaringStep } from "./Conversion" import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson" -import { - MappingConfigJson, - QuestionableTagRenderingConfigJson, -} from "../Json/QuestionableTagRenderingConfigJson" +import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" import { ConversionContext } from "./ConversionContext" import { Translation } from "../../../UI/i18n/Translation" import { TagUtils } from "../../../Logic/Tags/TagUtils" @@ -216,6 +213,14 @@ export class MiscTagRenderingChecks extends DesugaringStep + + /** + * Used to quickly calculate a distance by dragging a map (and selecting start- and endpoints) + */ + + import LocationInput from "./LocationInput.svelte" + import { UIEventSource, Store } from "../../../Logic/UIEventSource" + import type { MapProperties } from "../../../Models/MapProperties" + import ThemeViewState from "../../../Models/ThemeViewState" + import type { Feature } from "geojson" + import type { RasterLayerPolygon } from "../../../Models/RasterLayers" + import { RasterLayerUtils } from "../../../Models/RasterLayers" + import { eliCategory } from "../../../Models/RasterLayerProperties" + import { GeoOperations } from "../../../Logic/GeoOperations" + import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte" + import { Map as MlMap } from "maplibre-gl" + import StaticFeatureSource from "../../../Logic/FeatureSource/Sources/StaticFeatureSource" + import ShowDataLayer from "../../Map/ShowDataLayer" + import * as conflation from "../../../assets/generated/layers/conflation.json" + import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" + import Translations from "../../i18n/Translations" + import Tr from "../../Base/Tr.svelte" + + export let value: UIEventSource + export let feature: Feature + export let args: { background?: string, zoom?: number } + export let state: ThemeViewState = undefined + export let map: UIEventSource = new UIEventSource(undefined) + + let center = GeoOperations.centerpointCoordinates(feature) + export let initialCoordinate: { lon: number, lat: number } = { lon: center[0], lat: center[1] } + let mapLocation: UIEventSource<{ lon: number, lat: number }> = new UIEventSource(initialCoordinate) + let bg = args?.background + let rasterLayer = state?.mapProperties.rasterLayer + if (bg !== undefined) { + if (eliCategory.indexOf(bg) >= 0) { + const availableLayers = state.availableLayers.store.data + const startLayer: RasterLayerPolygon = RasterLayerUtils.SelectBestLayerAccordingTo(availableLayers, bg) + rasterLayer = new UIEventSource(startLayer) + state?.mapProperties.rasterLayer.addCallbackD(layer => rasterLayer.set(layer)) + } + + } + let mapProperties: Partial = { + rasterLayer: rasterLayer, + location: mapLocation, + zoom: new UIEventSource(args?.zoom ?? 18) + } + + let start: UIEventSource<{ lon: number, lat: number }> = new UIEventSource(undefined) + + function selectStart() { + start.set(mapLocation.data) + } + + let lengthFeature: Store = start.map(start => { + if (!start) { + return [] + } + // A bit of a double task: calculate the actual value _and_ the map rendering + const end = mapLocation.data + const distance = GeoOperations.distanceBetween([start.lon, start.lat], [end.lon, end.lat]) + value.set(distance.toFixed(2)) + + + return [ + + { + type: "Feature", + properties: { + id: "distance_line_" + distance, + distance: "" + distance + }, + geometry: { + type: "LineString", + coordinates: [[start.lon, start.lat], [end.lon, end.lat]] + } + } + ] + + }, [mapLocation]) + + new ShowDataLayer(map, { + layer: new LayerConfig(conflation), + features: new StaticFeatureSource(lengthFeature) + }) + + + +
+ +
+ +
+
+ + diff --git a/src/UI/InputElement/InputHelper.svelte b/src/UI/InputElement/InputHelper.svelte index 9b62c4d766..7b4dbcbe40 100644 --- a/src/UI/InputElement/InputHelper.svelte +++ b/src/UI/InputElement/InputHelper.svelte @@ -19,12 +19,13 @@ import SlopeInput from "./Helpers/SlopeInput.svelte" import type { SpecialVisualizationState } from "../SpecialVisualization" import WikidataInputHelper from "./WikidataInputHelper.svelte" + import DistanceInput from "./Helpers/DistanceInput.svelte" export let type: ValidatorType export let value: UIEventSource export let feature: Feature = undefined - export let args: (string | number | boolean)[] = undefined + export let args: (string | number | boolean)[] | any = undefined export let state: SpecialVisualizationState = undefined @@ -52,6 +53,8 @@ {:else if type === "wikidata"} +{:else if type === "distance"} + {:else} {/if} diff --git a/src/UI/InputElement/InputHelpers.ts b/src/UI/InputElement/InputHelpers.ts index 6527d5fea8..1f83161345 100644 --- a/src/UI/InputElement/InputHelpers.ts +++ b/src/UI/InputElement/InputHelpers.ts @@ -27,7 +27,6 @@ export interface InputHelperProperties { export default class InputHelpers { public static hideInputField: string[] = ["translation", "simple_tag", "tag"] - // noinspection JSUnusedLocalSymbols /** * Constructs a mapProperties-object for the given properties. * Assumes that the first helper-args contains the desired zoom-level diff --git a/src/UI/InputElement/Validator.ts b/src/UI/InputElement/Validator.ts index 3ee511c95d..3dd0fbc076 100644 --- a/src/UI/InputElement/Validator.ts +++ b/src/UI/InputElement/Validator.ts @@ -14,7 +14,8 @@ export abstract class Validator { * */ public readonly explanation: string /** - * What HTML-inputmode to use + * What HTML-inputmode to use? + * Note: some inputHelpers will completely hide the default text field. This is kept in InputHelpers.hideInputField */ public readonly inputmode?: | "none" @@ -81,4 +82,14 @@ export abstract class Validator { public reformat(s: string, _?: () => string): string { return s } + + /** + * Checks that the helper arguments are correct. + * This is called while preparing the themes. + * Returns 'undefined' if everything is fine, or feedback if an error is detected + * @param args the args for the input helper + */ + public validateArguments(args: string): undefined | string { + return undefined + } } diff --git a/src/UI/InputElement/Validators.ts b/src/UI/InputElement/Validators.ts index e5ab91b9fd..d07061bdd8 100644 --- a/src/UI/InputElement/Validators.ts +++ b/src/UI/InputElement/Validators.ts @@ -4,7 +4,7 @@ import TextValidator from "./Validators/TextValidator" import DateValidator from "./Validators/DateValidator" import NatValidator from "./Validators/NatValidator" import IntValidator from "./Validators/IntValidator" -import LengthValidator from "./Validators/LengthValidator" +import DistanceValidator from "./Validators/DistanceValidator" import DirectionValidator from "./Validators/DirectionValidator" import WikidataValidator from "./Validators/WikidataValidator" import PNatValidator from "./Validators/PNatValidator" @@ -71,7 +71,7 @@ export default class Validators { new DateValidator(), new NatValidator(), new IntValidator(), - new LengthValidator(), + new DistanceValidator(), new DirectionValidator(), new WikidataValidator(), new PNatValidator(), diff --git a/src/UI/InputElement/Validators/DistanceValidator.ts b/src/UI/InputElement/Validators/DistanceValidator.ts new file mode 100644 index 0000000000..ea237199dc --- /dev/null +++ b/src/UI/InputElement/Validators/DistanceValidator.ts @@ -0,0 +1,55 @@ +import { Validator } from "../Validator" +import { Utils } from "../../../Utils" +import { eliCategory } from "../../../Models/RasterLayerProperties" + +export default class DistanceValidator extends Validator { + private readonly docs: string = [ + "#### Helper-arguments", + "Options are:", + ["````json", + " \"background\": \"some_background_id or category, e.g. 'map'\"", + " \"zoom\": 20 # initial zoom level of the map", + "}", + "```"].join("\n") + ].join("\n\n") + + 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) + } + + validateArguments(args: any): undefined | string { + if (args === undefined) { + return undefined + } + if (typeof args !== "object" || Array.isArray(args)) { + return "Expected an object of type `{background?: string, zoom?: number}`" + } + + const optionalKeys = ["background", "zoom"] + const keys = Object.keys(args).filter(k => optionalKeys.indexOf(k) < 0) + if (keys.length > 0) { + return "Unknown key " + keys.join("; ") + "; use " + optionalKeys.join("; ") + " instead" + } + const bg = args["background"] + if (bg && eliCategory.indexOf(bg) < 0) { + return "The given background layer is not a recognized ELI-type. Perhaps you meant one of " + + Utils.sortedByLevenshteinDistance(bg, eliCategory, x => x).slice(0, 5) + } + if (typeof args["zoom"] !== "number") { + return "zoom must be a number, got a " + typeof args["zoom"] + } + if (typeof args["zoom"] !== "number" || args["zoom"] <= 1 || args["zoom"] > 25) { + return "zoom must be a number between 2 and 25, got " + args["zoom"] + } + return undefined + } +} diff --git a/src/UI/InputElement/Validators/FloatValidator.ts b/src/UI/InputElement/Validators/FloatValidator.ts index 639c250b46..4ce5da81ea 100644 --- a/src/UI/InputElement/Validators/FloatValidator.ts +++ b/src/UI/InputElement/Validators/FloatValidator.ts @@ -4,7 +4,7 @@ import { Validator } from "../Validator" import { ValidatorType } from "../Validators" export default class FloatValidator extends Validator { - inputmode: "decimal" = "decimal" + inputmode: "decimal" = "decimal" as const constructor(name?: ValidatorType, explanation?: string) { super(name ?? "float", explanation ?? "A decimal number", "decimal") diff --git a/src/UI/InputElement/Validators/LengthValidator.ts b/src/UI/InputElement/Validators/LengthValidator.ts deleted file mode 100644 index 4151b2aa80..0000000000 --- a/src/UI/InputElement/Validators/LengthValidator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Validator } from "../Validator" - -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/src/UI/InputElement/Validators/NameSuggestionIndexValidator.ts b/src/UI/InputElement/Validators/NameSuggestionIndexValidator.ts index fdf09ea455..2591b39077 100644 --- a/src/UI/InputElement/Validators/NameSuggestionIndexValidator.ts +++ b/src/UI/InputElement/Validators/NameSuggestionIndexValidator.ts @@ -1,40 +1,14 @@ -import Title from "../../Base/Title" -import Combine from "../../Base/Combine" import { Validator } from "../Validator" -import Table from "../../Base/Table" export default class NameSuggestionIndexValidator extends Validator { constructor() { super( "nsi", - new Combine([ - "Gives a list of possible suggestions for a brand or operator tag.", - new Title("Helper arguments"), - new Table( - ["name", "doc"], - [ - [ - "options", - new Combine([ - "A JSON-object of type `{ main: string, key: string }`. ", - new Table( - ["subarg", "doc"], - [ - [ - "main", - "The main tag to give suggestions for, e.g. `amenity=restaurant`.", - ], - [ - "addExtraTags", - "Extra tags to add to the suggestions, e.g. `nobrand=yes`.", - ], - ] - ), - ]), - ], - ] - ), - ]) + "Gives a list of possible suggestions for a brand or operator tag. Note: this is detected automatically; there is no need to explicitly set this" ) } + + validateArguments(args: string): string | undefined { + return "No arguments needed" + } } diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 8d98941d3e..38e96484d7 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -352,7 +352,7 @@ class LineRenderingLayer { // After waiting 'till the map has loaded, the data might have changed already // As such, we only now read the features from the featureSource and compare with the previously set data const features = featureSource.data - if (features.length === 0) { + if (!features || features.length === 0) { // This is a very ugly workaround for https://source.mapcomplete.org/MapComplete/MapComplete/issues/2312, // but I couldn't find the root cause return