Merge pull request #198 from trails-cool/feat/multi-day-routes
Add multi-day routes with overnight waypoints
This commit is contained in:
commit
a1b231c54c
37 changed files with 1562 additions and 161 deletions
|
|
@ -19,13 +19,70 @@ function FitBounds({ data }: { data: GeoJsonObject }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function FlyToSegment({ segments, highlightedDay, fullData }: {
|
||||
segments: Array<{ coords: number[][] }>;
|
||||
highlightedDay: number | null | undefined;
|
||||
fullData: GeoJsonObject;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const fullBoundsRef = useRef<L.LatLngBounds | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cache full route bounds on first render
|
||||
if (!fullBoundsRef.current) {
|
||||
const layer = L.geoJSON(fullData);
|
||||
fullBoundsRef.current = layer.getBounds();
|
||||
}
|
||||
|
||||
if (highlightedDay != null && highlightedDay >= 1 && highlightedDay <= segments.length) {
|
||||
const seg = segments[highlightedDay - 1]!;
|
||||
// coords are [lon, lat] GeoJSON format
|
||||
const latlngs = seg.coords.map((c) => L.latLng(c[1]!, c[0]!));
|
||||
const bounds = L.latLngBounds(latlngs);
|
||||
if (bounds.isValid()) {
|
||||
map.flyToBounds(bounds, { padding: [40, 40], duration: 0.2 });
|
||||
}
|
||||
} else if (fullBoundsRef.current?.isValid()) {
|
||||
map.flyToBounds(fullBoundsRef.current, { padding: [20, 20], duration: 0.2 });
|
||||
}
|
||||
}, [highlightedDay, segments, fullData, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const DAY_COLORS = ["#2563eb", "#8B6D3A", "#059669", "#9333ea", "#dc2626", "#0891b2"];
|
||||
|
||||
function extractGeometry(data: GeoJsonObject): number[][] | null {
|
||||
const coords = (data as { type: string; coordinates?: number[][] }).coordinates
|
||||
?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates);
|
||||
return coords && coords.length >= 2 ? coords : null;
|
||||
}
|
||||
|
||||
function splitGeometry(data: GeoJsonObject, numDays: number): Array<{ coords: number[][] }> {
|
||||
const geometry = extractGeometry(data);
|
||||
if (!geometry) return [];
|
||||
const totalPoints = geometry.length;
|
||||
const pointsPerDay = Math.ceil(totalPoints / numDays);
|
||||
const segments: Array<{ coords: number[][] }> = [];
|
||||
for (let d = 0; d < numDays; d++) {
|
||||
const start = d * pointsPerDay;
|
||||
const end = Math.min((d + 1) * pointsPerDay + 1, totalPoints);
|
||||
if (start >= totalPoints) break;
|
||||
segments.push({ coords: geometry.slice(start, end) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
interface RouteMapProps {
|
||||
geojson: string;
|
||||
interactive?: boolean;
|
||||
className?: string;
|
||||
dayBreaks?: number[];
|
||||
/** 1-based day number to highlight, or null for no highlight */
|
||||
highlightedDay?: number | null;
|
||||
}
|
||||
|
||||
export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) {
|
||||
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
|
||||
const data: GeoJsonObject = JSON.parse(geojson);
|
||||
|
||||
return (
|
||||
|
|
@ -44,8 +101,60 @@ export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapP
|
|||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution={interactive ? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
|
||||
/>
|
||||
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
|
||||
{dayBreaks && dayBreaks.length > 0 ? (
|
||||
<DayColoredRoute data={data} dayBreaks={dayBreaks} highlightedDay={highlightedDay} />
|
||||
) : (
|
||||
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
|
||||
)}
|
||||
<FitBounds data={data} />
|
||||
{dayBreaks && dayBreaks.length > 0 && (
|
||||
<FlyToSegment
|
||||
segments={splitGeometry(data, dayBreaks.length + 1)}
|
||||
highlightedDay={highlightedDay}
|
||||
fullData={data}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) {
|
||||
const geometry = extractGeometry(data);
|
||||
if (!geometry) {
|
||||
return <GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />;
|
||||
}
|
||||
|
||||
const numDays = dayBreaks.length + 1;
|
||||
const rawSegments = splitGeometry(data, numDays);
|
||||
const segments = rawSegments.map((seg, i) => ({
|
||||
...seg,
|
||||
color: DAY_COLORS[i % DAY_COLORS.length]!,
|
||||
}));
|
||||
|
||||
const isHighlighting = highlightedDay != null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const dayNum = i + 1;
|
||||
const isActive = highlightedDay === dayNum;
|
||||
const segData: GeoJsonObject = {
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: seg.coords },
|
||||
properties: {},
|
||||
} as unknown as GeoJsonObject;
|
||||
return (
|
||||
<GeoJSON
|
||||
key={`${i}-${highlightedDay}`}
|
||||
data={segData}
|
||||
style={{
|
||||
color: seg.color,
|
||||
weight: isHighlighting ? (isActive ? 5 : 2) : 3,
|
||||
opacity: isHighlighting ? (isActive ? 1 : 0.3) : 0.8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ export async function createRoute(ownerId: string, input: RouteInput) {
|
|||
let distance: number | null = null;
|
||||
let elevationGain: number | null = null;
|
||||
let elevationLoss: number | null = null;
|
||||
let dayBreaks: number[] = [];
|
||||
if (input.gpx) {
|
||||
const stats = await computeRouteStats(input.gpx);
|
||||
distance = stats.distance;
|
||||
elevationGain = stats.elevationGain;
|
||||
elevationLoss = stats.elevationLoss;
|
||||
dayBreaks = stats.dayBreaks;
|
||||
}
|
||||
|
||||
await db.insert(routes).values({
|
||||
|
|
@ -36,6 +38,7 @@ export async function createRoute(ownerId: string, input: RouteInput) {
|
|||
distance,
|
||||
elevationGain,
|
||||
elevationLoss,
|
||||
dayBreaks,
|
||||
});
|
||||
|
||||
if (input.gpx) {
|
||||
|
|
@ -110,6 +113,7 @@ export async function updateRoute(
|
|||
updateData.distance = stats.distance;
|
||||
updateData.elevationGain = stats.elevationGain;
|
||||
updateData.elevationLoss = stats.elevationLoss;
|
||||
updateData.dayBreaks = stats.dayBreaks;
|
||||
|
||||
// Get next version number
|
||||
const existingVersions = await db
|
||||
|
|
@ -151,13 +155,17 @@ export async function deleteRoute(id: string, ownerId: string) {
|
|||
async function computeRouteStats(gpxString: string) {
|
||||
try {
|
||||
const gpxData = await parseGpxAsync(gpxString);
|
||||
const dayBreaks = gpxData.waypoints
|
||||
.map((w, i) => (w.isDayBreak ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
return {
|
||||
distance: gpxData.distance,
|
||||
elevationGain: gpxData.elevation.gain,
|
||||
elevationLoss: gpxData.elevation.loss,
|
||||
dayBreaks,
|
||||
};
|
||||
} catch {
|
||||
return { distance: null, elevationGain: null, elevationLoss: null };
|
||||
return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[] };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,77 @@
|
|||
import type { Route } from "./+types/api.routes.$id.gpx";
|
||||
import { getRouteWithVersions } from "~/lib/routes.server";
|
||||
import { parseGpxAsync, computeDays, generateGpx } from "@trails-cool/gpx";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const route = await getRouteWithVersions(params.id);
|
||||
if (!route?.gpx) {
|
||||
return new Response("No GPX data", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(route.gpx, {
|
||||
headers: {
|
||||
"Content-Type": "application/gpx+xml",
|
||||
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
|
||||
},
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
const dayParam = url.searchParams.get("day");
|
||||
|
||||
if (!dayParam) {
|
||||
return new Response(route.gpx, {
|
||||
headers: {
|
||||
"Content-Type": "application/gpx+xml",
|
||||
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export a single day's segment
|
||||
const dayNumber = parseInt(dayParam, 10);
|
||||
if (isNaN(dayNumber) || dayNumber < 1) {
|
||||
return new Response("Invalid day number", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const gpxData = await parseGpxAsync(route.gpx);
|
||||
const days = computeDays(gpxData.waypoints, gpxData.tracks);
|
||||
const day = days.find((d) => d.dayNumber === dayNumber);
|
||||
if (!day) {
|
||||
return new Response("Day not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Extract track points for this day by matching waypoint positions to track
|
||||
const allPoints = gpxData.tracks.flat();
|
||||
const startWp = gpxData.waypoints[day.startWaypointIndex]!;
|
||||
const endWp = gpxData.waypoints[day.endWaypointIndex]!;
|
||||
|
||||
const findClosest = (wp: { lat: number; lon: number }) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const startIdx = findClosest(startWp);
|
||||
const endIdx = findClosest(endWp);
|
||||
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
|
||||
|
||||
const dayName = day.startName && day.endName
|
||||
? `Day ${dayNumber}: ${day.startName} - ${day.endName}`
|
||||
: `Day ${dayNumber}`;
|
||||
|
||||
const dayGpx = generateGpx({
|
||||
name: dayName,
|
||||
tracks: [dayPoints],
|
||||
});
|
||||
|
||||
const filename = `${route.name.replace(/[^a-z0-9]/gi, "_")}_day${dayNumber}.gpx`;
|
||||
return new Response(dayGpx, {
|
||||
headers: {
|
||||
"Content-Type": "application/gpx+xml",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Failed to extract day segment", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,19 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
const isOwner = user?.id === route.ownerId;
|
||||
|
||||
// Compute per-day stats if route has day breaks and GPX
|
||||
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
|
||||
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
|
||||
try {
|
||||
const { computeDays } = await import("@trails-cool/gpx");
|
||||
const { parseGpxAsync } = await import("@trails-cool/gpx");
|
||||
const gpxData = await parseGpxAsync(route.gpx);
|
||||
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
|
||||
} catch {
|
||||
// Fall back to no day stats
|
||||
}
|
||||
}
|
||||
|
||||
return data({
|
||||
route: {
|
||||
id: route.id,
|
||||
|
|
@ -29,10 +42,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
elevationLoss: route.elevationLoss,
|
||||
routingProfile: route.routingProfile,
|
||||
hasGpx: !!route.gpx,
|
||||
dayBreaks: route.dayBreaks ?? [],
|
||||
geojson: routeWithGeojson?.geojson ?? null,
|
||||
createdAt: route.createdAt.toISOString(),
|
||||
updatedAt: route.updatedAt.toISOString(),
|
||||
},
|
||||
dayStats,
|
||||
versions: route.versions.map((v) => ({
|
||||
version: v.version,
|
||||
changeDescription: v.changeDescription,
|
||||
|
|
@ -79,9 +94,10 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||
const { route, versions, isOwner } = loaderData;
|
||||
const { route, dayStats, versions, isOwner } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
|
||||
|
||||
const handleEditInPlanner = useCallback(async () => {
|
||||
setEditLoading(true);
|
||||
|
|
@ -156,9 +172,53 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{dayStats.length > 1 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
|
||||
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
|
||||
{dayStats.map((day) => (
|
||||
<div
|
||||
key={day.dayNumber}
|
||||
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
onMouseEnter={() => setHighlightedDay(day.dayNumber)}
|
||||
onMouseLeave={() => setHighlightedDay(null)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t("routes.dayLabel", { n: day.dayNumber })}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">
|
||||
{day.startName && day.endName
|
||||
? `${day.startName} → ${day.endName}`
|
||||
: day.startName || day.endName || ""}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-gray-700">
|
||||
{(day.distance / 1000).toFixed(1)} km
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
↑{day.ascent} m
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
↓{day.descent} m
|
||||
</span>
|
||||
{route.hasGpx && (
|
||||
<a
|
||||
href={`/api/routes/${route.id}/gpx?day=${day.dayNumber}`}
|
||||
download
|
||||
className="shrink-0 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
title={t("routes.exportGpx")}
|
||||
>
|
||||
GPX
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.geojson && (
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||||
<ClientMap geojson={route.geojson} interactive className="h-full w-full" />
|
||||
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
54
apps/planner/app/components/DayBreakdown.tsx
Normal file
54
apps/planner/app/components/DayBreakdown.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
|
||||
interface DayBreakdownProps {
|
||||
days: DayStage[];
|
||||
children: (dayStage: DayStage, waypointIndices: { start: number; end: number }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DayBreakdown({ days, children }: DayBreakdownProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [expandedDay, setExpandedDay] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{days.map((day) => {
|
||||
const isExpanded = expandedDay === day.dayNumber;
|
||||
return (
|
||||
<div key={day.dayNumber}>
|
||||
<button
|
||||
onClick={() => setExpandedDay(isExpanded ? -1 : day.dayNumber)}
|
||||
className="flex w-full items-center gap-2 border-b border-gray-100 px-4 py-2 text-left hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
{t("multiDay.dayLabel", { n: day.dayNumber })}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-gray-600">
|
||||
{day.startName && day.endName
|
||||
? `${day.startName} → ${day.endName}`
|
||||
: day.startName || day.endName || ""}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-gray-500">
|
||||
{(day.distance / 1000).toFixed(1)} km
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{isExpanded ? "▾" : "▸"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="flex gap-3 border-b border-gray-100 bg-gray-50 px-4 py-1.5 text-[10px] text-gray-500">
|
||||
<span>↑ {day.ascent} m</span>
|
||||
<span>↓ {day.descent} m</span>
|
||||
</div>
|
||||
{children(day, { start: day.startWaypointIndex, end: day.endWaypointIndex })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { elevationColor, type ColorMode } from "~/components/ColoredRoute";
|
||||
|
||||
|
|
@ -55,9 +57,11 @@ const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
|||
interface ElevationChartProps {
|
||||
yjs: YjsState;
|
||||
onHover?: (position: [number, number] | null) => void;
|
||||
days?: DayStage[];
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||
export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
|
|
@ -170,6 +174,31 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
|||
ctx.fillText("0 km", PADDING.left, h - 4);
|
||||
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
|
||||
|
||||
// Day dividers
|
||||
if (days && days.length > 1) {
|
||||
for (let d = 0; d < days.length - 1; d++) {
|
||||
// Find the point closest to the day boundary distance
|
||||
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
|
||||
const bx = toX(boundaryDist);
|
||||
|
||||
// Dashed vertical line
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.moveTo(bx, PADDING.top);
|
||||
ctx.lineTo(bx, PADDING.top + chartH);
|
||||
ctx.strokeStyle = "#9A9484";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Day label at top
|
||||
ctx.fillStyle = "#9A9484";
|
||||
ctx.font = "9px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Hover crosshair + info
|
||||
if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
|
||||
const p = points[highlightIdx]!;
|
||||
|
|
@ -203,7 +232,7 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
|||
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||
}
|
||||
},
|
||||
[points, colorMode],
|
||||
[points, colorMode, days, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function getWaypoints(yjs: YjsState) {
|
|||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
isDayBreak: yMap.get("overnight") === true ? true : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,25 +3,46 @@ import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEve
|
|||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { setOvernight } from "~/lib/overnight";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
function waypointIcon(index: number): L.DivIcon {
|
||||
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
|
||||
const bg = overnight ? "#8B6D3A" : "#2563eb";
|
||||
const scale = highlighted ? "scale(1.17)" : "scale(1)";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:24px;height:24px;border-radius:50%;
|
||||
background:#2563eb;color:white;
|
||||
background:${bg};color:white;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:12px;font-weight:600;
|
||||
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
|
||||
transform:translate(-12px,-12px);
|
||||
">${index + 1}</div>`,
|
||||
transform:translate(-12px,-12px) ${scale};
|
||||
transition:transform 0.2s ease;
|
||||
">${overnight ? "☾" : index + 1}</div>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:absolute;left:50%;transform:translate(-50%,-40px);">
|
||||
<div style="
|
||||
background:white;color:#1f2937;
|
||||
padding:1px 6px;border-radius:8px;
|
||||
font-size:10px;font-weight:600;white-space:nowrap;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.15);
|
||||
">Day ${dayNumber} · ${distanceKm} km</div>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
|
@ -30,6 +51,7 @@ interface WaypointData {
|
|||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
|
|
@ -37,6 +59,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
|
|||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
overnight: isOvernight(yMap),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +68,8 @@ interface PlannerMapProps {
|
|||
onRouteRequest?: (waypoints: WaypointData[]) => void;
|
||||
onImportError?: (message: string) => void;
|
||||
highlightPosition?: [number, number] | null;
|
||||
highlightedWaypoint?: number | null;
|
||||
days?: DayStage[];
|
||||
}
|
||||
|
||||
function MapExposer() {
|
||||
|
|
@ -206,7 +231,7 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
|
|||
);
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) {
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
|
|
@ -390,6 +415,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
|
|||
const yMap = new Y.Map();
|
||||
yMap.set("lat", wp.lat);
|
||||
yMap.set("lon", wp.lon);
|
||||
if (wp.name) yMap.set("name", wp.name);
|
||||
if (wp.isDayBreak) yMap.set("overnight", true);
|
||||
yjs.waypoints.push([yMap]);
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +469,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
|
|||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
icon={waypointIcon(i)}
|
||||
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
|
||||
eventHandlers={{
|
||||
mouseover: () => {
|
||||
routeInteractionSuspendedRef.current = true;
|
||||
|
|
@ -465,12 +492,31 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
|
|||
},
|
||||
contextmenu: (e) => {
|
||||
L.DomEvent.preventDefault(e as unknown as Event);
|
||||
deleteWaypoint(i);
|
||||
// Middle waypoints: toggle overnight. First/last: delete.
|
||||
if (i > 0 && i < waypoints.length - 1) {
|
||||
setOvernight(yjs, i, !wp.overnight);
|
||||
} else {
|
||||
deleteWaypoint(i);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Day boundary labels on map */}
|
||||
{days && days.length > 1 && days.map((day) => {
|
||||
const wp = waypoints[day.endWaypointIndex];
|
||||
if (!wp || day.dayNumber === days.length) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`day-${day.dayNumber}`}
|
||||
position={[wp.lat, wp.lon]}
|
||||
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
|
||||
interactive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{routeCoordinates && routeCoordinates.length >= 2 && (
|
||||
<>
|
||||
<ColoredRoute
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
|
|||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
isDayBreak: yMap.get("overnight") === true ? true : undefined,
|
||||
}));
|
||||
|
||||
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas });
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
||||
import { useRouting, type RouteError } from "~/lib/use-routing";
|
||||
import { useDays } from "~/lib/use-days";
|
||||
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
import { ExportButton } from "~/components/ExportButton";
|
||||
|
|
@ -141,7 +142,7 @@ function ColorModeToggle({ yjs }: { yjs: YjsState }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
|
||||
function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"]; days: ReturnType<typeof useDays>; onWaypointHover: (index: number | null) => void }) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnTyp
|
|||
<div className="flex-1 overflow-hidden">
|
||||
{tab === "waypoints" ? (
|
||||
<Suspense fallback={null}>
|
||||
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
|
||||
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={onWaypointHover} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<NotesPanel yjs={yjs} />
|
||||
|
|
@ -179,7 +180,7 @@ interface SessionViewProps {
|
|||
callbackUrl?: string;
|
||||
callbackToken?: string;
|
||||
returnUrl?: string;
|
||||
initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>;
|
||||
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>;
|
||||
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +191,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs);
|
||||
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
|
||||
useUndoShortcuts(yjs?.undoManager ?? null);
|
||||
const days = useDays(yjs);
|
||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
|
||||
const { toasts, addToast } = useToasts();
|
||||
useAwarenessToasts(yjs, t, addToast);
|
||||
|
||||
|
|
@ -283,14 +286,14 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} onImportError={(msg) => addToast(msg, "error")} />
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} onImportError={(msg) => addToast(msg, "error")} days={days} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} days={days} />
|
||||
</Suspense>
|
||||
</main>
|
||||
<SidebarTabs yjs={yjs} routeStats={routeStats} />
|
||||
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
|
||||
</div>
|
||||
<YjsDebugPanel yjs={yjs} />
|
||||
{toasts.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import { setOvernight, isOvernight } from "~/lib/overnight";
|
||||
import { DayBreakdown } from "./DayBreakdown";
|
||||
|
||||
interface WaypointData {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
|
|
@ -13,6 +18,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
|
|||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
overnight: isOvernight(yMap),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -23,9 +29,12 @@ interface WaypointSidebarProps {
|
|||
elevationGain?: number;
|
||||
elevationLoss?: number;
|
||||
};
|
||||
days: DayStage[];
|
||||
onWaypointHover?: (index: number | null) => void;
|
||||
}
|
||||
|
||||
export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
|
||||
export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: WaypointSidebarProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -47,92 +56,149 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
|
|||
if (from === to || from < 0 || to < 0) return;
|
||||
const item = yjs.waypoints.get(from);
|
||||
if (!item) return;
|
||||
const data = { lat: item.get("lat") as number, lon: item.get("lon") as number, name: item.get("name") as string | undefined };
|
||||
const data = {
|
||||
lat: item.get("lat") as number,
|
||||
lon: item.get("lon") as number,
|
||||
name: item.get("name") as string | undefined,
|
||||
overnight: isOvernight(item),
|
||||
};
|
||||
yjs.doc.transact(() => {
|
||||
yjs.waypoints.delete(from, 1);
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", data.lat);
|
||||
yMap.set("lon", data.lon);
|
||||
if (data.name) yMap.set("name", data.name);
|
||||
if (data.overnight) yMap.set("overnight", true);
|
||||
yjs.waypoints.insert(to, [yMap]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.waypoints, yjs.doc],
|
||||
);
|
||||
|
||||
const toggleOvernight = useCallback(
|
||||
(index: number) => {
|
||||
const wp = waypoints[index];
|
||||
if (!wp) return;
|
||||
setOvernight(yjs, index, !wp.overnight);
|
||||
},
|
||||
[yjs, waypoints],
|
||||
);
|
||||
|
||||
const hasMultipleDays = days.length > 1;
|
||||
|
||||
const renderWaypointRow = (wp: WaypointData, i: number) => (
|
||||
<li
|
||||
key={i}
|
||||
className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50"
|
||||
onMouseEnter={() => onWaypointHover?.(i)}
|
||||
onMouseLeave={() => onWaypointHover?.(null)}
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-700">
|
||||
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
|
||||
</p>
|
||||
</div>
|
||||
{wp.overnight && (
|
||||
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-50 border border-amber-200">
|
||||
{t("multiDay.overnight")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
|
||||
{/* Overnight toggle — not on first or last waypoint */}
|
||||
{i > 0 && i < waypoints.length - 1 && (
|
||||
<button
|
||||
onClick={() => toggleOvernight(i)}
|
||||
className={`rounded p-1 ${wp.overnight ? "text-amber-600 hover:bg-amber-100" : "text-gray-400 hover:bg-gray-200 hover:text-gray-600"}`}
|
||||
title={wp.overnight ? t("multiDay.removeOvernight") : t("multiDay.markOvernight")}
|
||||
>
|
||||
☾
|
||||
</button>
|
||||
)}
|
||||
{i > 0 && (
|
||||
<button
|
||||
onClick={() => moveWaypoint(i, i - 1)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
{i < waypoints.length - 1 && (
|
||||
<button
|
||||
onClick={() => moveWaypoint(i, i + 1)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteWaypoint(i)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with route summary */}
|
||||
<div className="border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">
|
||||
Waypoints ({waypoints.length})
|
||||
{t("sidebar.waypoints")} ({waypoints.length})
|
||||
</h2>
|
||||
{routeStats && routeStats.distance !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{(routeStats.distance / 1000).toFixed(1)} km
|
||||
{routeStats.elevationGain !== undefined && ` · ↑${routeStats.elevationGain} m`}
|
||||
{hasMultipleDays && ` · ${t("multiDay.dayCount", { count: days.length })}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waypoint list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{waypoints.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-gray-500">
|
||||
Click on the map to add waypoints
|
||||
</p>
|
||||
) : hasMultipleDays ? (
|
||||
<DayBreakdown days={days}>
|
||||
{(_day, { start, end }) => (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{waypoints.slice(start, end + 1).map((wp, offset) => renderWaypointRow(wp, start + offset))}
|
||||
</ul>
|
||||
)}
|
||||
</DayBreakdown>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{waypoints.map((wp, i) => (
|
||||
<li key={i} className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-700">
|
||||
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
|
||||
{i > 0 && (
|
||||
<button
|
||||
onClick={() => moveWaypoint(i, i - 1)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
{i < waypoints.length - 1 && (
|
||||
<button
|
||||
onClick={() => moveWaypoint(i, i + 1)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteWaypoint(i)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{waypoints.map((wp, i) => renderWaypointRow(wp, i))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{routeStats && routeStats.distance !== undefined && (
|
||||
{/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */}
|
||||
{!hasMultipleDays && routeStats && routeStats.distance !== undefined && (
|
||||
<div className="border-t border-gray-200 px-4 py-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{(routeStats.distance / 1000).toFixed(1)} km
|
||||
</p>
|
||||
<p className="text-gray-500">Distance</p>
|
||||
<p className="text-gray-500">{t("multiDay.distance")}</p>
|
||||
</div>
|
||||
{routeStats.elevationGain !== undefined && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
↑ {routeStats.elevationGain} m
|
||||
</p>
|
||||
<p className="text-gray-500">Ascent</p>
|
||||
<p className="text-gray-500">{t("multiDay.ascent")}</p>
|
||||
</div>
|
||||
)}
|
||||
{routeStats.elevationLoss !== undefined && (
|
||||
|
|
@ -140,7 +206,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
|
|||
<p className="font-medium text-gray-900">
|
||||
↓ {routeStats.elevationLoss} m
|
||||
</p>
|
||||
<p className="text-gray-500">Descent</p>
|
||||
<p className="text-gray-500">{t("multiDay.descent")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
80
apps/planner/app/lib/overnight.test.ts
Normal file
80
apps/planner/app/lib/overnight.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import { setOvernight, isOvernight } from "./overnight.ts";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
|
||||
function createTestYjs(): YjsState {
|
||||
const doc = new Y.Doc();
|
||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
return { doc, waypoints } as unknown as YjsState;
|
||||
}
|
||||
|
||||
function addWaypoint(yjs: YjsState, lat: number, lon: number): void {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lon);
|
||||
yjs.waypoints.push([yMap]);
|
||||
}
|
||||
|
||||
function createDocMap(): Y.Map<unknown> {
|
||||
const doc = new Y.Doc();
|
||||
const arr = doc.getArray<Y.Map<unknown>>("test");
|
||||
const yMap = new Y.Map<unknown>();
|
||||
arr.push([yMap]);
|
||||
return yMap;
|
||||
}
|
||||
|
||||
describe("isOvernight", () => {
|
||||
it("returns false for a waypoint without overnight flag", () => {
|
||||
const yMap = createDocMap();
|
||||
yMap.set("lat", 52.52);
|
||||
yMap.set("lon", 13.405);
|
||||
expect(isOvernight(yMap)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a waypoint with overnight flag", () => {
|
||||
const yMap = createDocMap();
|
||||
yMap.set("lat", 52.52);
|
||||
yMap.set("lon", 13.405);
|
||||
yMap.set("overnight", true);
|
||||
expect(isOvernight(yMap)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-boolean overnight value", () => {
|
||||
const yMap = createDocMap();
|
||||
yMap.set("overnight", "yes");
|
||||
expect(isOvernight(yMap)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOvernight", () => {
|
||||
it("sets overnight flag on a waypoint", () => {
|
||||
const yjs = createTestYjs();
|
||||
addWaypoint(yjs, 52.52, 13.405);
|
||||
|
||||
setOvernight(yjs, 0, true);
|
||||
|
||||
const yMap = yjs.waypoints.get(0)!;
|
||||
expect(yMap.get("overnight")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears overnight flag from a waypoint", () => {
|
||||
const yjs = createTestYjs();
|
||||
addWaypoint(yjs, 52.52, 13.405);
|
||||
|
||||
setOvernight(yjs, 0, true);
|
||||
expect(yjs.waypoints.get(0)!.get("overnight")).toBe(true);
|
||||
|
||||
setOvernight(yjs, 0, false);
|
||||
expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing for out-of-bounds index", () => {
|
||||
const yjs = createTestYjs();
|
||||
addWaypoint(yjs, 52.52, 13.405);
|
||||
|
||||
// Should not throw
|
||||
setOvernight(yjs, 5, true);
|
||||
expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ export interface YjsState {
|
|||
|
||||
export function useYjs(
|
||||
sessionId: string,
|
||||
initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>,
|
||||
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>,
|
||||
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>,
|
||||
): YjsState | null {
|
||||
const [state, setState] = useState<YjsState | null>(null);
|
||||
|
|
@ -106,6 +106,7 @@ export function useYjs(
|
|||
yMap.set("lat", wp.lat);
|
||||
yMap.set("lon", wp.lon);
|
||||
if (wp.name) yMap.set("name", wp.name);
|
||||
if (wp.isDayBreak) yMap.set("overnight", true);
|
||||
waypoints.push([yMap]);
|
||||
}
|
||||
if (initialNoGoAreas?.length && noGoAreas.length === 0) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
return withDb(async () => {
|
||||
const session = await createSession({ callbackUrl, callbackToken });
|
||||
|
||||
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
|
||||
let initialWaypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> | undefined;
|
||||
let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined;
|
||||
if (gpx) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) {
|
|||
const [searchParams] = useSearchParams();
|
||||
const returnUrl = searchParams.get("returnUrl") ?? undefined;
|
||||
const waypointsParam = searchParams.get("waypoints");
|
||||
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
|
||||
let initialWaypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> | undefined;
|
||||
if (waypointsParam) {
|
||||
try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ }
|
||||
}
|
||||
|
|
|
|||
97
e2e/fixtures/brouter-mock.ts
Normal file
97
e2e/fixtures/brouter-mock.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Canned enriched route for 3 nearby Berlin waypoints:
|
||||
* [52.520, 13.405] → [52.516, 13.377] → [52.510, 13.390]
|
||||
*
|
||||
* ~3.2km total, 6 track points with elevation.
|
||||
*/
|
||||
const MOCK_ROUTE_3WP = {
|
||||
geojson: {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38],
|
||||
[13.384, 52.517, 40], [13.377, 52.516, 42],
|
||||
[13.379, 52.514, 40], [13.382, 52.513, 38],
|
||||
[13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34],
|
||||
],
|
||||
},
|
||||
properties: {},
|
||||
}],
|
||||
},
|
||||
coordinates: [
|
||||
[13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38],
|
||||
[13.384, 52.517, 40], [13.377, 52.516, 42],
|
||||
[13.379, 52.514, 40], [13.382, 52.513, 38],
|
||||
[13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34],
|
||||
],
|
||||
segmentBoundaries: [0, 4],
|
||||
totalLength: 3200,
|
||||
totalAscend: 8,
|
||||
surfaces: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned enriched route for 2 nearby Berlin waypoints:
|
||||
* [52.520, 13.405] → [52.515, 13.351]
|
||||
*
|
||||
* ~3.8km total, 6 track points with elevation.
|
||||
*/
|
||||
const MOCK_ROUTE_2WP = {
|
||||
geojson: {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40],
|
||||
[13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35],
|
||||
],
|
||||
},
|
||||
properties: {},
|
||||
}],
|
||||
},
|
||||
coordinates: [
|
||||
[13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40],
|
||||
[13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35],
|
||||
],
|
||||
segmentBoundaries: [0],
|
||||
totalLength: 3800,
|
||||
totalAscend: 6,
|
||||
surfaces: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock the BRouter route API to return instant, deterministic responses.
|
||||
* Selects the appropriate canned response based on waypoint count.
|
||||
*/
|
||||
export async function mockBRouter(page: Page) {
|
||||
await page.route("**/api/route", async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
const wpCount = body?.waypoints?.length ?? 0;
|
||||
const mock = wpCount >= 3 ? MOCK_ROUTE_3WP : MOCK_ROUTE_2WP;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mock),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a lat/lon to pixel coordinates on the Leaflet map.
|
||||
* Requires MapExposer to have set window.__leafletMap.
|
||||
*/
|
||||
export async function latLngToPixel(page: Page, lat: number, lon: number): Promise<{ x: number; y: number }> {
|
||||
return page.evaluate(([la, ln]) => {
|
||||
const map = (window as any).__leafletMap;
|
||||
if (!map) throw new Error("__leafletMap not available");
|
||||
const point = map.latLngToContainerPoint([la, ln]);
|
||||
return { x: point.x, y: point.y };
|
||||
}, [lat, lon]);
|
||||
}
|
||||
|
|
@ -32,6 +32,30 @@ test.describe("Integration: Journal ↔ Planner handoff", () => {
|
|||
expect(session.initialWaypoints).toHaveLength(2);
|
||||
expect(session.initialWaypoints[0].name).toBe("Berlin");
|
||||
});
|
||||
|
||||
test("GPX import with overnight waypoints preserves isDayBreak", async ({ request }) => {
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
|
||||
<wpt lat="51.84" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
|
||||
<wpt lat="50.98" lon="11.028"><name>Erfurt</name></wpt>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.84" lon="12.243"><ele>80</ele></trkpt>
|
||||
<trkpt lat="50.98" lon="11.028"><ele>195</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
const sessionResp = await request.post(`${PLANNER}/api/sessions`, {
|
||||
data: { gpx },
|
||||
});
|
||||
expect(sessionResp.ok()).toBeTruthy();
|
||||
const session = await sessionResp.json();
|
||||
expect(session.initialWaypoints).toHaveLength(3);
|
||||
expect(session.initialWaypoints[1].name).toBe("Dessau");
|
||||
// isDayBreak should be preserved through GPX parsing
|
||||
expect(session.initialWaypoints[1].isDayBreak).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Integration: BRouter routing", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock";
|
||||
|
||||
test.describe("Planner", () => {
|
||||
test("loads the home page", async ({ page }) => {
|
||||
|
|
@ -86,6 +87,8 @@ test.describe("Planner", () => {
|
|||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
await mockBRouter(page);
|
||||
|
||||
await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([
|
||||
{ lat: 52.520, lon: 13.405 },
|
||||
{ lat: 52.515, lon: 13.351 },
|
||||
|
|
@ -94,31 +97,23 @@ test.describe("Planner", () => {
|
|||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Zoom in and click on the route midpoint to split it
|
||||
// RouteInteraction uses map-level mousemove, but click on the ghost marker
|
||||
// inserts the waypoint. We'll use Leaflet events to simulate.
|
||||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Zoom in to the route midpoint using known coordinates
|
||||
await page.evaluate(() => {
|
||||
const map = (window as any).__leafletMap;
|
||||
if (!map) return;
|
||||
map.setView([52.5175, 13.378], 14, { animate: false });
|
||||
map.setView([52.518, 13.383], 15, { animate: false });
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click on the route via the visible polyline's bounding box
|
||||
const routePath = page.locator(".leaflet-overlay-pane path").first();
|
||||
await expect(routePath).toBeAttached({ timeout: 5000 });
|
||||
const box = await routePath.boundingBox();
|
||||
if (!box) throw new Error("Route polyline not visible");
|
||||
// Click on a known route coordinate to insert a waypoint
|
||||
const pixel = await latLngToPixel(page, 52.518, 13.383);
|
||||
await page.mouse.click(pixel.x, pixel.y);
|
||||
|
||||
// Click the center of the route path bounding box
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
// The click on the map near the route should trigger RouteInteraction
|
||||
// which shows the ghost and inserts a waypoint. If the click was within
|
||||
// snap tolerance, a waypoint is inserted. Otherwise, a new waypoint is
|
||||
// appended (map click). Either way we get 3 waypoints.
|
||||
// Should now have 3 waypoints (original 2 + inserted)
|
||||
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
|
|
@ -155,6 +150,8 @@ test.describe("Planner", () => {
|
|||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
await mockBRouter(page);
|
||||
|
||||
const waypoints = [
|
||||
{ lat: 52.520, lon: 13.405 },
|
||||
{ lat: 52.515, lon: 13.351 },
|
||||
|
|
@ -163,8 +160,9 @@ test.describe("Planner", () => {
|
|||
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
// Wait for route to compute and fit
|
||||
await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The map should have zoomed in to the route bounds (zoom > default 6)
|
||||
const zoom = await page.evaluate(() => {
|
||||
|
|
@ -235,4 +233,80 @@ test.describe("Planner", () => {
|
|||
await expect(page).toHaveURL(/\/session\//, { timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("toggle overnight on waypoint shows day breakdown in sidebar", async ({ page, request }) => {
|
||||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
await mockBRouter(page);
|
||||
|
||||
await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([
|
||||
{ lat: 52.520, lon: 13.405 },
|
||||
{ lat: 52.516, lon: 13.377 },
|
||||
{ lat: 52.510, lon: 13.390 },
|
||||
]))}`);
|
||||
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon)
|
||||
const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") });
|
||||
const secondRow = waypointRows.nth(1);
|
||||
await secondRow.hover();
|
||||
const moonButton = secondRow.getByTitle(/overnight/i);
|
||||
await moonButton.click();
|
||||
|
||||
// Day breakdown should appear in the sidebar
|
||||
await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 });
|
||||
await expect(sidebar.getByText("Day 2")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("export GPX with day breaks includes overnight metadata", async ({ page, request }) => {
|
||||
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||
const { url } = await sessionResp.json();
|
||||
|
||||
// Import a GPX with overnight waypoints
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="52.520" lon="13.405"><name>Berlin</name></wpt>
|
||||
<wpt lat="51.840" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
|
||||
<wpt lat="50.980" lon="11.028"><name>Erfurt</name></wpt>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.840" lon="12.243"><ele>80</ele></trkpt>
|
||||
<trkpt lat="50.980" lon="11.028"><ele>195</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
await page.goto(url);
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Drop the GPX file onto the map
|
||||
const dataTransfer = await page.evaluateHandle((content) => {
|
||||
const dt = new DataTransfer();
|
||||
const file = new File([content], "multi-day.gpx", { type: "application/gpx+xml" });
|
||||
dt.items.add(file);
|
||||
return dt;
|
||||
}, gpx);
|
||||
|
||||
const map = page.locator(".leaflet-container");
|
||||
|
||||
await map.dispatchEvent("dragenter", { dataTransfer });
|
||||
await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 });
|
||||
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await map.dispatchEvent("drop", { dataTransfer });
|
||||
|
||||
// Wait for waypoints to load
|
||||
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The overnight waypoint should show day breakdown in the sidebar
|
||||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -280,6 +280,36 @@ function computeDays(
|
|||
The Planner's `useDays()` hook maps its Yjs waypoints + EnrichedRoute into
|
||||
this same shape before calling `computeDays`.
|
||||
|
||||
### D13: Sidebar waypoint hover highlights map marker
|
||||
|
||||
Hovering a waypoint row in the `WaypointSidebar` passes the waypoint index
|
||||
up to `SessionView` via an `onWaypointHover` callback. `PlannerMap` receives
|
||||
a `highlightedWaypoint` index and renders the corresponding marker with a
|
||||
CSS `scale(1.17)` transform (0.2s ease transition). This reuses the existing
|
||||
`waypointIcon` function with a `highlighted` parameter — no extra DOM
|
||||
elements or Leaflet layers needed.
|
||||
|
||||
### D14: Journal route detail — day segment hover interaction
|
||||
|
||||
Hovering a day row in the route detail day breakdown triggers two effects:
|
||||
|
||||
1. **Map segment highlighting**: The hovered day's polyline thickens (weight 5,
|
||||
opacity 1) while other days dim (weight 2, opacity 0.3). Implemented via
|
||||
`highlightedDay` state passed to `DayColoredRoute`.
|
||||
|
||||
2. **Fly-to-segment**: A `FlyToSegment` component calls `map.flyToBounds()`
|
||||
on the hovered segment's bounds (200ms animation). On mouse leave it flies
|
||||
back to the full route bounds. The full route bounds are cached on first
|
||||
render to avoid recomputation.
|
||||
|
||||
### D15: Per-day GPX export endpoint
|
||||
|
||||
The existing `/api/routes/:id/gpx` endpoint gains an optional `?day=N` query
|
||||
parameter. When present, it parses the stored GPX, runs `computeDays()` to
|
||||
find the track point range for that day, and returns a GPX containing only
|
||||
that day's track segment. The filename includes the day number
|
||||
(`route_day1.gpx`).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Segment boundary alignment**: The day computation relies on
|
||||
|
|
@ -26,6 +26,12 @@ and deriving per-day stats from the segment data we already have.
|
|||
"Day 1 . 120 km".
|
||||
- **GPX export**: Day-break waypoints exported with a `<type>overnight</type>`
|
||||
element so the structure survives round-trips.
|
||||
- **Waypoint highlighting**: Hovering a waypoint in the sidebar highlights
|
||||
the corresponding marker on the map with a smooth scale animation.
|
||||
- **Journal day interaction**: Hovering a day in the Journal route detail
|
||||
highlights that segment on the map and flies the map to fit it.
|
||||
- **Per-day GPX export**: Each day in the Journal route detail has a GPX
|
||||
download button that exports just that day's track segment.
|
||||
|
||||
All state lives in Yjs. No database changes are needed -- the Planner remains
|
||||
stateless. The visual design is already specified in the `visual-redesign`
|
||||
|
|
@ -46,7 +52,9 @@ the data model, computation logic, and integration wiring.
|
|||
- `gpx-export`: Day-break metadata in exported GPX waypoints
|
||||
- `gpx-import`: Parse `<type>overnight</type>` waypoints to restore day breaks
|
||||
- `route-management`: Populate `dayBreaks` column on save, expose per-day stats
|
||||
- `journal-route-detail`: Day breakdown display with per-day stats and map segments
|
||||
- `journal-route-detail`: Day breakdown display with per-day stats, map segment
|
||||
highlighting, fly-to-segment on hover, per-day GPX export
|
||||
- `planner-sidebar`: Waypoint hover highlights corresponding map marker
|
||||
|
||||
## Non-Goals
|
||||
|
||||
|
|
@ -78,6 +86,9 @@ the data model, computation logic, and integration wiring.
|
|||
- **Journal route storage**: `updateRoute` populates `dayBreaks` column from
|
||||
parsed waypoint indices when saving GPX
|
||||
- **Journal route detail**: Day breakdown section with per-day stats (distance,
|
||||
ascent, descent) when `dayBreaks` is non-empty
|
||||
ascent, descent) when `dayBreaks` is non-empty. Hover highlights segment on
|
||||
map and flies to fit. Per-day GPX download via `?day=N` query param.
|
||||
- **Sidebar hover**: Hovering waypoint rows highlights the marker on the map
|
||||
with CSS `scale(1.17)` transition (0.2s ease)
|
||||
- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de)
|
||||
in both planner and journal namespaces
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
## 1. Data Model & Computation
|
||||
|
||||
- [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
|
||||
|
||||
- [x] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
|
||||
- [x] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
|
||||
- [x] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
|
||||
- [x] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
|
||||
|
||||
## 3. Map Integration
|
||||
|
||||
- [x] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
|
||||
- [x] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
|
||||
- [x] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
|
||||
|
||||
## 4. Elevation Chart
|
||||
|
||||
- [x] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
|
||||
- [x] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
|
||||
|
||||
## 5. GPX Roundtrip
|
||||
|
||||
- [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
|
||||
- [x] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
|
||||
|
||||
## 6. Journal Integration
|
||||
|
||||
- [x] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column
|
||||
- [x] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`)
|
||||
- [x] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty
|
||||
- [x] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist
|
||||
|
||||
## 7. i18n
|
||||
|
||||
- [x] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
|
||||
- [x] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
|
||||
|
||||
## 8. Planner Waypoint Hover
|
||||
|
||||
- [x] 8.0a Add `onWaypointHover` callback to `WaypointSidebar`, emit waypoint index on row hover
|
||||
- [x] 8.0b Pass `highlightedWaypoint` index to `PlannerMap`, render highlighted marker with CSS `scale(1.17)` and 0.2s ease transition
|
||||
|
||||
## 9. Journal Day Interaction
|
||||
|
||||
- [x] 9.1 Add `highlightedDay` state to route detail page, pass to `RouteMapThumbnail`
|
||||
- [x] 9.2 Highlight hovered day segment on map: thicken active (weight 5), dim others (opacity 0.3)
|
||||
- [x] 9.3 Fly map to hovered day segment bounds (200ms), fly back to full route on mouse leave
|
||||
- [x] 9.4 Add per-day GPX download button on each day row in route detail breakdown
|
||||
- [x] 9.5 Extend `/api/routes/:id/gpx` with `?day=N` query param to export single day's track segment
|
||||
|
||||
## 10. Testing
|
||||
|
||||
- [x] 10.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
|
||||
- [x] 10.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
|
||||
- [x] 10.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved
|
||||
- [x] 10.4 Unit tests for `dayBreaks` extraction in route update logic
|
||||
- [x] 10.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
|
||||
- [x] 10.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
|
||||
- [x] 10.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
## 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[]`
|
||||
|
||||
## 2. Sidebar Day Breakdown
|
||||
|
||||
- [ ] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
|
||||
- [ ] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
|
||||
- [ ] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
|
||||
- [ ] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
|
||||
|
||||
## 3. Map Integration
|
||||
|
||||
- [ ] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
|
||||
- [ ] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
|
||||
- [ ] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
|
||||
|
||||
## 4. Elevation Chart
|
||||
|
||||
- [ ] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
|
||||
- [ ] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
|
||||
|
||||
## 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
|
||||
- [ ] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
|
||||
|
||||
## 6. Journal Integration
|
||||
|
||||
- [ ] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column
|
||||
- [ ] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`)
|
||||
- [ ] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty
|
||||
- [ ] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist
|
||||
|
||||
## 7. i18n
|
||||
|
||||
- [ ] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
|
||||
- [ ] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- [ ] 8.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
|
||||
- [ ] 8.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
|
||||
- [ ] 8.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved
|
||||
- [ ] 8.4 Unit tests for `dayBreaks` extraction in route update logic
|
||||
- [ ] 8.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
|
||||
- [ ] 8.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
|
||||
- [ ] 8.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page
|
||||
50
openspec/specs/multi-day-routes/spec.md
Normal file
50
openspec/specs/multi-day-routes/spec.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Overnight waypoint markers
|
||||
Any waypoint SHALL be toggleable as an overnight stop, creating day boundaries in the route.
|
||||
|
||||
#### Scenario: Toggle overnight
|
||||
- **WHEN** a user toggles the overnight flag on a waypoint
|
||||
- **THEN** the waypoint's `overnight: true` flag is set in the Yjs document
|
||||
- **AND** the route is visually divided into days at that point
|
||||
|
||||
#### Scenario: Implicit day boundaries
|
||||
- **WHEN** overnight stops are set
|
||||
- **THEN** the first waypoint is the implicit start of Day 1 and the last waypoint is the implicit end of the final day
|
||||
|
||||
### Requirement: Per-day statistics
|
||||
The Planner SHALL compute and display distance, ascent, and estimated duration for each day.
|
||||
|
||||
#### Scenario: Day stats computed
|
||||
- **WHEN** a route has overnight waypoints
|
||||
- **THEN** per-day distance, total ascent, and estimated duration are derived from segment boundaries and coordinates
|
||||
|
||||
### Requirement: Day-aware sidebar
|
||||
The sidebar SHALL group waypoints by day with collapsible sections and per-day stats.
|
||||
|
||||
#### Scenario: Day breakdown
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** waypoints are grouped under "Day 1", "Day 2", etc. with collapsible sections
|
||||
- **AND** each section header shows day distance and ascent
|
||||
|
||||
### Requirement: Elevation chart day dividers
|
||||
The elevation chart SHALL show day boundaries as dashed vertical lines.
|
||||
|
||||
#### Scenario: Day dividers on chart
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** dashed vertical lines with "Day N" labels appear at each overnight waypoint position
|
||||
|
||||
### Requirement: Map day labels
|
||||
The map SHALL display day summary labels at day boundary waypoints.
|
||||
|
||||
#### Scenario: Day labels on map
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** white pill markers at day boundaries show "Day N . X km"
|
||||
|
||||
### Requirement: Multi-day GPX export
|
||||
Day structure SHALL be preserved in GPX exports via waypoint type elements.
|
||||
|
||||
#### Scenario: Export multi-day route
|
||||
- **WHEN** a user exports a plan with overnight waypoints
|
||||
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
|
||||
- **AND** reimporting the GPX restores the day structure
|
||||
139
packages/gpx/src/compute-days.test.ts
Normal file
139
packages/gpx/src/compute-days.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeDays } from "./compute-days.ts";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { TrackPoint } from "./types.ts";
|
||||
|
||||
function makeTrack(coords: [number, number, number][]): TrackPoint[] {
|
||||
return coords.map(([lat, lon, ele]) => ({ lat, lon, ele }));
|
||||
}
|
||||
|
||||
describe("computeDays", () => {
|
||||
it("returns empty array for no waypoints", () => {
|
||||
expect(computeDays([], [[{ lat: 0, lon: 0 }]])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for no tracks", () => {
|
||||
expect(computeDays([{ lat: 0, lon: 0 }], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single day when no overnight waypoints", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 51.34, lon: 12.375, name: "Leipzig" },
|
||||
];
|
||||
const track = makeTrack([
|
||||
[52.52, 13.405, 34],
|
||||
[52.0, 13.0, 50],
|
||||
[51.34, 12.375, 113],
|
||||
]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
expect(days).toHaveLength(1);
|
||||
expect(days[0]!.dayNumber).toBe(1);
|
||||
expect(days[0]!.startName).toBe("Berlin");
|
||||
expect(days[0]!.endName).toBe("Leipzig");
|
||||
expect(days[0]!.distance).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("splits into two days at overnight waypoint", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
|
||||
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
|
||||
];
|
||||
const track = makeTrack([
|
||||
[52.52, 13.405, 34],
|
||||
[52.2, 12.8, 60],
|
||||
[51.84, 12.243, 80],
|
||||
[51.4, 11.6, 200],
|
||||
[50.98, 11.028, 195],
|
||||
]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
expect(days).toHaveLength(2);
|
||||
|
||||
expect(days[0]!.dayNumber).toBe(1);
|
||||
expect(days[0]!.startName).toBe("Berlin");
|
||||
expect(days[0]!.endName).toBe("Dessau");
|
||||
|
||||
expect(days[1]!.dayNumber).toBe(2);
|
||||
expect(days[1]!.startName).toBe("Dessau");
|
||||
expect(days[1]!.endName).toBe("Erfurt");
|
||||
|
||||
// Total distance should roughly equal sum of days
|
||||
const totalDist = days[0]!.distance + days[1]!.distance;
|
||||
expect(totalDist).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("splits into three days with two overnight stops", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 0, lon: 0, name: "A" },
|
||||
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
|
||||
{ lat: 2, lon: 0, name: "C", isDayBreak: true },
|
||||
{ lat: 3, lon: 0, name: "D" },
|
||||
];
|
||||
const track = makeTrack([
|
||||
[0, 0, 0], [0.5, 0, 10], [1, 0, 20],
|
||||
[1.5, 0, 30], [2, 0, 40],
|
||||
[2.5, 0, 50], [3, 0, 60],
|
||||
]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
expect(days).toHaveLength(3);
|
||||
expect(days[0]!.startName).toBe("A");
|
||||
expect(days[0]!.endName).toBe("B");
|
||||
expect(days[1]!.startName).toBe("B");
|
||||
expect(days[1]!.endName).toBe("C");
|
||||
expect(days[2]!.startName).toBe("C");
|
||||
expect(days[2]!.endName).toBe("D");
|
||||
});
|
||||
|
||||
it("computes ascent and descent per day", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 0, isDayBreak: true },
|
||||
{ lat: 2, lon: 0 },
|
||||
];
|
||||
const track = makeTrack([
|
||||
[0, 0, 100],
|
||||
[0.5, 0, 200], // +100 ascent
|
||||
[1, 0, 150], // -50 descent
|
||||
[1.5, 0, 50], // -100 descent
|
||||
[2, 0, 250], // +200 ascent
|
||||
]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
expect(days).toHaveLength(2);
|
||||
expect(days[0]!.ascent).toBe(100);
|
||||
expect(days[0]!.descent).toBe(50);
|
||||
expect(days[1]!.ascent).toBe(200);
|
||||
expect(days[1]!.descent).toBe(100);
|
||||
});
|
||||
|
||||
it("handles overnight on first waypoint gracefully", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 0, lon: 0, isDayBreak: true },
|
||||
{ lat: 1, lon: 0 },
|
||||
];
|
||||
const track = makeTrack([[0, 0, 0], [1, 0, 100]]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
// First waypoint is implicit start of Day 1, isDayBreak on it creates
|
||||
// a zero-length day followed by the real day
|
||||
expect(days.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles overnight on last waypoint gracefully", () => {
|
||||
const waypoints: Waypoint[] = [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 0, isDayBreak: true },
|
||||
];
|
||||
const track = makeTrack([[0, 0, 0], [1, 0, 100]]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
// Last waypoint is implicit end, isDayBreak on it = single day
|
||||
expect(days.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles single waypoint", () => {
|
||||
const waypoints: Waypoint[] = [{ lat: 0, lon: 0 }];
|
||||
const track = makeTrack([[0, 0, 0]]);
|
||||
const days = computeDays(waypoints, [track]);
|
||||
// Single waypoint can't form a meaningful day with distance
|
||||
expect(days).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
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));
|
||||
}
|
||||
68
packages/gpx/src/daybreaks-extraction.test.ts
Normal file
68
packages/gpx/src/daybreaks-extraction.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { generateGpx } from "./generate.ts";
|
||||
import { parseGpxAsync } from "./parse.ts";
|
||||
|
||||
/**
|
||||
* Tests the dayBreaks extraction pattern used by the Journal's updateRoute:
|
||||
* parse GPX → find waypoints with isDayBreak → extract indices
|
||||
*/
|
||||
describe("dayBreaks extraction from GPX", () => {
|
||||
it("extracts empty dayBreaks when no overnight waypoints", async () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
|
||||
],
|
||||
tracks: [[
|
||||
{ lat: 52.52, lon: 13.405, ele: 34 },
|
||||
{ lat: 50.98, lon: 11.028, ele: 195 },
|
||||
]],
|
||||
});
|
||||
const parsed = await parseGpxAsync(gpx);
|
||||
const dayBreaks = parsed.waypoints
|
||||
.map((w, i) => (w.isDayBreak ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
expect(dayBreaks).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts single dayBreak index", async () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
|
||||
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
|
||||
],
|
||||
tracks: [[
|
||||
{ lat: 52.52, lon: 13.405, ele: 34 },
|
||||
{ lat: 51.84, lon: 12.243, ele: 80 },
|
||||
{ lat: 50.98, lon: 11.028, ele: 195 },
|
||||
]],
|
||||
});
|
||||
const parsed = await parseGpxAsync(gpx);
|
||||
const dayBreaks = parsed.waypoints
|
||||
.map((w, i) => (w.isDayBreak ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
expect(dayBreaks).toEqual([1]);
|
||||
});
|
||||
|
||||
it("extracts multiple dayBreak indices", async () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 0, lon: 0, name: "A" },
|
||||
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
|
||||
{ lat: 2, lon: 0, name: "C" },
|
||||
{ lat: 3, lon: 0, name: "D", isDayBreak: true },
|
||||
{ lat: 4, lon: 0, name: "E" },
|
||||
],
|
||||
tracks: [[
|
||||
{ lat: 0, lon: 0 }, { lat: 1, lon: 0 }, { lat: 2, lon: 0 },
|
||||
{ lat: 3, lon: 0 }, { lat: 4, lon: 0 },
|
||||
]],
|
||||
});
|
||||
const parsed = await parseGpxAsync(gpx);
|
||||
const dayBreaks = parsed.waypoints
|
||||
.map((w, i) => (w.isDayBreak ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
expect(dayBreaks).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
|
@ -48,4 +48,59 @@ describe("generateGpx", () => {
|
|||
expect(parsed.waypoints[0]!.name).toBe("Start");
|
||||
expect(parsed.tracks[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("emits <type>overnight</type> for isDayBreak waypoints", () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
|
||||
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
|
||||
],
|
||||
});
|
||||
expect(gpx).toContain("<type>overnight</type>");
|
||||
// Only Dessau should have the type element
|
||||
expect(gpx.match(/<type>overnight<\/type>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("round-trips isDayBreak through generate and parse", async () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 52.52, lon: 13.405, name: "Berlin" },
|
||||
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
|
||||
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
|
||||
],
|
||||
tracks: [[
|
||||
{ lat: 52.52, lon: 13.405, ele: 34 },
|
||||
{ lat: 51.84, lon: 12.243, ele: 80 },
|
||||
{ lat: 50.98, lon: 11.028, ele: 195 },
|
||||
]],
|
||||
});
|
||||
const parsed = await parseGpxAsync(gpx);
|
||||
expect(parsed.waypoints).toHaveLength(3);
|
||||
expect(parsed.waypoints[0]!.isDayBreak).toBeUndefined();
|
||||
expect(parsed.waypoints[1]!.isDayBreak).toBe(true);
|
||||
expect(parsed.waypoints[2]!.isDayBreak).toBeUndefined();
|
||||
});
|
||||
|
||||
it("splits tracks by day with splitByDay option", () => {
|
||||
const gpx = generateGpx({
|
||||
waypoints: [
|
||||
{ lat: 0, lon: 0, name: "A" },
|
||||
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
|
||||
{ lat: 2, lon: 0, name: "C" },
|
||||
],
|
||||
tracks: [[
|
||||
{ lat: 0, lon: 0, ele: 0 },
|
||||
{ lat: 0.5, lon: 0, ele: 50 },
|
||||
{ lat: 1, lon: 0, ele: 100 },
|
||||
{ lat: 1.5, lon: 0, ele: 150 },
|
||||
{ lat: 2, lon: 0, ele: 200 },
|
||||
]],
|
||||
splitByDay: true,
|
||||
});
|
||||
// Should have two <trk> elements
|
||||
expect(gpx.match(/<trk>/g)).toHaveLength(2);
|
||||
expect(gpx).toContain("<name>Day 1: A - B</name>");
|
||||
expect(gpx).toContain("<name>Day 2: B - C</name>");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export function generateGpx(options: {
|
|||
waypoints?: Waypoint[];
|
||||
tracks?: TrackPoint[][];
|
||||
noGoAreas?: NoGoArea[];
|
||||
/** When true, splits tracks at overnight waypoints into separate <trk> elements per day */
|
||||
splitByDay?: boolean;
|
||||
}): string {
|
||||
const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0;
|
||||
const lines: string[] = [
|
||||
|
|
@ -33,24 +35,52 @@ 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>");
|
||||
}
|
||||
}
|
||||
|
||||
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>`);
|
||||
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(" <trk>");
|
||||
if (dayTrack.name) {
|
||||
lines.push(` <name>${escapeXml(dayTrack.name)}</name>`);
|
||||
}
|
||||
if (pt.time) {
|
||||
lines.push(` <time>${escapeXml(pt.time)}</time>`);
|
||||
lines.push(" <trkseg>");
|
||||
for (const pt of dayTrack.points) {
|
||||
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(" </trkpt>");
|
||||
lines.push(" </trkseg>", " </trk>");
|
||||
}
|
||||
} else {
|
||||
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>");
|
||||
}
|
||||
lines.push(" </trkseg>", " </trk>");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,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, "&")
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { GpxData } from "./types.ts";
|
|||
* Uses explicit <wpt> elements if present, otherwise simplifies the
|
||||
* track using Douglas-Peucker to find significant turning points.
|
||||
*/
|
||||
export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string }> {
|
||||
export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> {
|
||||
if (gpxData.waypoints.length > 0) return gpxData.waypoints;
|
||||
if (gpxData.tracks.length === 0) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ export default {
|
|||
waypoints: "Wegpunkte",
|
||||
notes: "Notizen",
|
||||
},
|
||||
multiDay: {
|
||||
dayLabel: "Tag {{n}}",
|
||||
dayCount: "{{count}} Tage",
|
||||
dayCount_one: "1 Tag",
|
||||
overnight: "Übernachtung",
|
||||
markOvernight: "Als Übernachtung markieren",
|
||||
removeOvernight: "Übernachtung entfernen",
|
||||
distance: "Distanz",
|
||||
ascent: "Anstieg",
|
||||
descent: "Abstieg",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Sperrgebiet zeichnen",
|
||||
cancel: "Sperrgebiet abbrechen",
|
||||
|
|
@ -124,6 +135,8 @@ export default {
|
|||
delete: "Route löschen",
|
||||
distance: "Strecke",
|
||||
elevationGain: "Höhenmeter",
|
||||
dayBreakdown: "Tagesübersicht",
|
||||
dayLabel: "Tag {{n}}",
|
||||
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
|
||||
noMapPreview: "Keine Kartenvorschau",
|
||||
saveChanges: "Änderungen speichern",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ export default {
|
|||
waypoints: "Waypoints",
|
||||
notes: "Notes",
|
||||
},
|
||||
multiDay: {
|
||||
dayLabel: "Day {{n}}",
|
||||
dayCount: "{{count}} days",
|
||||
dayCount_one: "1 day",
|
||||
overnight: "Overnight",
|
||||
markOvernight: "Mark as overnight stop",
|
||||
removeOvernight: "Remove overnight stop",
|
||||
distance: "Distance",
|
||||
ascent: "Ascent",
|
||||
descent: "Descent",
|
||||
},
|
||||
noGoAreas: {
|
||||
draw: "Draw no-go area",
|
||||
cancel: "Cancel no-go area",
|
||||
|
|
@ -124,6 +135,8 @@ export default {
|
|||
delete: "Delete Route",
|
||||
distance: "Distance",
|
||||
elevationGain: "Elevation Gain",
|
||||
dayBreakdown: "Day Breakdown",
|
||||
dayLabel: "Day {{n}}",
|
||||
noRoutesYet: "No routes yet. Create your first route!",
|
||||
noMapPreview: "No map preview",
|
||||
saveChanges: "Save Changes",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue