trails/packages/gpx/src/generate.ts
Ullrich Schäfer 4b711abaec
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>
2026-04-03 10:52:39 +01:00

80 lines
2.3 KiB
TypeScript

import type { Waypoint } from "@trails-cool/types";
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"' +
(hasExtensions ? '\n xmlns:trails="https://trails.cool/gpx/1"' : "") +
">",
];
if (options.name) {
lines.push(" <metadata>", ` <name>${escapeXml(options.name)}</name>`, " </metadata>");
}
if (options.waypoints) {
for (const wpt of options.waypoints) {
lines.push(` <wpt lat="${wpt.lat}" lon="${wpt.lon}">`);
if (wpt.name) {
lines.push(` <name>${escapeXml(wpt.name)}</name>`);
}
lines.push(" </wpt>");
}
}
if (options.tracks) {
for (const track of options.tracks) {
lines.push(" <trk>", " <trkseg>");
for (const pt of track) {
lines.push(` <trkpt lat="${pt.lat}" lon="${pt.lon}">`);
if (pt.ele !== undefined) {
lines.push(` <ele>${pt.ele}</ele>`);
}
if (pt.time) {
lines.push(` <time>${escapeXml(pt.time)}</time>`);
}
lines.push(" </trkpt>");
}
lines.push(" </trkseg>", " </trk>");
}
}
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");
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}