feat(route-builder): import existing GPX (round-trip editing)

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 `<trkseg>`. The importer doesn't
try to perfectly round-trip "manual waypoint vs intermediate";
instead it recovers the *image* anchors by matching `<wpt>`
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.
This commit is contained in:
2026-05-19 17:29:34 +02:00
parent 7b7fbed472
commit 59f40b9f05
5 changed files with 337 additions and 118 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.76.1",
"version": "1.77.0",
"private": true,
"type": "module",
"scripts": {
@@ -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 `<trkseg>`. We don't
* try to round-trip "manual waypoints" vs "intermediates" perfectly —
* instead we recover the *image* anchors (matched against `<wpt>` 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 <name> on the track or top-level metadata.
const nameMatch =
xml.match(/<trk>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/trk>/i) ??
xml.match(/<metadata>[\s\S]*?<name>([^<]+)<\/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<number>([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<Array<[number, number, number?]>> = [];
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
};
}
+123
View File
@@ -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 `<trkpt>`/`<rtept>` with optional `<ele>` and `<time>`.
* Falls back to `Date.now()` when no timestamp is present so downstream
* consumers always have a numeric `timestamp` field.
*/
export function parseGpx(xml: string): GpxPoint[] {
const points: GpxPoint[] = [];
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
let match;
while ((match = trkptRegex.exec(xml)) !== null) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
const body = match[3];
let altitude: number | undefined;
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
if (eleMatch) altitude = parseFloat(eleMatch[1]);
let timestamp = Date.now();
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
if (!isNaN(lat) && !isNaN(lng)) {
points.push({ lat, lng, altitude, timestamp });
}
}
return points;
}
export interface GpxImageRef {
hash: string;
name?: string;
lat: number;
lng: number;
altitude?: number;
timestamp?: number;
visibility?: 'public' | 'private';
}
/**
* Parse standalone `<wpt>` waypoints that carry a `<bocken:image hash="…"/>`
* extension. Returned as a hash → ref map so the build script can look up an
* image's corrected position by content hash. Waypoints without the image
* extension are ignored.
*/
export function parseGpxImageRefs(xml: string): Record<string, GpxImageRef> {
const out: Record<string, GpxImageRef> = {};
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
const body = match[3];
// Accept either namespaced (`bocken:image`) or bare (`image`) tags so
// the parser tolerates GPX files produced by other tooling that may
// drop our custom namespace prefix.
const imageMatch = body.match(/<(?:[A-Za-z]+:)?image\s+([^/>]*?)\/?>/i);
if (!imageMatch) continue;
const attrs = imageMatch[1];
const hashAttr = attrs.match(/\bhash="([^"]+)"/i);
if (!hashAttr) continue;
const hash = hashAttr[1];
const visibilityAttr = attrs.match(/\bvisibility="([^"]+)"/i);
const visibility: 'public' | 'private' =
visibilityAttr && visibilityAttr[1].toLowerCase() === 'private' ? 'private' : 'public';
const nameMatch = body.match(/<name>([^<]+)<\/name>/);
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
if (isNaN(lat) || isNaN(lng)) continue;
out[hash] = {
hash,
name: nameMatch ? nameMatch[1].trim() : undefined,
lat,
lng,
altitude: eleMatch ? parseFloat(eleMatch[1]) : undefined,
timestamp: timeMatch ? new Date(timeMatch[1]).getTime() : undefined,
visibility
};
}
return out;
}
// ---------------------------------------------------------------------------
// GPX writing (used by the route-builder export + fitness GPX export).
// ---------------------------------------------------------------------------
export interface GpxWritePoint {
lat: number;
lng: number;
+13 -116
View File
@@ -1,119 +1,16 @@
/**
* Shared GPX helpers used by the fitness API and the hikes build pipeline.
* Kept dependency-free so the same module is callable from server routes,
* vite-node build scripts, and (where useful) the browser.
* Server-side GPX entry point. The parsers themselves are dependency-free
* and live in `$lib/gpx` (also imported by the browser-side route-builder
* for the import feature). This shim re-exports them under the historical
* server-module path so existing callers (build script, fitness API)
* don't need to change their imports.
*/
export interface GpxPoint {
lat: number;
lng: number;
altitude?: number;
timestamp: number;
}
/** Haversine distance in km between two points. */
export function haversine(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 += haversine(track[i - 1], track[i]);
}
return total;
}
/**
* Parse a GPX XML string into an array of GpxPoints.
* Extracts `<trkpt>`/`<rtept>` with optional `<ele>` and `<time>`.
* Falls back to `Date.now()` when no timestamp is present so downstream
* consumers always have a numeric `timestamp` field.
*/
export function parseGpx(xml: string): GpxPoint[] {
const points: GpxPoint[] = [];
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
let match;
while ((match = trkptRegex.exec(xml)) !== null) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
const body = match[3];
let altitude: number | undefined;
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
if (eleMatch) altitude = parseFloat(eleMatch[1]);
let timestamp = Date.now();
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
if (!isNaN(lat) && !isNaN(lng)) {
points.push({ lat, lng, altitude, timestamp });
}
}
return points;
}
export interface GpxImageRef {
hash: string;
name?: string;
lat: number;
lng: number;
altitude?: number;
timestamp?: number;
visibility?: 'public' | 'private';
}
/**
* Parse standalone `<wpt>` waypoints that carry a `<bocken:image hash="…"/>`
* extension. Returned as a hash → ref map so the build script can look up an
* image's corrected position by content hash. Waypoints without the image
* extension are ignored.
*/
export function parseGpxImageRefs(xml: string): Record<string, GpxImageRef> {
const out: Record<string, GpxImageRef> = {};
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
const body = match[3];
// Accept either namespaced (`bocken:image`) or bare (`image`) tags so
// the parser tolerates GPX files produced by other tooling that may
// drop our custom namespace prefix.
const imageMatch = body.match(/<(?:[A-Za-z]+:)?image\s+([^/>]*?)\/?>/i);
if (!imageMatch) continue;
const attrs = imageMatch[1];
const hashAttr = attrs.match(/\bhash="([^"]+)"/i);
if (!hashAttr) continue;
const hash = hashAttr[1];
const visibilityAttr = attrs.match(/\bvisibility="([^"]+)"/i);
const visibility: 'public' | 'private' =
visibilityAttr && visibilityAttr[1].toLowerCase() === 'private' ? 'private' : 'public';
const nameMatch = body.match(/<name>([^<]+)<\/name>/);
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
if (isNaN(lat) || isNaN(lng)) continue;
out[hash] = {
hash,
name: nameMatch ? nameMatch[1].trim() : undefined,
lat,
lng,
altitude: eleMatch ? parseFloat(eleMatch[1]) : undefined,
timestamp: timeMatch ? new Date(timeMatch[1]).getTime() : undefined,
visibility
};
}
return out;
}
export {
parseGpx,
parseGpxImageRefs,
trackDistance,
haversineKm as haversine,
type GpxPoint,
type GpxImageRef
} from '$lib/gpx';
+57 -1
View File
@@ -11,7 +11,8 @@
setElevations,
clearDraft,
reconcileSegments,
densifyLinearSegments
densifyLinearSegments,
importGpx
} from '$lib/components/hikes/route-builder/builderStore.svelte';
let busy = $state(false);
@@ -201,6 +202,46 @@
URL.revokeObjectURL(url);
}
// GPX import: file input is hidden; the visible "GPX laden" button
// proxies its click. Imported route REPLACES the current draft, so
// confirm first when there's existing work to avoid silent data loss.
let gpxFileInput: HTMLInputElement | undefined = $state();
function openGpxPicker() {
if (
builder.waypoints.length > 0 &&
!confirm(
'Bestehenden Entwurf durch importierte GPX ersetzen? Aktuelle Wegpunkte gehen verloren.'
)
) {
return;
}
gpxFileInput?.click();
}
async function onGpxSelected(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
const result = importGpx(xml);
if (!result.ok) {
error = result.error;
return;
}
error = null;
// Cancel any in-flight enrichment so it doesn't overwrite the
// freshly-imported geometry.
routeRequestId++;
} catch (err) {
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
} finally {
// Reset so the same file can be re-selected later.
input.value = '';
}
}
// Placement coordination: which unplaced waypoint is currently waiting for
// a click on the map?
let pendingPlacementId = $state<string | null>(null);
@@ -255,6 +296,21 @@
>
GPX herunterladen
</button>
<button
type="button"
class="link"
onclick={openGpxPicker}
title="Eine zuvor exportierte GPX-Datei in den Editor laden"
>
GPX laden
</button>
<input
bind:this={gpxFileInput}
type="file"
accept=".gpx,application/gpx+xml,application/xml,text/xml"
onchange={onGpxSelected}
hidden
/>
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
</div>
</header>