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

@ -75,10 +75,13 @@ const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
interface ElevationChartProps {
yjs: YjsState;
onHover?: (position: [number, number] | null) => void;
highlightDistance?: number | null;
onClickPosition?: (position: [number, number]) => void;
onDragSelect?: (bounds: [[number, number], [number, number]]) => void;
days?: DayStage[];
}
export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) {
const { t } = useTranslation("planner");
const [points, setPoints] = useState<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
@ -93,6 +96,36 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
pointsRef.current = points;
const isExternalHover = useRef(false);
const dragStartX = useRef<number | null>(null);
const dragStartClientX = useRef<number | null>(null);
const isDragging = useRef(false);
const dragCurrentX = useRef<number | null>(null);
// External highlight from map hover: find closest point by distance
useEffect(() => {
if (highlightDistance == null) {
if (isExternalHover.current) {
isExternalHover.current = false;
setHoverIdx(null);
}
return;
}
if (points.length < 2) return;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - highlightDistance);
if (diff < minDiff) {
minDiff = diff;
closest = i;
}
}
isExternalHover.current = true;
setHoverIdx(closest);
// Do NOT call onHover here to avoid feedback loop
}, [highlightDistance, points]);
useEffect(() => {
const update = () => {
@ -524,6 +557,19 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
ctx.fillText(label, labelX, PADDING.top + 10);
}
// Drag selection overlay
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null) {
const x1 = Math.max(PADDING.left, Math.min(dragStartX.current, PADDING.left + chartW));
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX.current, PADDING.left + chartW));
const selLeft = Math.min(x1, x2);
const selRight = Math.max(x1, x2);
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
}
},
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
);
@ -542,6 +588,17 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (mouseX - PADDING.left) / chartW;
// Handle drag selection
if (dragStartClientX.current != null) {
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx > 5) {
isDragging.current = true;
dragCurrentX.current = mouseX;
drawChart(hoverIdx);
return;
}
}
if (ratio < 0 || ratio > 1) {
setHoverIdx(null);
onHover?.(null);
@ -562,18 +619,107 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
}
}
isExternalHover.current = false;
setHoverIdx(closest);
const p = points[closest]!;
onHover?.([p.lat, p.lon]);
},
[points, onHover],
[points, onHover, hoverIdx, drawChart],
);
const handleMouseLeave = useCallback(() => {
setHoverIdx(null);
onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
}, [onHover]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
},
[],
);
const handleMouseUp = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) {
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
return;
}
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
const maxDist = points[points.length - 1]!.distance;
if (isDragging.current && dragStartX.current != null) {
// Drag-select: compute bounding box of selected range
const endX = e.clientX - rect.left;
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (endX - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
// Find all points in the selected distance range
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
let found = false;
for (const p of points) {
if (p.distance >= startDist && p.distance <= endDist) {
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
found = true;
}
}
if (found && onDragSelect) {
onDragSelect([[minLat, minLon], [maxLat, maxLon]]);
}
} else if (dragStartClientX.current != null) {
// Click (not drag): pan map to clicked point
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx <= 5 && onClickPosition) {
const mouseX = e.clientX - rect.left;
const ratio = (mouseX - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1) {
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) {
minDiff = diff;
closest = i;
}
}
const p = points[closest]!;
onClickPosition([p.lat, p.lon]);
}
}
}
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
drawChart(hoverIdx);
},
[points, onClickPosition, onDragSelect, hoverIdx, drawChart],
);
const setMode = useCallback((mode: string) => {
yjs.routeData.set("colorMode", mode);
}, [yjs.routeData]);
@ -705,6 +851,8 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
className="h-24 w-full cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>
</div>
);