Add split export: Export Route vs Export Plan

Split button with default "Export GPX" (track only) and dropdown:
- Export Route: clean track for use in any app
- Export Plan: track + waypoints + no-go areas in GPX extensions
  (trails:planning namespace) for reimporting into the planner

Also:
- Add no-go area serialization to generateGpx (GPX extensions)
- Parse no-go areas from GPX extensions on import
- Initialize no-go areas in Yjs session from imported GPX
- Save to Journal now exports track only (consistent with Export Route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-03 10:52:39 +01:00
parent 237660db5e
commit 4b711abaec
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 172 additions and 60 deletions

View file

@ -1,60 +1,110 @@
import { useCallback } from "react"; import { useCallback, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as Y from "yjs"; import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx"; import { generateGpx } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx"; import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
function getTracks(yjs: YjsState): TrackPoint[][] {
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
if (!geojsonStr) return [];
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length > 0) {
return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
}
} catch { /* invalid geojson */ }
return [];
}
function getWaypoints(yjs: YjsState) {
return yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
}));
}
function getNoGoAreas(yjs: YjsState): NoGoArea[] {
return yjs.noGoAreas.toArray().map((yMap: Y.Map<unknown>) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
}
function download(gpx: string, filename: string) {
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function ExportButton({ yjs }: { yjs: YjsState }) { export function ExportButton({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner"); const { t } = useTranslation("planner");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const handleExport = useCallback(() => { // Close dropdown on outside click
// Get waypoints from Yjs useEffect(() => {
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({ if (!open) return;
lat: yMap.get("lat") as number, const handler = (e: MouseEvent) => {
lon: yMap.get("lon") as number, if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
name: yMap.get("name") as string | undefined, };
})); document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, [open]);
// Get route track from GeoJSON const handleExportRoute = useCallback(() => {
let tracks: TrackPoint[][] = []; const tracks = getTracks(yjs);
const geojsonStr = yjs.routeData.get("geojson") as string | undefined; const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
if (geojsonStr) { download(gpx, "route.gpx");
try { setOpen(false);
const geojson = JSON.parse(geojsonStr); }, [yjs]);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length > 0) {
tracks = [
coords.map((c) => ({
lat: c[1]!,
lon: c[0]!,
ele: c[2],
})),
];
}
} catch {
// Invalid GeoJSON
}
}
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); const handleExportPlan = useCallback(() => {
const tracks = getTracks(yjs);
// Download const waypoints = getWaypoints(yjs);
const blob = new Blob([gpx], { type: "application/gpx+xml" }); const noGoAreas = getNoGoAreas(yjs);
const url = URL.createObjectURL(blob); const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas });
const a = document.createElement("a"); download(gpx, "route-plan.gpx");
a.href = url; setOpen(false);
a.download = "route.gpx"; }, [yjs]);
a.click();
URL.revokeObjectURL(url);
}, [yjs.waypoints, yjs.routeData]);
return ( return (
<button <div ref={ref} className="relative">
onClick={handleExport} <div className="flex">
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200" <button
> onClick={handleExportRoute}
{t("exportGpx")} className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
</button> >
{t("exportGpx")}
</button>
<button
onClick={() => setOpen((v) => !v)}
className="rounded-r border-l border-gray-300 bg-gray-100 px-1.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
</button>
</div>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-gray-200 bg-white py-1 shadow-lg">
<button
onClick={handleExportRoute}
className="block w-full px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
>
{t("exportRoute")}
</button>
<button
onClick={handleExportPlan}
className="block w-full px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
>
{t("exportPlan")}
</button>
</div>
)}
</div>
); );
} }

View file

