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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.76.1",
|
"version": "1.77.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { parseGpx, parseGpxImageRefs } from '$lib/gpx';
|
||||||
|
|
||||||
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
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
@@ -4,6 +4,129 @@
|
|||||||
* GPX export without dragging in Node-only helpers.
|
* 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 {
|
export interface GpxWritePoint {
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
|
|||||||
+13
-116
@@ -1,119 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Shared GPX helpers used by the fitness API and the hikes build pipeline.
|
* Server-side GPX entry point. The parsers themselves are dependency-free
|
||||||
* Kept dependency-free so the same module is callable from server routes,
|
* and live in `$lib/gpx` (also imported by the browser-side route-builder
|
||||||
* vite-node build scripts, and (where useful) the browser.
|
* 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 {
|
export {
|
||||||
lat: number;
|
parseGpx,
|
||||||
lng: number;
|
parseGpxImageRefs,
|
||||||
altitude?: number;
|
trackDistance,
|
||||||
timestamp: number;
|
haversineKm as haversine,
|
||||||
}
|
type GpxPoint,
|
||||||
|
type GpxImageRef
|
||||||
/** Haversine distance in km between two points. */
|
} from '$lib/gpx';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
setElevations,
|
setElevations,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
reconcileSegments,
|
reconcileSegments,
|
||||||
densifyLinearSegments
|
densifyLinearSegments,
|
||||||
|
importGpx
|
||||||
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
||||||
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -201,6 +202,46 @@
|
|||||||
URL.revokeObjectURL(url);
|
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
|
// Placement coordination: which unplaced waypoint is currently waiting for
|
||||||
// a click on the map?
|
// a click on the map?
|
||||||
let pendingPlacementId = $state<string | null>(null);
|
let pendingPlacementId = $state<string | null>(null);
|
||||||
@@ -255,6 +296,21 @@
|
|||||||
>
|
>
|
||||||
GPX herunterladen
|
GPX herunterladen
|
||||||
</button>
|
</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>
|
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user