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

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

View file

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

View file

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

View file

@ -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(),

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

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

View file

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