@ -23,13 +23,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
setError(null); setError(null);
try { try {
// Build GPX from current Yjs state // Build GPX from computed track (not editing waypoints).
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
}));
let tracks: TrackPoint[][] = []; let tracks: TrackPoint[][] = [];
const geojsonStr = yjs.routeData.get("geojson") as string | undefined; const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
if (geojsonStr) { if (geojsonStr) {
@ -42,7 +36,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
} catch { /* invalid geojson */ } } catch { /* invalid geojson */ }
} }
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
// POST to Journal callback // POST to Journal callback
const response = await fetch(callbackUrl, { const response = await fetch(callbackUrl, {

View file

@ -90,6 +90,22 @@ export function initializeSessionWithWaypoints(
}); });
} }
export function initializeSessionWithNoGoAreas(
sessionId: string,
noGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yNoGoAreas = doc.getArray("noGoAreas");
doc.transact(() => {
for (const area of noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yNoGoAreas.push([yMap]);
}
});
}
export async function listSessions(): Promise<SessionMetadata[]> { export async function listSessions(): Promise<SessionMetadata[]> {
return getDb() return getDb()
.select() .select()

View file

@ -1,6 +1,6 @@
import { data } from "react-router"; import { data } from "react-router";
import type { Route } from "./+types/api.sessions"; import type { Route } from "./+types/api.sessions";
import { createSession, listSessions } from "~/lib/sessions"; import { createSession, listSessions, initializeSessionWithNoGoAreas } from "~/lib/sessions";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { withDb } from "@trails-cool/db"; import { withDb } from "@trails-cool/db";
@ -25,6 +25,9 @@ export async function action({ request }: Route.ActionArgs) {
const gpxData = await parseGpxAsync(gpx); const gpxData = await parseGpxAsync(gpx);
const wps = extractWaypoints(gpxData); const wps = extractWaypoints(gpxData);
if (wps.length > 0) initialWaypoints = wps; if (wps.length > 0) initialWaypoints = wps;
if (gpxData.noGoAreas.length > 0) {
initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas);
}
} catch { } catch {
// Continue with empty session if GPX is invalid // Continue with empty session if GPX is invalid
} }

View file

@ -1,6 +1,6 @@
import { redirect, data } from "react-router"; import { redirect, data } from "react-router";
import type { Route } from "./+types/new"; import type { Route } from "./+types/new";
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; import { createSession, initializeSessionWithWaypoints, initializeSessionWithNoGoAreas } from "~/lib/sessions";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { checkRateLimit } from "~/lib/rate-limit"; import { checkRateLimit } from "~/lib/rate-limit";
@ -37,6 +37,9 @@ export async function loader({ request }: Route.LoaderArgs) {
const gpx = decodeURIComponent(gpxEncoded); const gpx = decodeURIComponent(gpxEncoded);
const gpxData = await parseGpxAsync(gpx); const gpxData = await parseGpxAsync(gpx);
initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData)); initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData));
if (gpxData.noGoAreas.length > 0) {
initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas);
}
} catch { } catch {
// Continue with empty session if GPX is invalid // Continue with empty session if GPX is invalid
} }

View file

@ -4,15 +4,23 @@ import type { TrackPoint } from "./types.ts";
/** /**
* Generate a GPX XML string from waypoints and track points. * Generate a GPX XML string from waypoints and track points.
*/ */
export interface NoGoArea {
points: Array<{ lat: number; lon: number }>;
}
export function generateGpx(options: { export function generateGpx(options: {
name?: string; name?: string;
waypoints?: Waypoint[]; waypoints?: Waypoint[];
tracks?: TrackPoint[][]; tracks?: TrackPoint[][];
noGoAreas?: NoGoArea[];
}): string { }): string {
const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0;
const lines: string[] = [ const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
'<gpx version="1.1" creator="trails.cool"', '<gpx version="1.1" creator="trails.cool"',
' xmlns="http://www.topografix.com/GPX/1/1">', ' xmlns="http://www.topografix.com/GPX/1/1"' +
(hasExtensions ? '\n xmlns:trails="https://trails.cool/gpx/1"' : "") +
">",
]; ];
if (options.name) { if (options.name) {
@ -46,6 +54,18 @@ export function generateGpx(options: {
} }
} }
if (options.noGoAreas && options.noGoAreas.length > 0) {
lines.push(" <extensions>", " <trails:planning>");
for (const area of options.noGoAreas) {
lines.push(" <trails:nogo>");
for (const pt of area.points) {
lines.push(` <trails:point lat="${pt.lat}" lon="${pt.lon}"/>`);
}
lines.push(" </trails:nogo>");
}
lines.push(" </trails:planning>", " </extensions>");
}
lines.push("</gpx>"); lines.push("</gpx>");
return lines.join("\n"); return lines.join("\n");
} }

