Feature(distancePicker): revive geographical distance picker

This commit is contained in:
Pieter Vander Vennet 2025-04-08 02:42:30 +02:00
parent e997653284
commit 5095bffc50
16 changed files with 245 additions and 107 deletions

View file

@ -494,6 +494,7 @@
"id": "Cycle barrier type" "id": "Cycle barrier type"
}, },
{ {
"id": "MaxWidth",
"render": { "render": {
"en": "Maximum width: {maxwidth:physical} m", "en": "Maximum width: {maxwidth:physical} m",
"nl": "Maximumbreedte: {maxwidth:physical} m", "nl": "Maximumbreedte: {maxwidth:physical} m",
@ -532,12 +533,11 @@
"freeform": { "freeform": {
"key": "maxwidth:physical", "key": "maxwidth:physical",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"20", "zoom": 20,
"map" "background": "map"
] }
}, }
"id": "MaxWidth"
}, },
{ {
"render": { "render": {
@ -575,10 +575,10 @@
"freeform": { "freeform": {
"key": "width:separation", "key": "width:separation",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"21", "zoom": 21,
"map" "background": "map"
] }
}, },
"id": "Space between barrier (cyclebarrier)" "id": "Space between barrier (cyclebarrier)"
}, },
@ -618,10 +618,10 @@
"freeform": { "freeform": {
"key": "width:opening", "key": "width:opening",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"21", "zoom": 21,
"map" "background": "map"
] }
}, },
"id": "Width of opening (cyclebarrier)" "id": "Width of opening (cyclebarrier)"
}, },
@ -661,10 +661,10 @@
"freeform": { "freeform": {
"key": "overlap", "key": "overlap",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"21", "zoom": 21,
"map" "background": "map"
] }
}, },
"id": "Overlap (cyclebarrier)" "id": "Overlap (cyclebarrier)"
} }

View file

@ -1283,10 +1283,10 @@
"freeform": { "freeform": {
"key": "width", "key": "width",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"20", "zoom": 20,
"map" "background": "map"
] }
}, },
"question": { "question": {
"en": "What is the carriage width of this road (in meters)?", "en": "What is the carriage width of this road (in meters)?",
@ -1808,10 +1808,10 @@
"freeform": { "freeform": {
"key": "cycleway:buffer", "key": "cycleway:buffer",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
"20", "background": "map",
"map" "zoom": 20
] }
}, },
"id": "cycleways_and_roads-cycleway:buffer" "id": "cycleways_and_roads-cycleway:buffer"
}, },

View file

