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:
parent
9a46a7eac0
commit
3345ef127e
6 changed files with 369 additions and 6 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import L from "leaflet";
|
||||
import type { TFunction } from "i18next";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
||||
|
|
@ -165,6 +166,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
const days = useDays(yjs);
|
||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
|
||||
const [highlightChartDistance, setHighlightChartDistance] = useState<number | null>(null);
|
||||
const [isZoomedByChart, setIsZoomedByChart] = useState(false);
|
||||
const { toasts, addToast } = useToasts();
|
||||
useAwarenessToasts(yjs, t, addToast);
|
||||
|
||||
|
|
@ -185,6 +188,31 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
setHighlightPosition(pos);
|
||||
}, []);
|
||||
|
||||
const handleChartClick = useCallback((position: [number, number]) => {
|
||||
const map = (window as unknown as Record<string, unknown>).__leafletMap as L.Map | undefined;
|
||||
map?.panTo(position);
|
||||
}, []);
|
||||
|
||||
const handleChartDragSelect = useCallback((bounds: [[number, number], [number, number]]) => {
|
||||
const map = (window as unknown as Record<string, unknown>).__leafletMap as L.Map | undefined;
|
||||
map?.fitBounds(bounds, { padding: [30, 30] });
|
||||
setIsZoomedByChart(true);
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
const map = (window as unknown as Record<string, unknown>).__leafletMap as L.Map | undefined;
|
||||
if (!map || !yjs) return;
|
||||
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
|
||||
if (!coordsJson) return;
|
||||
try {
|
||||
const coords: [number, number, number][] = JSON.parse(coordsJson);
|
||||
if (coords.length < 2) return;
|
||||
const latLngs = coords.map((c) => [c[1]!, c[0]!] as [number, number]);
|
||||
map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] });
|
||||
} catch { /* ignore */ }
|
||||
setIsZoomedByChart(false);
|
||||
}, [yjs]);
|
||||
|
||||
if (!yjs) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -256,11 +284,21 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} onImportError={(msg) => addToast(msg, "error")} days={days} />
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} onRouteHover={setHighlightChartDistance} onImportError={(msg) => addToast(msg, "error")} days={days} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} days={days} />
|
||||
<div className="relative">
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} highlightDistance={highlightChartDistance} onClickPosition={handleChartClick} onDragSelect={handleChartDragSelect} days={days} />
|
||||
{isZoomedByChart && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{t("elevation.resetZoom", "Reset zoom")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
</main>
|
||||
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue