diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts index 5bd38cf..61c809e 100644 --- a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts +++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts @@ -36,13 +36,17 @@ export async function action({ params, request }: Route.ActionArgs) { const session = (await sessionResp.json()) as { url: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; }; - // Encode waypoints in URL params (small — just coordinates, not full GPX) + // Encode planning data in URL params const urlParams = new URLSearchParams({ returnUrl }); if (session.initialWaypoints?.length) { urlParams.set("waypoints", JSON.stringify(session.initialWaypoints)); } + if (session.initialNoGoAreas?.length) { + urlParams.set("noGoAreas", JSON.stringify(session.initialNoGoAreas)); + } return data({ url: `${plannerUrl}${session.url}?${urlParams}`, diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index b4dff4e..f143c09 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -1,60 +1,112 @@ -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 mousedown + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", 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..52f1138 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -3,7 +3,7 @@ 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"; interface SaveToJournalButtonProps { yjs: YjsState; @@ -23,13 +23,8 @@ 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 with planning data (no-go areas) + // so the route round-trips correctly through the journal. let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -42,7 +37,17 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } catch { /* invalid geojson */ } } - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ + points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], + })).filter((a) => a.points.length >= 3); + + 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, + })); + + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); // POST to Journal callback const response = await fetch(callbackUrl, { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0c2ea56..16a0a19 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -179,12 +179,13 @@ interface SessionViewProps { callbackToken?: string; returnUrl?: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; } -export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) { +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas }: SessionViewProps) { const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); - const yjs = useYjs(sessionId, initialWaypoints); + const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas); const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); const { toasts, addToast } = useToasts(); 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/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 862b72a..bec224d 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -42,6 +42,7 @@ export interface YjsState { export function useYjs( sessionId: string, initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>, + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>, ): YjsState | null { const [state, setState] = useState(null); const providerRef = useRef(null); @@ -106,6 +107,13 @@ export function useYjs( if (wp.name) yMap.set("name", wp.name); waypoints.push([yMap]); } + if (initialNoGoAreas?.length && noGoAreas.length === 0) { + for (const area of initialNoGoAreas) { + const yMap = new Y.Map(); + yMap.set("points", area.points); + noGoAreas.push([yMap]); + } + } }); } }); diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 3364385..fd7df07 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -20,18 +20,20 @@ export async function action({ request }: Route.ActionArgs) { const session = await createSession({ callbackUrl, callbackToken }); let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined; + let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined; if (gpx) { try { const gpxData = await parseGpxAsync(gpx); const wps = extractWaypoints(gpxData); if (wps.length > 0) initialWaypoints = wps; + if (gpxData.noGoAreas.length > 0) initialNoGoAreas = gpxData.noGoAreas; } catch { // Continue with empty session if GPX is invalid } } return data( - { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints }, + { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints, initialNoGoAreas }, { status: 201 }, ); }); 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/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index 2c630d7..33b8311 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -37,6 +37,11 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { if (waypointsParam) { try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ } } + const noGoParam = searchParams.get("noGoAreas"); + let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined; + if (noGoParam) { + try { initialNoGoAreas = JSON.parse(noGoParam); } catch { /* ignore */ } + } return (
@@ -61,6 +66,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { callbackToken={loaderData.callbackToken ?? undefined} returnUrl={returnUrl} initialWaypoints={initialWaypoints} + initialNoGoAreas={initialNoGoAreas} /> )} diff --git a/openspec/specs/no-go-areas/spec.md b/openspec/specs/no-go-areas/spec.md index dd6bfe4..5c4c60d 100644 --- a/openspec/specs/no-go-areas/spec.md +++ b/openspec/specs/no-go-areas/spec.md @@ -13,5 +13,44 @@ Users SHALL be able to draw polygons on the map that BRouter avoids when computi - **AND** BRouter routes around the no-go area #### Scenario: Delete no-go area -- **WHEN** a user deletes a no-go area polygon +- **WHEN** a user right-clicks a no-go area polygon - **THEN** it is removed from the Yjs doc and the route is recomputed + +### Requirement: Persist no-go areas in GPX +No-go areas SHALL be preserved when saving to the journal or exporting a plan. + +#### Scenario: Save to Journal +- **WHEN** a user saves a route to the journal from the planner +- **THEN** the GPX includes no-go area polygons in `` using the `trails:planning` namespace +- **AND** editing the route in the planner restores the no-go areas + +#### Scenario: Export Plan +- **WHEN** a user exports a plan (via "Export Plan" dropdown option) +- **THEN** the GPX includes waypoints, track, and no-go areas in `` +- **AND** reimporting the plan into the planner restores all planning data + +#### Scenario: Export Route +- **WHEN** a user exports a route (default export or "Export Route" dropdown option) +- **THEN** the GPX includes only the computed track (no waypoints, no extensions) +- **AND** the file is compatible with any GPX-consuming application + +### Requirement: GPX extensions format +No-go areas are stored in GPX using a custom XML namespace: + +```xml + + + + + + + + + + + +``` + +- Each `` element contains 3+ `` elements +- Parser accepts both namespaced (`trails:nogo`) and non-namespaced (`nogo`) elements +- Areas with fewer than 3 points are rejected on parse 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/nogo.test.ts b/packages/gpx/src/nogo.test.ts new file mode 100644 index 0000000..2f6bff0 --- /dev/null +++ b/packages/gpx/src/nogo.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "./generate.ts"; +import { parseGpxAsync } from "./parse.ts"; +import { extractWaypoints } from "./waypoints.ts"; + +describe("no-go area generation", () => { + it("includes trails namespace when no-go areas are present", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52, lon: 13 }, { lat: 51, lon: 13 }, { lat: 51, lon: 14 }] }], + }); + expect(gpx).toContain('xmlns:trails="https://trails.cool/gpx/1"'); + }); + + it("does not include trails namespace without no-go areas", () => { + const gpx = generateGpx({ name: "test" }); + expect(gpx).not.toContain("xmlns:trails"); + expect(gpx).not.toContain(""); + }); + + it("generates correct extension structure", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }], + }); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + }); + + it("generates multiple no-go areas", () => { + const gpx = generateGpx({ + noGoAreas: [ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] }, + { points: [{ lat: 7, lon: 8 }, { lat: 9, lon: 10 }, { lat: 11, lon: 12 }] }, + ], + }); + const matches = gpx.match(//g); + expect(matches).toHaveLength(2); + }); +}); + +describe("no-go area parsing", () => { + it("parses namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + expect(data.noGoAreas[0]!.points[0]).toEqual({ lat: 52.5, lon: 13.3 }); + }); + + it("parses non-namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + }); + + it("rejects no-go areas with fewer than 3 points", async () => { + const gpx = ` + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("returns empty array when no extensions", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("parses multiple no-go areas", async () => { + const gpx = ` + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(2); + }); +}); + +describe("no-go area round-trip", () => { + it("round-trips no-go areas through generate and parse", async () => { + const noGoAreas = [ + { points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }, + { points: [{ lat: 48.1, lon: 11.5 }, { lat: 48.0, lon: 11.6 }, { lat: 47.9, lon: 11.4 }] }, + ]; + const gpx = generateGpx({ + name: "No-go test", + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas, + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("No-go test"); + expect(parsed.noGoAreas).toHaveLength(2); + expect(parsed.noGoAreas[0]!.points).toEqual(noGoAreas[0]!.points); + expect(parsed.noGoAreas[1]!.points).toEqual(noGoAreas[1]!.points); + }); + + it("round-trips complete planning state", async () => { + const gpx = generateGpx({ + name: "Full plan", + waypoints: [{ lat: 52.5, lon: 13.3, name: "Berlin" }, { lat: 48.1, lon: 11.5, name: "Munich" }], + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 50.0, lon: 12.0, ele: 300 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas: [{ points: [{ lat: 51, lon: 12 }, { lat: 50, lon: 13 }, { lat: 50, lon: 11 }] }], + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("Full plan"); + expect(parsed.waypoints).toHaveLength(2); + expect(parsed.tracks[0]).toHaveLength(3); + expect(parsed.noGoAreas).toHaveLength(1); + expect(parsed.distance).toBeGreaterThan(0); + }); +}); + +describe("extractWaypoints", () => { + it("returns explicit waypoints when present", async () => { + const gpx = ` + + Berlin + Munich + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(2); + expect(wps[0]!.name).toBe("Berlin"); + }); + + it("extracts segment endpoints for multi-segment tracks", async () => { + const gpx = ` + + + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Start of each segment (deduped) + end of last = 52, 51, 50, 48 + expect(wps.length).toBeGreaterThanOrEqual(3); + expect(wps[0]).toEqual({ lat: 52, lon: 13 }); + }); + + it("uses Douglas-Peucker for single-segment tracks", async () => { + // Build a track with a clear deviation in the middle + const points = []; + for (let i = 0; i <= 10; i++) { + const lat = 52 - i * 0.4; // straight south + const lon = i === 5 ? 14.5 : 13; // big eastward deviation at midpoint + points.push(``); + } + const gpx = ` + + ${points.join("")} + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Should include start, end, and the midpoint deviation + expect(wps.length).toBeGreaterThanOrEqual(3); + // The deviated midpoint should be preserved + const hasDeviation = wps.some((w) => Math.abs(w.lon - 14.5) < 0.01); + expect(hasDeviation).toBe(true); + }); + + it("returns empty for empty tracks", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(0); + }); +}); diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 1ff1b79..e43d249 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,23 @@ function parseTracks(doc: Document): TrackPoint[][] { return tracks; } +function parseNoGoAreas(doc: Document): NoGoArea[] { + const areas: NoGoArea[] = []; + // Match both unprefixed and prefixed (trails:nogo) elements + const nogos = doc.querySelectorAll("nogo, trails\\:nogo"); + for (const nogo of Array.from(nogos)) { + const points: Array<{ lat: number; lon: number }> = []; + const pts = nogo.querySelectorAll("point, trails\\: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..80917ca 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -18,6 +18,10 @@ export default { newSession: "Neue Sitzung", saveRoute: "Route speichern", exportGpx: "GPX exportieren", + exportRoute: "Route exportieren", + exportRouteDesc: "GPX-Track für jede App", + exportPlan: "Plan exportieren", + exportPlanDesc: "Mit Wegpunkten und Sperrzonen", 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..534bbb2 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -18,6 +18,10 @@ export default { newSession: "New Session", saveRoute: "Save Route", exportGpx: "Export GPX", + exportRoute: "Export Route", + exportRouteDesc: "Clean GPX track for any app", + exportPlan: "Export Plan", + exportPlanDesc: "Includes waypoints and no-go areas", profile: "Profile", connecting: "Connecting...", loadingMap: "Loading map...",