Add multi-day data model, computeDays, and GPX roundtrip
- overnight.ts: Yjs helpers to set/clear/check overnight flag on waypoints - compute-days.ts: Pure function splitting routes into DayStage[] at day breaks - use-days.ts: Reactive hook mapping Yjs state into computeDays() input - GPX generate: emit <type>overnight</type> for isDayBreak waypoints - GPX parse: recognize <type>overnight</type> and set isDayBreak on waypoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9d6f65616
commit
efdb3c1973
7 changed files with 226 additions and 6 deletions
24
apps/planner/app/lib/overnight.ts
Normal file
24
apps/planner/app/lib/overnight.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as Y from "yjs";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
|
||||
/**
|
||||
* Set or clear the overnight flag on a waypoint.
|
||||
*/
|
||||
export function setOvernight(yjs: YjsState, index: number, value: boolean): void {
|
||||
const waypointMap = yjs.waypoints.get(index);
|
||||
if (!waypointMap) return;
|
||||
yjs.doc.transact(() => {
|
||||
if (value) {
|
||||
waypointMap.set("overnight", true);
|
||||
} else {
|
||||
waypointMap.delete("overnight");
|
||||
}
|
||||
}, "local");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a waypoint Y.Map has the overnight flag set.
|
||||
*/
|
||||
export function isOvernight(yMap: Y.Map<unknown>): boolean {
|
||||
return yMap.get("overnight") === true;
|
||||
}
|
||||
68
apps/planner/app/lib/use-days.ts
Normal file
68
apps/planner/app/lib/use-days.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { computeDays, type DayStage } from "@trails-cool/gpx";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { TrackPoint } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
import { isOvernight } from "./overnight.ts";
|
||||
|
||||
/**
|
||||
* Reactive hook that computes day stages from Yjs waypoints and route data.
|
||||
* Returns an empty array for single-day routes (no overnight waypoints).
|
||||
*/
|
||||
export function useDays(yjs: YjsState | null): DayStage[] {
|
||||
const [days, setDays] = useState<DayStage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yjs) return;
|
||||
|
||||
const recompute = () => {
|
||||
// Extract waypoints with isDayBreak from Yjs
|
||||
const waypoints: Waypoint[] = 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,
|
||||
isDayBreak: isOvernight(yMap) || undefined,
|
||||
}));
|
||||
|
||||
// Check if any waypoint has isDayBreak
|
||||
const hasBreaks = waypoints.some((w) => w.isDayBreak);
|
||||
if (!hasBreaks) {
|
||||
setDays([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract track points from route coordinates stored in Yjs
|
||||
const coordsStr = yjs.routeData.get("coordinates") as string | undefined;
|
||||
if (!coordsStr) {
|
||||
setDays([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// coordinates are stored as [[lon, lat, ele], ...] (GeoJSON format)
|
||||
const coords: number[][] = JSON.parse(coordsStr);
|
||||
const trackPoints: TrackPoint[] = coords.map((c) => ({
|
||||
lat: c[1]!,
|
||||
lon: c[0]!,
|
||||
ele: c[2],
|
||||
}));
|
||||
|
||||
setDays(computeDays(waypoints, [trackPoints]));
|
||||
} catch {
|
||||
setDays([]);
|
||||
}
|
||||
};
|
||||
|
||||
yjs.waypoints.observeDeep(recompute);
|
||||
yjs.routeData.observe(recompute);
|
||||
recompute();
|
||||
|
||||
return () => {
|
||||
yjs.waypoints.unobserveDeep(recompute);
|
||||
yjs.routeData.unobserve(recompute);
|
||||
};
|
||||
}, [yjs]);
|
||||
|
||||
return days;
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
## 1. Data Model & Computation
|
||||
|
||||
- [ ] 1.1 Add `overnight` property support to Yjs waypoint Y.Maps: helper functions `setOvernight(yjs, index, value)` and `isOvernight(yMap)` in a new `apps/planner/app/lib/overnight.ts`
|
||||
- [ ] 1.2 Create `computeDays()` pure function in `packages/gpx/src/compute-days.ts`: takes `Waypoint[]` + `TrackPoint[][]`, returns `DayStage[]` (day number, waypoint range, distance, ascent, descent). Export from package index.
|
||||
- [ ] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: maps Yjs waypoints + EnrichedRoute into `computeDays()` input, returns reactive `DayStage[]`
|
||||
- [x] 1.1 Add `overnight` property support to Yjs waypoint Y.Maps: helper functions `setOvernight(yjs, index, value)` and `isOvernight(yMap)` in a new `apps/planner/app/lib/overnight.ts`
|
||||
- [x] 1.2 Create `computeDays()` pure function in `packages/gpx/src/compute-days.ts`: takes `Waypoint[]` + `TrackPoint[][]`, returns `DayStage[]` (day number, waypoint range, distance, ascent, descent). Export from package index.
|
||||
- [x] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: maps Yjs waypoints + EnrichedRoute into `computeDays()` input, returns reactive `DayStage[]`
|
||||
|
||||
## 2. Sidebar Day Breakdown
|
||||
|
||||
|
|
@ -24,8 +24,8 @@
|
|||
|
||||
## 5. GPX Roundtrip
|
||||
|
||||
- [ ] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `<type>overnight</type>` for waypoints with `isDayBreak: true`
|
||||
- [ ] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `<type>overnight</type>` and set `isDayBreak: true` on parsed waypoints
|
||||
- [x] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `<type>overnight</type>` for waypoints with `isDayBreak: true`
|
||||
- [x] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `<type>overnight</type>` and set `isDayBreak: true` on parsed waypoints
|
||||
- [ ] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
|
||||
|
||||
## 6. Journal Integration
|
||||
|
|
|
|||
121
packages/gpx/src/compute-days.ts
Normal file
121
packages/gpx/src/compute-days.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { TrackPoint } from "./types.ts";
|
||||
|
||||
export interface DayStage {
|
||||
dayNumber: number;
|
||||
startWaypointIndex: number;
|
||||
endWaypointIndex: number;
|
||||
startName?: string;
|
||||
endName?: string;
|
||||
/** Distance in meters */
|
||||
distance: number;
|
||||
/** Ascent in meters */
|
||||
ascent: number;
|
||||
/** Descent in meters */
|
||||
descent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a route into day stages based on waypoints with isDayBreak.
|
||||
*
|
||||
* Day boundaries are defined by waypoints where isDayBreak is true.
|
||||
* The first waypoint is the implicit start of Day 1, the last waypoint
|
||||
* is the implicit end of the final day, and each isDayBreak waypoint
|
||||
* marks the end of one day and the start of the next.
|
||||
*
|
||||
* If no waypoints have isDayBreak, returns a single day covering the
|
||||
* entire route.
|
||||
*/
|
||||
export function computeDays(
|
||||
waypoints: Waypoint[],
|
||||
tracks: TrackPoint[][],
|
||||
): DayStage[] {
|
||||
if (waypoints.length === 0 || tracks.length === 0) return [];
|
||||
|
||||
// Flatten all tracks into a single point array
|
||||
const allPoints: TrackPoint[] = tracks.flat();
|
||||
if (allPoints.length === 0) return [];
|
||||
|
||||
// Find waypoint indices that are day breaks
|
||||
const breakIndices: number[] = [];
|
||||
for (let i = 0; i < waypoints.length; i++) {
|
||||
if (waypoints[i]!.isDayBreak) {
|
||||
breakIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Build day boundaries: [0, break1, break2, ..., lastWaypointIndex]
|
||||
const boundaries = [0, ...breakIndices];
|
||||
if (boundaries[boundaries.length - 1] !== waypoints.length - 1) {
|
||||
boundaries.push(waypoints.length - 1);
|
||||
}
|
||||
|
||||
// For each waypoint, find the closest point in the track
|
||||
const waypointTrackIndices = waypoints.map((wp) => {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < allPoints.length; i++) {
|
||||
const dx = allPoints[i]!.lat - wp.lat;
|
||||
const dy = allPoints[i]!.lon - wp.lon;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
});
|
||||
|
||||
// Precompute cumulative distances and elevation changes
|
||||
const cumDist: number[] = [0];
|
||||
const cumAscent: number[] = [0];
|
||||
const cumDescent: number[] = [0];
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const prev = allPoints[i - 1]!;
|
||||
const curr = allPoints[i]!;
|
||||
cumDist.push(cumDist[i - 1]! + haversine(prev.lat, prev.lon, curr.lat, curr.lon));
|
||||
|
||||
if (prev.ele !== undefined && curr.ele !== undefined) {
|
||||
const diff = curr.ele - prev.ele;
|
||||
cumAscent.push(cumAscent[i - 1]! + (diff > 0 ? diff : 0));
|
||||
cumDescent.push(cumDescent[i - 1]! + (diff < 0 ? -diff : 0));
|
||||
} else {
|
||||
cumAscent.push(cumAscent[i - 1]!);
|
||||
cumDescent.push(cumDescent[i - 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
// Build day stages
|
||||
const days: DayStage[] = [];
|
||||
for (let d = 0; d < boundaries.length - 1; d++) {
|
||||
const startWpIdx = boundaries[d]!;
|
||||
const endWpIdx = boundaries[d + 1]!;
|
||||
const startTrackIdx = waypointTrackIndices[startWpIdx]!;
|
||||
const endTrackIdx = waypointTrackIndices[endWpIdx]!;
|
||||
|
||||
days.push({
|
||||
dayNumber: d + 1,
|
||||
startWaypointIndex: startWpIdx,
|
||||
endWaypointIndex: endWpIdx,
|
||||
startName: waypoints[startWpIdx]!.name,
|
||||
endName: waypoints[endWpIdx]!.name,
|
||||
distance: Math.round(cumDist[endTrackIdx]! - cumDist[startTrackIdx]!),
|
||||
ascent: Math.round(cumAscent[endTrackIdx]! - cumAscent[startTrackIdx]!),
|
||||
descent: Math.round(cumDescent[endTrackIdx]! - cumDescent[startTrackIdx]!),
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
|
@ -33,6 +33,9 @@ export function generateGpx(options: {
|
|||
if (wpt.name) {
|
||||
lines.push(` <name>${escapeXml(wpt.name)}</name>`);
|
||||
}
|
||||
if (wpt.isDayBreak) {
|
||||
lines.push(" <type>overnight</type>");
|
||||
}
|
||||
lines.push(" </wpt>");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export { parseGpxAsync } from "./parse.ts";
|
||||
export { generateGpx } from "./generate.ts";
|
||||
export { extractWaypoints } from "./waypoints.ts";
|
||||
export { computeDays } from "./compute-days.ts";
|
||||
export type { DayStage } from "./compute-days.ts";
|
||||
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ function parseWaypoints(doc: Document): Waypoint[] {
|
|||
const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
|
||||
const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
|
||||
const name = wpt.querySelector("name")?.textContent ?? undefined;
|
||||
return { lat, lon, name };
|
||||
const type = wpt.querySelector("type")?.textContent ?? undefined;
|
||||
const isDayBreak = type === "overnight" ? true : undefined;
|
||||
return { lat, lon, name, isDayBreak };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue