From 7029c85dd0fc9a52ccaf4f624b649764b7e0360d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 00:44:11 +0200 Subject: [PATCH] Add route detail day segment highlighting and per-day GPX export - Hovering a day row dims other segments (opacity 0.3) and thickens the hovered day's segment (weight 5) for clear visual focus - Each day row has a GPX download button that exports just that day's track segment via /api/routes/:id/gpx?day=N - GPX endpoint uses computeDays to extract the correct track slice Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/RouteMapThumbnail.client.tsx | 20 +++-- apps/journal/app/routes/api.routes.$id.gpx.ts | 75 +++++++++++++++++-- apps/journal/app/routes/routes.$id.tsx | 20 ++++- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx index 54b7c72..df74485 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -26,9 +26,11 @@ interface RouteMapProps { 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, dayBreaks }: RouteMapProps) { +export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) { const data: GeoJsonObject = JSON.parse(geojson); return ( @@ -48,7 +50,7 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks } attribution={interactive ? '© OpenStreetMap' : undefined} /> {dayBreaks && dayBreaks.length > 0 ? ( - + ) : ( )} @@ -57,7 +59,7 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks } ); } -function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks: number[] }) { +function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) { // Extract coordinates from the GeoJSON LineString const geometry = (data as { type: string; coordinates?: number[][] }).coordinates ?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates); @@ -84,9 +86,13 @@ function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks: }); } + 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 }, @@ -94,9 +100,13 @@ function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks: } as unknown as GeoJsonObject; return ( ); })} diff --git a/apps/journal/app/routes/api.routes.$id.gpx.ts b/apps/journal/app/routes/api.routes.$id.gpx.ts index f3b721d..d430ece 100644 --- a/apps/journal/app/routes/api.routes.$id.gpx.ts +++ b/apps/journal/app/routes/api.routes.$id.gpx.ts @@ -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 }); + } } diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index a65467e..5623e31 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -97,6 +97,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { const { route, dayStats, versions, isOwner } = loaderData; const { t } = useTranslation("journal"); const [editLoading, setEditLoading] = useState(false); + const [highlightedDay, setHighlightedDay] = useState(null); const handleEditInPlanner = useCallback(async () => { setEditLoading(true); @@ -176,7 +177,12 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {

{t("routes.dayBreakdown")}

{dayStats.map((day) => ( -
+
setHighlightedDay(day.dayNumber)} + onMouseLeave={() => setHighlightedDay(null)} + > {t("routes.dayLabel", { n: day.dayNumber })} @@ -194,6 +200,16 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { ↓{day.descent} m + {route.hasGpx && ( + + GPX + + )}
))}
@@ -202,7 +218,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {route.geojson && (
- 0 ? route.dayBreaks : undefined} /> + 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
)}