@ -69,10 +69,10 @@
"freeform": { "freeform": {
"key": "width:carriageway", "key": "width:carriageway",
"type": "distance", "type": "distance",
"helperArgs": [ "helperArgs": {
21, "zoom": 21,
"map" "background": "map"
] }
} }
}, },
{ {

View file

@ -628,6 +628,11 @@
"recentThemes": "Recently visited themes", "recentThemes": "Recently visited themes",
"title": "MapComplete" "title": "MapComplete"
}, },
"input_helpers": {
"distance": {
"setFirst": "Measure from current location"
}
},
"inspector": { "inspector": {
"aggregateView": "Aggregate", "aggregateView": "Aggregate",
"answeredCountTimes": "Answered {count} times", "answeredCountTimes": "Answered {count} times",

View file

@ -32,6 +32,27 @@ export class AvailableRasterLayers {
public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> = public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> =
AvailableRasterLayers.initGlobalLayers() AvailableRasterLayers.initGlobalLayers()
public static bing = <RasterLayerPolygon>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[] { private static initGlobalLayers(): RasterLayerPolygon[] {
const gl: RasterLayerProperties[] = (globallayers["default"] ?? globallayers).layers.filter( const gl: RasterLayerProperties[] = (globallayers["default"] ?? globallayers).layers.filter(
@ -54,26 +75,7 @@ export class AvailableRasterLayers {
) )
} }
public static bing = <RasterLayerPolygon>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 * The default background layer that any theme uses which does not explicitly define a background

View file

@ -1,10 +1,7 @@
import { DesugaringStep } from "./Conversion" import { DesugaringStep } from "./Conversion"
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson" import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import { import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
MappingConfigJson,
QuestionableTagRenderingConfigJson,
} from "../Json/QuestionableTagRenderingConfigJson"
import { ConversionContext } from "./ConversionContext" import { ConversionContext } from "./ConversionContext"
import { Translation } from "../../../UI/i18n/Translation" import { Translation } from "../../../UI/i18n/Translation"
import { TagUtils } from "../../../Logic/Tags/TagUtils" import { TagUtils } from "../../../Logic/Tags/TagUtils"
@ -216,6 +213,14 @@ export class MiscTagRenderingChecks extends DesugaringStep<TagRenderingConfigJso
"No need to explicitly set type to 'NSI', autodetected based on freeform type" "No need to explicitly set type to 'NSI', autodetected based on freeform type"
) )
} }
if (json.freeform["type"] && json.freeform["helperArgs"]) {
const validator = Validators.get(json.freeform["type"])
const feedback = validator?.validateArguments(json.freeform["helperArgs"])
if (feedback) {
context.enters("freeform", "helperArgs").err(feedback)
}
}
} }
if (json.render && json["question"] && json.freeform === undefined) { if (json.render && json["question"] && json.freeform === undefined) {
context.err( context.err(

View file

@ -0,0 +1,100 @@
<script lang="ts">
/**
* 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<number>
export let feature: Feature
export let args: { background?: string, zoom?: number }
export let state: ThemeViewState = undefined
export let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(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<MapProperties> = {
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<Feature[]> = 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 <Feature[]>[
{
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)
})
</script>
<div class="relative w-full h-64">
<LocationInput value={mapLocation} {mapProperties} {map} />
<div class="absolute bottom-0 left-0 p-4">
<OpenBackgroundSelectorButton {state} {map} />
</div>
</div>
<button class="primary" on:click={() => selectStart()}>
<Tr t={Translations.t.input_helpers.distance.setFirst} />
</button>

View file

@ -19,12 +19,13 @@
import SlopeInput from "./Helpers/SlopeInput.svelte" import SlopeInput from "./Helpers/SlopeInput.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import WikidataInputHelper from "./WikidataInputHelper.svelte" import WikidataInputHelper from "./WikidataInputHelper.svelte"
import DistanceInput from "./Helpers/DistanceInput.svelte"
export let type: ValidatorType export let type: ValidatorType
export let value: UIEventSource<string | object> export let value: UIEventSource<string | object>
export let feature: Feature = undefined 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 export let state: SpecialVisualizationState = undefined
</script> </script>
@ -52,6 +53,8 @@
<SlopeInput {value} {feature} {state} /> <SlopeInput {value} {feature} {state} />
{:else if type === "wikidata"} {:else if type === "wikidata"}
<WikidataInputHelper {value} {feature} {state} {args} /> <WikidataInputHelper {value} {feature} {state} {args} />
{:else if type === "distance"}
<DistanceInput {value} {state} {feature} {args} />
{:else} {:else}
<slot name="fallback" /> <slot name="fallback" />
{/if} {/if}

View file

@ -27,7 +27,6 @@ export interface InputHelperProperties {
export default class InputHelpers { export default class InputHelpers {
public static hideInputField: string[] = ["translation", "simple_tag", "tag"] public static hideInputField: string[] = ["translation", "simple_tag", "tag"]
// noinspection JSUnusedLocalSymbols
/** /**
* Constructs a mapProperties-object for the given properties. * Constructs a mapProperties-object for the given properties.
* Assumes that the first helper-args contains the desired zoom-level * Assumes that the first helper-args contains the desired zoom-level

View file

@ -14,7 +14,8 @@ export abstract class Validator {
* */ * */
public readonly explanation: string 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?: public readonly inputmode?:
| "none" | "none"
@ -81,4 +82,14 @@ export abstract class Validator {
public reformat(s: string, _?: () => string): string { public reformat(s: string, _?: () => string): string {
return s 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
}
} }

View file

@ -4,7 +4,7 @@ import TextValidator from "./Validators/TextValidator"
import DateValidator from "./Validators/DateValidator" import DateValidator from "./Validators/DateValidator"
import NatValidator from "./Validators/NatValidator" import NatValidator from "./Validators/NatValidator"
import IntValidator from "./Validators/IntValidator" import IntValidator from "./Validators/IntValidator"
import LengthValidator from "./Validators/LengthValidator" import DistanceValidator from "./Validators/DistanceValidator"
import DirectionValidator from "./Validators/DirectionValidator" import DirectionValidator from "./Validators/DirectionValidator"
import WikidataValidator from "./Validators/WikidataValidator" import WikidataValidator from "./Validators/WikidataValidator"
import PNatValidator from "./Validators/PNatValidator" import PNatValidator from "./Validators/PNatValidator"
@ -71,7 +71,7 @@ export default class Validators {
new DateValidator(), new DateValidator(),
new NatValidator(), new NatValidator(),
new IntValidator(), new IntValidator(),
new LengthValidator(), new DistanceValidator(),
new DirectionValidator(), new DirectionValidator(),
new WikidataValidator(), new WikidataValidator(),
new PNatValidator(), new PNatValidator(),

View file

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

View file

@ -4,7 +4,7 @@ import { Validator } from "../Validator"
import { ValidatorType } from "../Validators" import { ValidatorType } from "../Validators"
export default class FloatValidator extends Validator { export default class FloatValidator extends Validator {
inputmode: "decimal" = "decimal" inputmode: "decimal" = "decimal" as const
constructor(name?: ValidatorType, explanation?: string) { constructor(name?: ValidatorType, explanation?: string) {
super(name ?? "float", explanation ?? "A decimal number", "decimal") super(name ?? "float", explanation ?? "A decimal number", "decimal")

View file

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

View file

@ -1,40 +1,14 @@
import Title from "../../Base/Title"
import Combine from "../../Base/Combine"
import { Validator } from "../Validator" import { Validator } from "../Validator"
import Table from "../../Base/Table"
export default class NameSuggestionIndexValidator extends Validator { export default class NameSuggestionIndexValidator extends Validator {
constructor() { constructor() {
super( super(
"nsi", "nsi",
new Combine([ "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"
"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`.",
],
]
),
]),
],
]
),
])
) )
} }
validateArguments(args: string): string | undefined {
return "No arguments needed"
}
} }

View file

@ -352,7 +352,7 @@ class LineRenderingLayer {
// After waiting 'till the map has loaded, the data might have changed already // 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 // As such, we only now read the features from the featureSource and compare with the previously set data
const features = featureSource.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, // This is a very ugly workaround for https://source.mapcomplete.org/MapComplete/MapComplete/issues/2312,
// but I couldn't find the root cause // but I couldn't find the root cause
return return