From 4b711abaec40047e686176cb7aba132d5dcc0d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:52:39 +0100 Subject: [PATCH] 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) --- apps/planner/app/components/ExportButton.tsx | 142 ++++++++++++------ .../app/components/SaveToJournalButton.tsx | 10 +- apps/planner/app/lib/sessions.ts | 16 ++ apps/planner/app/routes/api.sessions.ts | 5 +- apps/planner/app/routes/new.tsx | 5 +- packages/gpx/src/generate.ts | 22 ++- packages/gpx/src/index.ts | 2 +- packages/gpx/src/parse.ts | 21 ++- packages/gpx/src/types.ts | 5 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 11 files changed, 172 insertions(+), 60 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index b4dff4e..840e0b9 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -1,60 +1,110 @@ -import { useCallback } from "react"; +import { useCallback, useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; 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) => ({ + 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) => ({ + 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 }) { const { t } = useTranslation("planner"); + const [open, setOpen] = useState(false); + const ref = useRef(null); - const handleExport = useCallback(() => { - // Get waypoints from Yjs - const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - })); + // Close dropdown on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("click", handler); + return () => document.removeEventListener("click", handler); + }, [open]); - // Get route track from GeoJSON - let tracks: TrackPoint[][] = []; - const geojsonStr = yjs.routeData.get("geojson") as string | undefined; - if (geojsonStr) { - try { - const geojson = JSON.parse(geojsonStr); - 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 handleExportRoute = useCallback(() => { + const tracks = getTracks(yjs); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); + download(gpx, "route.gpx"); + setOpen(false); + }, [yjs]); - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); - - // Download - const blob = new Blob([gpx], { type: "application/gpx+xml" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "route.gpx"; - a.click(); - URL.revokeObjectURL(url); - }, [yjs.waypoints, yjs.routeData]); + const handleExportPlan = useCallback(() => { + const tracks = getTracks(yjs); + const waypoints = getWaypoints(yjs); + const noGoAreas = getNoGoAreas(yjs); + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); + download(gpx, "route-plan.gpx"); + setOpen(false); + }, [yjs]); return ( - +
+
+ + +
+ {open && ( +
+ + +
+ )} +
); } diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index af0389d..ae9b4d3 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -23,13 +23,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl setError(null); try { - // Build GPX from current Yjs state - const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - })); - + // Build GPX from computed track (not editing waypoints). let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -42,7 +36,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } 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 const response = await fetch(callbackUrl, { diff --git a/apps/planner/app/lib/sessions.ts b/apps/planner/app/lib/sessions.ts index 728f380..9b4bb33 100644 --- a/apps/planner/app/lib/sessions.ts +++ b/apps/planner/app/lib/sessions.ts @@ -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 { return getDb() .select() diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 3364385..f458bfc 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; 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 { withDb } from "@trails-cool/db"; @@ -25,6 +25,9 @@ export async function action({ request }: Route.ActionArgs) { const gpxData = await parseGpxAsync(gpx); const wps = extractWaypoints(gpxData); if (wps.length > 0) initialWaypoints = wps; + if (gpxData.noGoAreas.length > 0) { + initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas); + } } catch { // Continue with empty session if GPX is invalid } diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index 4150c29..121e0f6 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; 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 { checkRateLimit } from "~/lib/rate-limit"; @@ -37,6 +37,9 @@ export async function loader({ request }: Route.LoaderArgs) { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData)); + if (gpxData.noGoAreas.length > 0) { + initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas); + } } catch { // Continue with empty session if GPX is invalid } diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index d0f0268..1be51f2 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -4,15 +4,23 @@ import type { TrackPoint } from "./types.ts"; /** * Generate a GPX XML string from waypoints and track points. */ +export interface NoGoArea { + points: Array<{ lat: number; lon: number }>; +} + export function generateGpx(options: { name?: string; waypoints?: Waypoint[]; tracks?: TrackPoint[][]; + noGoAreas?: NoGoArea[]; }): string { + const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0; const lines: string[] = [ '', '', + ' xmlns="http://www.topografix.com/GPX/1/1"' + + (hasExtensions ? '\n xmlns:trails="https://trails.cool/gpx/1"' : "") + + ">", ]; if (options.name) { @@ -46,6 +54,18 @@ export function generateGpx(options: { } } + if (options.noGoAreas && options.noGoAreas.length > 0) { + lines.push(" ", " "); + for (const area of options.noGoAreas) { + lines.push(" "); + for (const pt of area.points) { + lines.push(` `); + } + lines.push(" "); + } + lines.push(" ", " "); + } + lines.push(""); return lines.join("\n"); } diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index f8887f2..6830ada 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,4 +1,4 @@ export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.ts"; export { extractWaypoints } from "./waypoints.ts"; -export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; +export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 1ff1b79..18c577e 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -1,5 +1,5 @@ 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. @@ -46,9 +46,10 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const waypoints = parseWaypoints(doc); const tracks = parseTracks(doc); + const noGoAreas = parseNoGoAreas(doc); 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[] { @@ -85,6 +86,22 @@ function parseTracks(doc: Document): TrackPoint[][] { 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 } { let gain = 0; let loss = 0; diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts index bb7d0a2..9d8a30f 100644 --- a/packages/gpx/src/types.ts +++ b/packages/gpx/src/types.ts @@ -14,10 +14,15 @@ export interface ElevationProfile { elevation: number; } +export interface NoGoArea { + points: Array<{ lat: number; lon: number }>; +} + export interface GpxData { name?: string; waypoints: Waypoint[]; tracks: TrackPoint[][]; + noGoAreas: NoGoArea[]; /** Total distance in meters (haversine, works with or without elevation data) */ distance: number; elevation: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 72080a3..14fb3b1 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -18,6 +18,8 @@ export default { newSession: "Neue Sitzung", saveRoute: "Route speichern", exportGpx: "GPX exportieren", + exportRoute: "Route exportieren", + exportPlan: "Plan exportieren", profile: "Profil", connecting: "Verbinde...", loadingMap: "Karte wird geladen...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 51af6f7..482a07e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -18,6 +18,8 @@ export default { newSession: "New Session", saveRoute: "Save Route", exportGpx: "Export GPX", + exportRoute: "Export Route", + exportPlan: "Export Plan", profile: "Profile", connecting: "Connecting...", loadingMap: "Loading map...",