import { useEffect, useRef } from "react"; import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet"; import L from "leaflet"; import type { GeoJsonObject } from "geojson"; import "leaflet/dist/leaflet.css"; /** Marker shown at the position the elevation chart is pointing at. */ function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) { if (!point) return null; return ( ); } /** Reports the route sample nearest the cursor so the chart can highlight it. */ function HoverTracker({ series, onHoverIndex, }: { series: Array<[number, number]>; onHoverIndex: (index: number | null) => void; }) { useMapEvents({ mousemove(e) { const { lat, lng } = e.latlng; let best = -1; let bestDelta = Infinity; for (let i = 0; i < series.length; i++) { const [slat, slng] = series[i]!; const delta = (slat - lat) ** 2 + (slng - lng) ** 2; if (delta < bestDelta) { bestDelta = delta; best = i; } } onHoverIndex(best >= 0 ? best : null); }, mouseout() { onHoverIndex(null); }, }); return null; } /** Pans the map when the chart is clicked (centerOn.v bumps per click). */ function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) { const map = useMap(); const lastV = useRef(null); useEffect(() => { if (!centerOn || centerOn.v === lastV.current) return; lastV.current = centerOn.v; map.panTo([centerOn.lat, centerOn.lng], { animate: true }); }, [centerOn, map]); return null; } function FitBounds({ data }: { data: GeoJsonObject }) { const map = useMap(); const fitted = useRef(false); useEffect(() => { if (fitted.current) return; const layer = L.geoJSON(data); const bounds = layer.getBounds(); if (bounds.isValid()) { map.fitBounds(bounds, { padding: [20, 20] }); fitted.current = true; } }, [data, map]); 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.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; /** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */ activePoint?: { lat: number; lng: number } | null; hoverSeries?: Array<[number, number]>; onHoverIndex?: (index: number | null) => void; centerOn?: { lat: number; lng: number; v: number } | null; } export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay, activePoint, hoverSeries, onHoverIndex, centerOn, }: RouteMapProps) { const data: GeoJsonObject = JSON.parse(geojson); return ( OpenStreetMap' : undefined} /> {dayBreaks && dayBreaks.length > 0 ? ( ) : ( )} {dayBreaks && dayBreaks.length > 0 && ( )} {hoverSeries && hoverSeries.length > 0 && onHoverIndex && ( )} ); } function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) { const geometry = extractGeometry(data); if (!geometry) { return ; } 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 GeoJsonObject; return ( ); })} ); }