From b010cd8c595dd4b38a7f8e8cb92b9ae8c32b99c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 00:46:46 +0200 Subject: [PATCH] Fly map to highlighted day segment on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hovering a day row in the route detail breakdown, the map smoothly flies to fit that segment's bounds (0.5s animation). On mouse leave it flies back to the full route bounds. Refactored geometry splitting into shared helpers used by both DayColoredRoute and FlyToSegment. Rotation not implemented — Leaflet doesn't support native bearing rotation. Would require leaflet-rotate plugin or MapLibre GL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/RouteMapThumbnail.client.tsx | 87 ++++++++++++++----- 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx index df74485..829ee74 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -19,8 +19,60 @@ 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(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.5 }); + } + } else if (fullBoundsRef.current?.isValid()) { + map.flyToBounds(fullBoundsRef.current, { padding: [20, 20], duration: 0.5 }); + } + }, [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; @@ -55,36 +107,29 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, )} + {dayBreaks && dayBreaks.length > 0 && ( + + )} ); } 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); - - if (!geometry || geometry.length < 2) { + const geometry = extractGeometry(data); + if (!geometry) { return ; } - // Split coordinates into segments at approximate day break points - // dayBreaks are waypoint indices — we split the line evenly since we don't - // have exact waypoint-to-coordinate mapping in the Journal context - const totalPoints = geometry.length; const numDays = dayBreaks.length + 1; - const pointsPerDay = Math.ceil(totalPoints / numDays); - - const segments: Array<{ coords: number[][]; color: string }> = []; - 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), - color: DAY_COLORS[d % DAY_COLORS.length]!, - }); - } + const rawSegments = splitGeometry(data, numDays); + const segments = rawSegments.map((seg, i) => ({ + ...seg, + color: DAY_COLORS[i % DAY_COLORS.length]!, + })); const isHighlighting = highlightedDay != null;