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:
@@ -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>[]> {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user