Merge pull request #158 from trails-cool/feat/export-plan-with-nogos
Add split export: Export Route vs Export Plan
This commit is contained in:
commit
516b86aa3d
17 changed files with 470 additions and 65 deletions
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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<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 }) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
// Get waypoints from Yjs
|
||||
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,
|
||||
}));
|
||||
// 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 (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
{t("exportGpx")}
|
||||
</button>
|
||||
<div ref={ref} className="relative z-[1001]">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={handleExportRoute}
|
||||
className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
{t("exportGpx")}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
className="rounded-r border-l border-gray-300 bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-56 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 hover:bg-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-700">{t("exportRoute")}</span>
|
||||
<span className="block text-xs text-gray-400">{t("exportRouteDesc")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPlan}
|
||||
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-700">{t("exportPlan")}</span>
|
||||
<span className="block text-xs text-gray-400">{t("exportPlanDesc")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown>) => ({
|
||||
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<unknown>) => ({
|
||||
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<unknown>) => ({
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
return getDb()
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -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<YjsState | null>(null);
|
||||
const providerRef = useRef<WebsocketProvider | null>(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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
|
|
@ -61,6 +66,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) {
|
|||
callbackToken={loaderData.callbackToken ?? undefined}
|
||||
returnUrl={returnUrl}
|
||||
initialWaypoints={initialWaypoints}
|
||||
initialNoGoAreas={initialNoGoAreas}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 `<extensions>` 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 `<extensions>`
|
||||
- **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
|
||||
<gpx xmlns:trails="https://trails.cool/gpx/1">
|
||||
<extensions>
|
||||
<trails:planning>
|
||||
<trails:nogo>
|
||||
<trails:point lat="52.5" lon="13.3"/>
|
||||
<trails:point lat="52.4" lon="13.4"/>
|
||||
<trails:point lat="52.3" lon="13.2"/>
|
||||
</trails:nogo>
|
||||
</trails:planning>
|
||||
</extensions>
|
||||
</gpx>
|
||||
```
|
||||
|
||||
- Each `<trails:nogo>` element contains 3+ `<trails:point>` elements
|
||||
- Parser accepts both namespaced (`trails:nogo`) and non-namespaced (`nogo`) elements
|
||||
- Areas with fewer than 3 points are rejected on parse
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<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) {
|
||||
|
|
@ -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>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
218
packages/gpx/src/nogo.test.ts
Normal file
218
packages/gpx/src/nogo.test.ts
Normal file
|
|
@ -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("<extensions>");
|
||||
});
|
||||
|
||||
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("<extensions>");
|
||||
expect(gpx).toContain("<trails:planning>");
|
||||
expect(gpx).toContain("<trails:nogo>");
|
||||
expect(gpx).toContain('<trails:point lat="52.5" lon="13.3"/>');
|
||||
expect(gpx).toContain('<trails:point lat="52.4" lon="13.4"/>');
|
||||
expect(gpx).toContain('<trails:point lat="52.3" lon="13.2"/>');
|
||||
});
|
||||
|
||||
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(/<trails:nogo>/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-go area parsing", () => {
|
||||
it("parses namespaced no-go areas", async () => {
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:trails="https://trails.cool/gpx/1">
|
||||
<extensions>
|
||||
<trails:planning>
|
||||
<trails:nogo>
|
||||
<trails:point lat="52.5" lon="13.3"/>
|
||||
<trails:point lat="52.4" lon="13.4"/>
|
||||
<trails:point lat="52.3" lon="13.2"/>
|
||||
</trails:nogo>
|
||||
</trails:planning>
|
||||
</extensions>
|
||||
</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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<extensions>
|
||||
<planning>
|
||||
<nogo>
|
||||
<point lat="52.5" lon="13.3"/>
|
||||
<point lat="52.4" lon="13.4"/>
|
||||
<point lat="52.3" lon="13.2"/>
|
||||
</nogo>
|
||||
</planning>
|
||||
</extensions>
|
||||
</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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<extensions><planning>
|
||||
<nogo><point lat="1" lon="2"/><point lat="3" lon="4"/></nogo>
|
||||
</planning></extensions>
|
||||
</gpx>`;
|
||||
const data = await parseGpxAsync(gpx);
|
||||
expect(data.noGoAreas).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when no extensions", async () => {
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg><trkpt lat="52" lon="13"/></trkseg></trk>
|
||||
</gpx>`;
|
||||
const data = await parseGpxAsync(gpx);
|
||||
expect(data.noGoAreas).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses multiple no-go areas", async () => {
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<extensions><planning>
|
||||
<nogo>
|
||||
<point lat="1" lon="2"/><point lat="3" lon="4"/><point lat="5" lon="6"/>
|
||||
</nogo>
|
||||
<nogo>
|
||||
<point lat="7" lon="8"/><point lat="9" lon="10"/><point lat="11" lon="12"/>
|
||||
</nogo>
|
||||
</planning></extensions>
|
||||
</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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
|
||||
<wpt lat="48.137" lon="11.576"><name>Munich</name></wpt>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"/>
|
||||
<trkpt lat="48.137" lon="11.576"/>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<trkseg><trkpt lat="52" lon="13"/><trkpt lat="51" lon="13"/></trkseg>
|
||||
<trkseg><trkpt lat="51" lon="13"/><trkpt lat="50" lon="12"/></trkseg>
|
||||
<trkseg><trkpt lat="50" lon="12"/><trkpt lat="48" lon="11"/></trkseg>
|
||||
</trk>
|
||||
</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(`<trkpt lat="${lat}" lon="${lon}"/>`);
|
||||
}
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg>${points.join("")}</trkseg></trk>
|
||||
</gpx>`;
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg></trkseg></trk>
|
||||
</gpx>`;
|
||||
const data = await parseGpxAsync(gpx);
|
||||
const wps = extractWaypoints(data);
|
||||
expect(wps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue