Improve trail planning: option to re-order route anchors (#1007)

* trail anchor list added

* add search location card to extend route endpoint in drawing mode

* add POI popup endpoint action in route drawing mode

* fix anchor stats, improve anchor list entries and refine route marker behavior

* fix map marker spinner

* docs updated

* fix spinner when delete an anchor

* further improvements

* fix adding first anchor from POI

* optimize drag handle area

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-06-06 11:56:12 +02:00
committed by GitHub
parent 14ebaa0d04
commit 44cd092065
32 changed files with 1792 additions and 192 deletions

View File

@@ -128,20 +128,46 @@ export async function searchLocations(q: string, limit?: number, f: (url: Reques
}))
}
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Response> {
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch, signal?: AbortSignal): Promise<Response> {
const query = params.toString();
const url = query.length ? `/api/v1/geocoding/${path}?${query}` : `/api/v1/geocoding/${path}`;
return await f(url);
return await f(url, signal ? { signal } : undefined);
}
export async function searchLocationReverse(lat: number, lon: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
type ReverseGeocodingOptions = {
includeRoad?: boolean;
signal?: AbortSignal;
}
export type ReverseLocationResult = {
label: string;
fullLabel: string;
country: string;
}
export type FetchFunction = (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>;
export async function searchLocationReverse(
lat: number,
lon: number,
options: ReverseGeocodingOptions = {},
f: FetchFunction = fetch,
) {
const location = await searchLocationReverseStructured(lat, lon, options, f);
return location?.fullLabel ?? "";
}
export async function searchLocationReverseStructured(
lat: number,
lon: number,
options: ReverseGeocodingOptions = {},
f: FetchFunction = fetch,
): Promise<ReverseLocationResult | null> {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lon),
format: "geojson",
addressdetails: "1",
});
const r = await fetchGeocoding("reverse", params, f);
lat: String(lat),
lon: String(lon),
});
const r = await fetchGeocoding("reverse", params, f, options.signal);
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
@@ -149,30 +175,52 @@ export async function searchLocationReverse(lat: number, lon: number, f: (url: R
const response: NominatimResponse = await r.json();
if (response.features?.at(0)?.properties.address) {
return getLocationDescription(response.features[0].properties.address)
return getReverseLocationResult(response.features[0].properties.address, options);
}
return ""
return null
}
function getLocationDescription(address: Address) {
let description = ""
function getReverseLocationResult(
address: Address,
options: ReverseGeocodingOptions = {},
): ReverseLocationResult {
const country = address.country ?? "";
const label = getLocationDescription(address, { ...options, includeCountry: false });
const fullLabel = getLocationDescription(address, options);
if (address.country) {
description += address.country;
}
if (address.state) {
description = `${address.state}, ` + description
return {
label: label || fullLabel,
fullLabel,
country,
};
}
function getLocationDescription(
address: Address,
options: ReverseGeocodingOptions & { includeCountry?: boolean } = {},
) {
const parts = [];
if (options.includeRoad && address.road) {
parts.push(address.road);
}
if (address.city) {
description = `${address.city}, ` + description
parts.push(address.city);
} else if (address.town) {
description = `${address.town}, ` + description
parts.push(address.town);
} else if (address.hamlet) {
description = `${address.hamlet}, ` + description
parts.push(address.hamlet);
} else if (address.village) {
description = `${address.village}, ` + description
parts.push(address.village);
}
return description;
if (address.state) {
parts.push(address.state);
}
if (options.includeCountry !== false && address.country) {
parts.push(address.country);
}
return parts.join(", ");
}
export async function searchMulti(options: MultiSearchParams): Promise<MultiSearchResult<any>[]> {

View File

@@ -334,9 +334,11 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
}
let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
}), {
});
let r = await fetch(updateUrl, {
method: 'POST',
body: formData,
})
@@ -360,6 +362,54 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
return model;
}
export async function trails_update_metadata(
currentTrail: Trail,
patch: Pick<Partial<Trail>, "name" | "description" | "tags"> & {
expand?: Pick<NonNullable<Trail["expand"]>, "tags">;
},
) {
const tagIds: string[] | undefined = patch.expand?.tags
? []
: patch.tags;
for (const tag of patch.expand?.tags ?? []) {
if (!tag.id) {
const model = await tags_create(tag);
tagIds!.push(model.id!);
} else {
tagIds!.push(tag.id);
}
}
const searchParams = new URLSearchParams(
tagIds !== undefined ? { expand: "tags" } : {},
);
const query = searchParams.toString();
const url = `/api/v1/trail/${currentTrail.id}${query ? `?${query}` : ""}`;
const payload = {
name: patch.name ?? currentTrail.name,
...(patch.description !== undefined
? { description: patch.description }
: {}),
...(tagIds !== undefined ? { tags: tagIds } : {}),
};
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const model: Trail = await r.json();
trail.set(model);
return model;
}
export async function trails_delete(trail: Trail) {
const r = await fetch('/api/v1/trail/' + trail.id, {

View File

@@ -6,6 +6,7 @@ import Waypoint from "$lib/models/gpx/waypoint";
import { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla";
import { APIError } from "$lib/util/api_util";
import { decodePolyline, encodePolyline } from "$lib/util/polyline_util";
import { renderValhallaAnchorMarker, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
import { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts';
import type { LngLat } from "maplibre-gl";
import { _ } from "svelte-i18n";
@@ -16,8 +17,8 @@ const emtpyTrack = new Track({ trkseg: [] })
class ValhallaStore {
route: GPX = $state(new GPX({ trk: [emtpyTrack] }));
anchors: ValhallaAnchor[] = $state([]);
undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
undoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
redoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
}
export const valhallaStore = new ValhallaStore();
@@ -179,14 +180,21 @@ export function reverseRoute() {
if (!a.marker) {
return;
}
a.marker.getElement().textContent = "" + (i + 1);
renderValhallaAnchorMarker(
a.marker.getElement(),
i,
valhallaStore.anchors.length,
);
const anchorPopupHeading = a.marker
.getPopup()
._content.getElementsByTagName("h5")[0];
if (anchorPopupHeading) {
anchorPopupHeading.textContent =
get(_)("route-point") + " #" + (i + 1);
anchorPopupHeading.textContent = valhallaAnchorTitle(
i,
valhallaStore.anchors.length,
get(_),
);
}
});
}
@@ -235,8 +243,8 @@ export async function splitSegment(index: number, pos: LngLat) {
const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint];
const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)];
editRoute(index, firstSegmentPoints)
insertIntoRoute(secondSegmentPoints, index + 1)
await editRoute(index, firstSegmentPoints)
await insertIntoRoute(secondSegmentPoints, index + 1)
}
@@ -263,21 +271,30 @@ export function normalizeRouteTime() {
export function undo() {
const historyItem = valhallaStore.undoStack.pop()
if (!historyItem) {
return
return undefined
}
valhallaStore.redoStack.push(historyItem)
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
valhallaStore.route.features = valhallaStore.route.getTotals();
return historyItem;
}
export function revertRouteChange() {
const historyItem = valhallaStore.undoStack.pop();
if (!historyItem) return;
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
valhallaStore.route.features = valhallaStore.route.getTotals();
}
export function redo() {
const historyItem = valhallaStore.redoStack.pop()
if (!historyItem) {
return
return undefined
}
valhallaStore.undoStack.push(historyItem)
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta);
valhallaStore.route.features = valhallaStore.route.getTotals();
return historyItem;
}