From 59f40b9f058c4605dc27d951e91a4fa8b4d1d3a8 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 19 May 2026 17:29:34 +0200 Subject: [PATCH] feat(route-builder): import existing GPX (round-trip editing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the user re-load a previously-exported GPX and keep iterating on the same route — add a waypoint, fix a turn, retag an image — without rebuilding from scratch. The exported GPX interleaves user-anchor waypoints with densified / snapped intermediates in a single ``. The importer doesn't try to perfectly round-trip "manual waypoint vs intermediate"; instead it recovers the *image* anchors by matching `` coordinates against the trkpt sequence (1e-5° tolerance, ≈1 m), plus the start + end trkpts, and reconstructs routedSegments from the trkpts between adjacent anchors. The intermediate geometry is preserved verbatim — no re-routing, no second elevation pass. Image waypoints carry their `imageHash` + `imageVisibility` across the round-trip so the build script can still re-attach the source JPEGs on the next publish. Visual previews from those hashes are deferred to a follow-up — for now an image anchor renders as a hash-only badge in the waypoint table. Auto-snap is forced off after import so the freshly-loaded geometry isn't immediately overwritten by a routing API call. UI: a "GPX laden" link-style button next to the existing Reset, confirms before replacing a non-empty draft. The pure parsers (`parseGpx`, `parseGpxImageRefs`) move from `$lib/server/gpx` to `$lib/gpx` so the browser-side importer can use them; the server module re-exports for back-compat. --- package.json | 2 +- .../route-builder/builderStore.svelte.ts | 143 ++++++++++++++++++ src/lib/gpx.ts | 123 +++++++++++++++ src/lib/server/gpx.ts | 129 ++-------------- src/routes/hikes/route-builder/+page.svelte | 58 ++++++- 5 files changed, 337 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index aff445f8..98fa59fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.76.1", + "version": "1.77.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/hikes/route-builder/builderStore.svelte.ts b/src/lib/components/hikes/route-builder/builderStore.svelte.ts index 15883eb0..17cc6c23 100644 --- a/src/lib/components/hikes/route-builder/builderStore.svelte.ts +++ b/src/lib/components/hikes/route-builder/builderStore.svelte.ts @@ -8,6 +8,7 @@ */ import { browser } from '$app/environment'; +import { parseGpx, parseGpxImageRefs } from '$lib/gpx'; export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road'; @@ -304,3 +305,145 @@ export function setElevations(elevations: (number | null)[]): void { } } } + +// --------------------------------------------------------------------------- +// GPX import — restores the builder state from a previously-exported GPX so +// the user can iterate on an existing route (add a waypoint, retag an +// image, fix a turn) without losing the densified track or photo anchors. +// --------------------------------------------------------------------------- + +export type ImportGpxResult = + | { ok: true; trackName: string | null; waypointCount: number; imageCount: number } + | { ok: false; error: string }; + +/** Coordinate equality with a small tolerance — float round-trips through + * the GPX writer can shift the 7th decimal. 1e-5° ≈ 1 m, well below the + * spacing of any meaningful pair of anchors on a hike. */ +function coordsClose(aLat: number, aLng: number, bLat: number, bLng: number): boolean { + return Math.abs(aLat - bLat) < 1e-5 && Math.abs(aLng - bLng) < 1e-5; +} + +/** + * Reconstruct the builder state from a GPX XML string. + * + * Strategy: the exported GPX interleaves user-anchor waypoints with + * densified/snapped intermediate trkpts in a single ``. We don't + * try to round-trip "manual waypoints" vs "intermediates" perfectly — + * instead we recover the *image* anchors (matched against `` entries + * by coordinate), plus the very first and last trkpts (start + end), and + * rebuild routedSegments from the trkpts that fall between each adjacent + * anchor pair. Result is an editable route where every photo waypoint is + * a draggable handle and the geometry between handles is preserved + * verbatim — no re-routing required. + * + * Replaces the existing draft. Caller should confirm with the user if the + * builder is non-empty. + */ +export function importGpx(xml: string): ImportGpxResult { + const trk = parseGpx(xml); + if (trk.length < 2) { + return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' }; + } + const imageRefs = parseGpxImageRefs(xml); + const imageList = Object.values(imageRefs); + + // Optional on the track or top-level metadata. + const nameMatch = + xml.match(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/trk>/i) ?? + xml.match(/[\s\S]*?([^<]+)<\/name>[\s\S]*?<\/metadata>/i); + const trackName = nameMatch ? nameMatch[1].trim() : null; + + // Map each image waypoint to its first matching trkpt index. Order the + // image anchors by that index so they slot into the builder in + // traversal order, not GPX-declaration order. + type ImageAnchor = { + trkIdx: number; + hash: string; + visibility: 'public' | 'private'; + lat: number; + lng: number; + altitude?: number; + timestamp?: number; + }; + const imageAnchors: ImageAnchor[] = []; + for (const ref of imageList) { + let bestIdx = -1; + for (let i = 0; i < trk.length; i++) { + if (coordsClose(trk[i].lat, trk[i].lng, ref.lat, ref.lng)) { + bestIdx = i; + break; + } + } + if (bestIdx < 0) continue; // wpt position doesn't match any trkpt — skip + imageAnchors.push({ + trkIdx: bestIdx, + hash: ref.hash, + visibility: ref.visibility === 'private' ? 'private' : 'public', + lat: ref.lat, + lng: ref.lng, + altitude: ref.altitude, + timestamp: ref.timestamp + }); + } + imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx); + + // Build the set of anchor trkpt indices: first, last, all image anchors. + const anchorIndices = new Set([0, trk.length - 1]); + for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx); + const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b); + + // Assemble waypoints in traversal order. + const newWaypoints: Waypoint[] = sortedAnchorIdx.map((i) => { + const t = trk[i]; + const ia = imageAnchors.find((a) => a.trkIdx === i); + const wp: Waypoint = { + id: nextWaypointId(), + lat: t.lat, + lng: t.lng, + altitude: typeof t.altitude === 'number' ? t.altitude : ia?.altitude, + timestamp: t.timestamp ?? ia?.timestamp ?? null + }; + if (ia) { + wp.imageHash = ia.hash; + wp.imageVisibility = ia.visibility; + } + return wp; + }); + + // Reconstruct routedSegments from the trkpts between consecutive anchors. + // Each segment is `[lng, lat, ele?][]` and spans anchor[i] .. anchor[i+1] + // inclusive — the GPX writer's reverse operation. + const newSegments: Array> = []; + for (let i = 0; i < sortedAnchorIdx.length - 1; i++) { + const start = sortedAnchorIdx[i]; + const end = sortedAnchorIdx[i + 1]; + const seg: Array<[number, number, number?]> = []; + for (let j = start; j <= end; j++) { + const t = trk[j]; + seg.push([t.lng, t.lat, typeof t.altitude === 'number' ? t.altitude : undefined]); + } + newSegments.push(seg); + } + + const newSources: SegmentSource[] = []; + for (let i = 0; i < newWaypoints.length - 1; i++) { + newSources.push(makeSource(newWaypoints[i], newWaypoints[i + 1])); + } + + // Atomic swap. + builder.name = trackName ?? builder.name ?? ''; + // Disable auto-snap so the imported densified/snapped geometry isn't + // immediately overwritten by a routing API call. + builder.autoSnap = false; + builder.waypoints.splice(0, builder.waypoints.length, ...newWaypoints); + builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegments); + builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources); + scheduleSave(); + + return { + ok: true, + trackName, + waypointCount: newWaypoints.length, + imageCount: imageAnchors.length + }; +} diff --git a/src/lib/gpx.ts b/src/lib/gpx.ts index a0060a0c..584c1cfd 100644 --- a/src/lib/gpx.ts +++ b/src/lib/gpx.ts @@ -4,6 +4,129 @@ * GPX export without dragging in Node-only helpers. */ +// --------------------------------------------------------------------------- +// GPX parsing (pure, regex-based — no DOM / no XML library, so usable in +// build scripts, server endpoints, and the browser alike). +// --------------------------------------------------------------------------- + +export interface GpxPoint { + lat: number; + lng: number; + altitude?: number; + timestamp: number; +} + +/** Haversine distance in km between two GpxPoints. */ +export function haversineKm(a: GpxPoint, b: GpxPoint): number { + const R = 6371; + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLng = ((b.lng - a.lng) * Math.PI) / 180; + const sinLat = Math.sin(dLat / 2); + const sinLng = Math.sin(dLng / 2); + const h = + sinLat * sinLat + + Math.cos((a.lat * Math.PI) / 180) * + Math.cos((b.lat * Math.PI) / 180) * + sinLng * sinLng; + return 2 * R * Math.asin(Math.sqrt(h)); +} + +/** Sum of consecutive haversine distances in km. */ +export function trackDistance(track: GpxPoint[]): number { + let total = 0; + for (let i = 1; i < track.length; i++) { + total += haversineKm(track[i - 1], track[i]); + } + return total; +} + +/** + * Parse a GPX XML string into an array of GpxPoints. + * Extracts ``/`` with optional `` and `