trails/apps/mobile/lib/editor/use-route-editor.ts
Ullrich Schäfer d45f1e14fd
Fix route display: parse track coordinates from GPX on init
The route polyline wasn't visible because segments were initialized
empty. Now extractSegmentsFromGpx() parses trkpt coordinates from
the GPX string via regex on init, so the route shows immediately
in both view and edit modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:10 +02:00

235 lines
6.8 KiB
TypeScript

import { useState, useCallback, useRef } from "react";
import type { Waypoint } from "@trails-cool/types";
import type { RouteDetail } from "../api-client";
import { updateRoute } from "../api-client";
import { getServerUrl } from "../server-config";
import { generateGpx } from "@trails-cool/gpx";
export interface RouteSegment {
coordinates: [number, number][];
}
export interface EditorState {
waypoints: Waypoint[];
segments: RouteSegment[];
dirty: boolean;
computing: boolean;
saving: boolean;
error: string | null;
}
export function useRouteEditor(route: RouteDetail) {
const [state, setState] = useState<EditorState>(() => {
const waypoints = extractWaypoints(route);
const segments = extractSegmentsFromGpx(route.gpx);
return {
waypoints,
segments,
dirty: false,
computing: false,
saving: false,
error: null,
};
});
const segmentsRef = useRef(state.segments);
segmentsRef.current = state.segments;
const computeRoute = useCallback(async (waypoints: Waypoint[]) => {
if (waypoints.length < 2) {
setState((s) => ({ ...s, segments: [], computing: false }));
return;
}
setState((s) => ({ ...s, computing: true, error: null }));
try {
const serverUrl = await getServerUrl();
const resp = await fetch(`${serverUrl}/api/v1/routes/compute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: waypoints.map((w) => ({ lat: w.lat, lon: w.lon })),
profile: route.routingProfile ?? "fastbike",
}),
});
if (!resp.ok) {
setState((s) => ({ ...s, computing: false, error: "Route computation failed" }));
return;
}
const geojson = await resp.json();
const coords = extractCoordsFromGeojson(geojson);
setState((s) => ({
...s,
segments: [{ coordinates: coords }],
computing: false,
}));
} catch {
setState((s) => ({ ...s, computing: false, error: "Route computation failed" }));
}
}, [route.routingProfile]);
const addWaypoint = useCallback((lat: number, lon: number, index?: number) => {
setState((s) => {
const wps = [...s.waypoints];
const wp: Waypoint = { lat, lon };
if (index !== undefined) {
wps.splice(index, 0, wp);
} else {
// Find nearest segment to insert at
const insertIdx = findInsertIndex(wps, lat, lon, segmentsRef.current);
wps.splice(insertIdx, 0, wp);
}
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const moveWaypoint = useCallback((index: number, lat: number, lon: number) => {
setState((s) => {
const wps = [...s.waypoints];
wps[index] = { ...wps[index]!, lat, lon };
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const deleteWaypoint = useCallback((index: number) => {
setState((s) => {
const wps = s.waypoints.filter((_, i) => i !== index);
computeRoute(wps);
return { ...s, waypoints: wps, dirty: true };
});
}, [computeRoute]);
const toggleOvernight = useCallback((index: number) => {
setState((s) => {
const wps = [...s.waypoints];
const wp = wps[index]!;
wps[index] = { ...wp, isDayBreak: !wp.isDayBreak };
return { ...s, waypoints: wps, dirty: true };
});
}, []);
const save = useCallback(async () => {
setState((s) => ({ ...s, saving: true, error: null }));
try {
const tracks = state.segments.map((seg) =>
seg.coordinates.map(([lon, lat]) => ({ lat, lon })),
);
const gpx = generateGpx({
name: route.name,
description: route.description,
waypoints: state.waypoints,
tracks,
});
await updateRoute(route.id, { gpx });
setState((s) => ({ ...s, saving: false, dirty: false }));
return true;
} catch {
setState((s) => ({ ...s, saving: false, error: "Failed to save" }));
return false;
}
}, [route.id, route.name, route.description, state.waypoints, state.segments]);
return {
...state,
addWaypoint,
moveWaypoint,
deleteWaypoint,
toggleOvernight,
save,
computeRoute,
};
}
function extractWaypoints(route: RouteDetail): Waypoint[] {
if (!route.gpx) return [];
try {
// Parse synchronously from the GPX string — waypoints are in the GPX
// We'll use a simple regex extraction since parseGpxAsync is async
const wpts: Waypoint[] = [];
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/g;
let match;
while ((match = wptRegex.exec(route.gpx)) !== null) {
const lat = parseFloat(match[1]!);
const lon = parseFloat(match[2]!);
const inner = match[3]!;
const nameMatch = inner.match(/<name>([^<]*)<\/name>/);
const typeMatch = inner.match(/<type>([^<]*)<\/type>/);
wpts.push({
lat,
lon,
name: nameMatch?.[1] ?? undefined,
isDayBreak: typeMatch?.[1] === "overnight" ? true : undefined,
});
}
return wpts;
} catch {
return [];
}
}
function extractSegmentsFromGpx(gpx: string | null): RouteSegment[] {
if (!gpx) return [];
try {
const segments: RouteSegment[] = [];
const trkptRegex = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"/g;
const coordinates: [number, number][] = [];
let match;
while ((match = trkptRegex.exec(gpx)) !== null) {
const lat = parseFloat(match[1]!);
const lon = parseFloat(match[2]!);
coordinates.push([lon, lat]); // GeoJSON order: [lon, lat]
}
if (coordinates.length > 0) {
segments.push({ coordinates });
}
return segments;
} catch {
return [];
}
}
function extractCoordsFromGeojson(geojson: unknown): [number, number][] {
try {
const features = (geojson as { features?: unknown[] })?.features;
if (!features?.[0]) return [];
const geometry = (features[0] as { geometry?: { coordinates?: number[][] } })?.geometry;
return (geometry?.coordinates ?? []) as [number, number][];
} catch {
return [];
}
}
function findInsertIndex(
waypoints: Waypoint[],
lat: number,
lon: number,
segments: RouteSegment[],
): number {
if (waypoints.length < 2) return waypoints.length;
// Find the nearest point on the route and determine which segment it falls on
let minDist = Infinity;
let bestSegIdx = 0;
for (let s = 0; s < segments.length; s++) {
const coords = segments[s]!.coordinates;
for (const [cLon, cLat] of coords) {
const dist = (cLat - lat) ** 2 + (cLon - lon) ** 2;
if (dist < minDist) {
minDist = dist;
bestSegIdx = s;
}
}
}
// Insert after the segment's start waypoint
return Math.min(bestSegIdx + 1, waypoints.length);
}