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
|
|
@ -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