From 774e2b1ae32cc62847846a204ed0cd6684cc016d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 00:04:48 +0200 Subject: [PATCH] Add multi-track GPX export option (splitByDay) generateGpx() gains a splitByDay option that splits the track into separate elements per day, each named "Day N: Start - End". Track points are split at the closest points to overnight waypoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/changes/multi-day-routes/tasks.md | 2 +- packages/gpx/src/generate.ts | 95 +++++++++++++++++++--- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md index 13830f8..b3e184e 100644 --- a/openspec/changes/multi-day-routes/tasks.md +++ b/openspec/changes/multi-day-routes/tasks.md @@ -26,7 +26,7 @@ - [x] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `overnight` for waypoints with `isDayBreak: true` - [x] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `overnight` and set `isDayBreak: true` on parsed waypoints -- [ ] 5.3 Add multi-track export option: split track into one `` per day, each named "Day N: Start - End" +- [x] 5.3 Add multi-track export option: split track into one `` per day, each named "Day N: Start - End" ## 6. Journal Integration diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index f52de43..d108bc1 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -13,6 +13,8 @@ export function generateGpx(options: { waypoints?: Waypoint[]; tracks?: TrackPoint[][]; noGoAreas?: NoGoArea[]; + /** When true, splits tracks at overnight waypoints into separate elements per day */ + splitByDay?: boolean; }): string { const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0; const lines: string[] = [ @@ -41,19 +43,44 @@ export function generateGpx(options: { } if (options.tracks) { - for (const track of options.tracks) { - lines.push(" ", " "); - for (const pt of track) { - lines.push(` `); - if (pt.ele !== undefined) { - lines.push(` ${pt.ele}`); + const allPoints = options.tracks.flat(); + + if (options.splitByDay && options.waypoints) { + // Split track into per-day segments at overnight waypoints + const dayTracks = splitTrackByDays(allPoints, options.waypoints); + for (const dayTrack of dayTracks) { + lines.push(" "); + if (dayTrack.name) { + lines.push(` ${escapeXml(dayTrack.name)}`); } - if (pt.time) { - lines.push(` `); + lines.push(" "); + for (const pt of dayTrack.points) { + lines.push(` `); + if (pt.ele !== undefined) { + lines.push(` ${pt.ele}`); + } + if (pt.time) { + lines.push(` `); + } + lines.push(" "); } - lines.push(" "); + lines.push(" ", " "); + } + } else { + for (const track of options.tracks) { + lines.push(" ", " "); + for (const pt of track) { + lines.push(` `); + if (pt.ele !== undefined) { + lines.push(` ${pt.ele}`); + } + if (pt.time) { + lines.push(` `); + } + lines.push(" "); + } + lines.push(" ", " "); } - lines.push(" ", " "); } } @@ -73,6 +100,54 @@ export function generateGpx(options: { return lines.join("\n"); } +function splitTrackByDays( + points: TrackPoint[], + waypoints: Waypoint[], +): Array<{ name: string; points: TrackPoint[] }> { + if (points.length === 0 || waypoints.length === 0) return []; + + // Find closest track point index for each waypoint + const wpTrackIndices = waypoints.map((wp) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < points.length; i++) { + const dx = points[i]!.lat - wp.lat; + const dy = points[i]!.lon - wp.lon; + const d = dx * dx + dy * dy; + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + return bestIdx; + }); + + // Build day boundaries from overnight waypoints + const breakWpIndices = waypoints + .map((wp, i) => (wp.isDayBreak ? i : -1)) + .filter((i) => i >= 0); + + if (breakWpIndices.length === 0) { + return [{ name: "Day 1", points }]; + } + + const boundaries = [0, ...breakWpIndices.map((i) => wpTrackIndices[i]!), points.length]; + const dayTracks: Array<{ name: string; points: TrackPoint[] }> = []; + + for (let d = 0; d < boundaries.length - 1; d++) { + const start = boundaries[d]!; + const end = boundaries[d + 1]!; + const startWp = d === 0 ? waypoints[0] : waypoints[breakWpIndices[d - 1]!]; + const endWp = d < breakWpIndices.length ? waypoints[breakWpIndices[d]!] : waypoints[waypoints.length - 1]; + const startName = startWp?.name ?? ""; + const endName = endWp?.name ?? ""; + const name = startName && endName ? `Day ${d + 1}: ${startName} - ${endName}` : `Day ${d + 1}`; + dayTracks.push({ name, points: points.slice(start, end) }); + } + + return dayTracks; +} + function escapeXml(str: string): string { return str .replace(/&/g, "&")