fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom

- Drag-to-zoom on the chart now tracks via window mousemove/mouseup, so
  it keeps going when the pointer leaves the chart and still completes if
  released outside it (previously mouseleave aborted the drag and an
  outside release was lost). Listeners are detached on mouseup/unmount.
- Move the "reset zoom" button from top-right (which overlapped the
  color-mode dropdown) to the bottom-right of the chart, and restyle it
  on tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-16 01:27:45 +02:00
parent 6d905c152f
commit 97ddf36621
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
2 changed files with 85 additions and 82 deletions

View file

@ -36,6 +36,10 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const dragStartClientX = useRef<number | null>(null); const dragStartClientX = useRef<number | null>(null);
const isDragging = useRef(false); const isDragging = useRef(false);
const dragCurrentX = useRef<number | null>(null); const dragCurrentX = useRef<number | null>(null);
const dragCleanup = useRef<(() => void) | null>(null);
// Detach any dangling window drag listeners on unmount.
useEffect(() => () => dragCleanup.current?.(), []);
// External highlight from map hover: find closest point by distance // External highlight from map hover: find closest point by distance
useEffect(() => { useEffect(() => {
@ -104,21 +108,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || points.length < 2) return; if (!canvas || points.length < 2) return;
// Drag is tracked via window listeners (see handleMouseDown); ignore
// canvas moves while a press/drag gesture is active.
if (dragStartClientX.current != null) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const chartW = rect.width - PADDING.left - PADDING.right; const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (mouseX - PADDING.left) / chartW; const ratio = (mouseX - PADDING.left) / chartW;
if (dragStartClientX.current != null) {
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx > 5) {
isDragging.current = true;
dragCurrentX.current = mouseX;
draw(hoverIdx);
return;
}
}
if (ratio < 0 || ratio > 1) { if (ratio < 0 || ratio > 1) {
setHoverIdx(null); setHoverIdx(null);
onHover?.(null); onHover?.(null);
@ -143,92 +141,98 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const p = points[closest]!; const p = points[closest]!;
onHover?.([p.lat, p.lon]); onHover?.([p.lat, p.lon]);
}, },
[points, onHover, hoverIdx, draw], [points, onHover],
); );
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
// Keep an in-progress drag alive when the pointer leaves the canvas —
// the window listeners keep tracking it.
if (dragStartClientX.current != null) return;
setHoverIdx(null); setHoverIdx(null);
onHover?.(null); onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
}, [onHover]); }, [onHover]);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => { const handleMouseDown = useCallback(
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>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || points.length < 2) { if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
setHoverIdx(null);
onHover?.(null);
const clampX = (clientX: number) =>
Math.max(PADDING.left, Math.min(clientX - rect.left, PADDING.left + chartW));
// Track the drag on the window so it survives the pointer leaving the
// canvas and still completes if the button is released outside it.
const onWindowMove = (ev: MouseEvent) => {
if (dragStartClientX.current == null) return;
if (Math.abs(ev.clientX - dragStartClientX.current) > 5) {
isDragging.current = true;
dragCurrentX.current = clampX(ev.clientX);
draw(null);
}
};
const detach = () => {
window.removeEventListener("mousemove", onWindowMove);
window.removeEventListener("mouseup", onWindowUp);
dragCleanup.current = null;
};
function onWindowUp(ev: MouseEvent) {
detach();
if (points.length >= 2) {
const maxDist = points[points.length - 1]!.distance;
if (isDragging.current && dragStartX.current != null) {
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (clampX(ev.clientX) - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
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 (onClickPosition) {
const ratio = (ev.clientX - rect.left - 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; }
}
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
}
}
}
dragStartX.current = null; dragStartX.current = null;
dragStartClientX.current = null; dragStartClientX.current = null;
isDragging.current = false; isDragging.current = false;
dragCurrentX.current = null; dragCurrentX.current = null;
return; draw(null);
} }
const rect = canvas.getBoundingClientRect(); dragCleanup.current = detach;
const chartW = rect.width - PADDING.left - PADDING.right; window.addEventListener("mousemove", onWindowMove);
const maxDist = points[points.length - 1]!.distance; window.addEventListener("mouseup", onWindowUp);
if (isDragging.current && dragStartX.current != null) {
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;
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) {
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;
}
}
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
}
}
}
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
draw(hoverIdx);
}, },
[points, onClickPosition, onDragSelect, hoverIdx, draw], [points, onHover, onDragSelect, onClickPosition, draw],
); );
const handleTouchStart = useCallback( const handleTouchStart = useCallback(
@ -506,7 +510,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}

View file

@ -284,7 +284,7 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
{isZoomedByChart && ( {isZoomedByChart && (
<button <button
onClick={handleResetZoom} onClick={handleResetZoom}
className="absolute right-3 top-1 z-10 rounded bg-white px-2 py-0.5 text-[11px] font-medium text-gray-600 shadow ring-1 ring-gray-200 hover:bg-gray-50" className="absolute bottom-7 right-3 z-10 rounded-md bg-bg-raised px-2 py-0.5 text-[11px] font-medium text-text-md shadow-sm ring-1 ring-border transition-colors hover:bg-bg-subtle"
> >
{t("elevation.resetZoom", "Reset zoom")} {t("elevation.resetZoom", "Reset zoom")}
</button> </button>