Add bidirectional elevation chart ↔ map interaction

Three new interactions:

1. Route hover → chart: Invisible interactive polyline (weight 20,
   opacity 0) detects mouse hover on the route, computes cumulative
   distance, and highlights the corresponding position on the chart.

2. Chart click → map pan: Clicking the elevation chart pans the map
   to center on that point along the route. Uses 5px threshold to
   distinguish from drag.

3. Chart drag-select → map zoom: Dragging a range on the chart shows
   a blue selection overlay, then zooms the map to fit the route
   coordinates in that range. "Reset zoom" button appears to restore
   the full route view.

State flow avoids feedback loops: external highlights (from map hover)
set the chart crosshair without re-emitting onHover back to the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-12 19:23:37 +02:00
parent 9a46a7eac0
commit 3345ef127e
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 369 additions and 6 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, useMapEvents, useMap } from "react-leaflet";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet";
import L from "leaflet";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
@ -92,6 +92,7 @@ interface PlannerMapProps {
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
highlightedWaypoint?: number | null;
onRouteHover?: (distance: number | null) => void;
days?: DayStage[];
}
@ -361,7 +362,7 @@ function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
return null;
}
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) {
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const poiState = usePois();
@ -607,6 +608,46 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
[yjs.waypoints],
);
const handleRoutePolylineHover = useCallback(
(e: L.LeafletMouseEvent) => {
if (!routeCoordinates || routeCoordinates.length < 2 || !onRouteHover) return;
const { lat, lng } = e.latlng;
// Find the closest coordinate index
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < routeCoordinates.length; i++) {
const c = routeCoordinates[i]!;
const dLat = c[1]! - lat;
const dLon = c[0]! - lng;
const dist = dLat * dLat + dLon * dLon;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
// Compute cumulative distance to this point using haversine
let totalDist = 0;
for (let i = 1; i <= bestIdx; i++) {
const prev = routeCoordinates[i - 1]!;
const curr = routeCoordinates[i]!;
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(curr[1]! - prev[1]!);
const dLon = toRad(curr[0]! - prev[0]!);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2;
totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
onRouteHover(totalDist);
},
[routeCoordinates, onRouteHover],
);
const handleRoutePolylineOut = useCallback(() => {
onRouteHover?.(null);
}, [onRouteHover]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current++;
@ -784,6 +825,17 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
suspendedRef={routeInteractionSuspendedRef}
disabled={noGoDrawing}
/>
{onRouteHover && (
<Polyline
positions={routeCoordinates.map((c) => [c[1]!, c[0]!] as [number, number])}
pathOptions={{ weight: 20, opacity: 0, interactive: true }}
eventHandlers={{
mouseover: handleRoutePolylineHover,
mousemove: handleRoutePolylineHover,
mouseout: handleRoutePolylineOut,
}}
/>
)}
</>
)}