View file

@ -1,4 +1,4 @@
export { parseGpxAsync } from "./parse.ts"; export { parseGpxAsync } from "./parse.ts";
export { generateGpx } from "./generate.ts"; export { generateGpx } from "./generate.ts";
export { extractWaypoints } from "./waypoints.ts"; export { extractWaypoints } from "./waypoints.ts";
export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";

View file

@ -1,5 +1,5 @@
import type { Waypoint } from "@trails-cool/types"; import type { Waypoint } from "@trails-cool/types";
import type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
/** /**
* Parse a GPX XML string into structured data. * Parse a GPX XML string into structured data.
@ -46,9 +46,10 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData {
const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
const waypoints = parseWaypoints(doc); const waypoints = parseWaypoints(doc);
const tracks = parseTracks(doc); const tracks = parseTracks(doc);
const noGoAreas = parseNoGoAreas(doc);
const { totalDistance, ...elevation } = computeElevation(tracks); const { totalDistance, ...elevation } = computeElevation(tracks);
return { name, waypoints, tracks, distance: totalDistance, elevation }; return { name, waypoints, tracks, noGoAreas, distance: totalDistance, elevation };
} }
function parseWaypoints(doc: Document): Waypoint[] { function parseWaypoints(doc: Document): Waypoint[] {
@ -85,6 +86,22 @@ function parseTracks(doc: Document): TrackPoint[][] {
return tracks; return tracks;
} }
function parseNoGoAreas(doc: Document): NoGoArea[] {
const areas: NoGoArea[] = [];
const nogos = doc.querySelectorAll("nogo");
for (const nogo of Array.from(nogos)) {
const points: Array<{ lat: number; lon: number }> = [];
const pts = nogo.querySelectorAll("point");
for (const pt of Array.from(pts)) {
const lat = parseFloat(pt.getAttribute("lat") ?? "0");
const lon = parseFloat(pt.getAttribute("lon") ?? "0");
points.push({ lat, lon });
}
if (points.length >= 3) areas.push({ points });
}
return areas;
}
function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } { function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } {
let gain = 0; let gain = 0;
let loss = 0; let loss = 0;

View file

@ -14,10 +14,15 @@ export interface ElevationProfile {
elevation: number; elevation: number;
} }
export interface NoGoArea {
points: Array<{ lat: number; lon: number }>;
}
export interface GpxData { export interface GpxData {
name?: string; name?: string;
waypoints: Waypoint[]; waypoints: Waypoint[];
tracks: TrackPoint[][]; tracks: TrackPoint[][];
noGoAreas: NoGoArea[];
/** Total distance in meters (haversine, works with or without elevation data) */ /** Total distance in meters (haversine, works with or without elevation data) */
distance: number; distance: number;
elevation: { elevation: {

View file

@ -18,6 +18,8 @@ export default {
newSession: "Neue Sitzung", newSession: "Neue Sitzung",
saveRoute: "Route speichern", saveRoute: "Route speichern",
exportGpx: "GPX exportieren", exportGpx: "GPX exportieren",
exportRoute: "Route exportieren",
exportPlan: "Plan exportieren",
profile: "Profil", profile: "Profil",
connecting: "Verbinde...", connecting: "Verbinde...",
loadingMap: "Karte wird geladen...", loadingMap: "Karte wird geladen...",

View file

@ -18,6 +18,8 @@ export default {
newSession: "New Session", newSession: "New Session",
saveRoute: "Save Route", saveRoute: "Save Route",
exportGpx: "Export GPX", exportGpx: "Export GPX",
exportRoute: "Export Route",
exportPlan: "Export Plan",
profile: "Profile", profile: "Profile",
connecting: "Connecting...", connecting: "Connecting...",
loadingMap: "Loading map...", loadingMap: "Loading map...",