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, "&")