Merge pull request #211 from trails-cool/feat/elevation-map-interaction
Add bidirectional elevation chart ↔ map interaction
This commit is contained in:
commit
5707983903
11 changed files with 663 additions and 17 deletions
|
|
@ -75,10 +75,13 @@ const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||||
interface ElevationChartProps {
|
interface ElevationChartProps {
|
||||||
yjs: YjsState;
|
yjs: YjsState;
|
||||||
onHover?: (position: [number, number] | null) => void;
|
onHover?: (position: [number, number] | null) => void;
|
||||||
|
highlightDistance?: number | null;
|
||||||
|
onClickPosition?: (position: [number, number]) => void;
|
||||||
|
onDragSelect?: (bounds: [[number, number], [number, number]]) => void;
|
||||||
days?: DayStage[];
|
days?: DayStage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) {
|
||||||
const { t } = useTranslation("planner");
|
const { t } = useTranslation("planner");
|
||||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||||
|
|
@ -93,6 +96,36 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||||
pointsRef.current = points;
|
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(() => {
|
useEffect(() => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
|
@ -524,6 +557,19 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||||
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
||||||
ctx.fillText(label, labelX, PADDING.top + 10);
|
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],
|
[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 chartW = rect.width - PADDING.left - PADDING.right;
|
||||||
const ratio = (mouseX - PADDING.left) / chartW;
|
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) {
|
if (ratio < 0 || ratio > 1) {
|
||||||
setHoverIdx(null);
|
setHoverIdx(null);
|
||||||
onHover?.(null);
|
onHover?.(null);
|
||||||
|
|
@ -562,18 +619,258 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isExternalHover.current = false;
|
||||||
setHoverIdx(closest);
|
setHoverIdx(closest);
|
||||||
const p = points[closest]!;
|
const p = points[closest]!;
|
||||||
onHover?.([p.lat, p.lon]);
|
onHover?.([p.lat, p.lon]);
|
||||||
},
|
},
|
||||||
[points, onHover],
|
[points, onHover, hoverIdx, drawChart],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
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 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Touch handlers for mobile
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
// Single touch: start scrub + potential drag
|
||||||
|
const x = e.touches[0]!.clientX - rect.left;
|
||||||
|
dragStartX.current = x;
|
||||||
|
dragStartClientX.current = e.touches[0]!.clientX;
|
||||||
|
isDragging.current = false;
|
||||||
|
dragCurrentX.current = null;
|
||||||
|
// Immediately show highlight at touch position
|
||||||
|
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||||
|
const ratio = (x - PADDING.left) / chartW;
|
||||||
|
if (ratio >= 0 && ratio <= 1 && points.length >= 2) {
|
||||||
|
const maxDist = points[points.length - 1]!.distance;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
isExternalHover.current = false;
|
||||||
|
setHoverIdx(closest);
|
||||||
|
const p = points[closest]!;
|
||||||
|
onHover?.([p.lat, p.lon]);
|
||||||
|
}
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Two fingers: range select
|
||||||
|
const x1 = e.touches[0]!.clientX - rect.left;
|
||||||
|
const x2 = e.touches[1]!.clientX - rect.left;
|
||||||
|
dragStartX.current = Math.min(x1, x2);
|
||||||
|
dragCurrentX.current = Math.max(x1, x2);
|
||||||
|
isDragging.current = true;
|
||||||
|
drawChart(hoverIdx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[points, onHover, hoverIdx, drawChart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback(
|
||||||
|
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || points.length < 2) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (e.touches.length === 1 && !isDragging.current) {
|
||||||
|
// Single touch scrub: update highlight
|
||||||
|
const x = e.touches[0]!.clientX - rect.left;
|
||||||
|
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||||
|
const ratio = (x - PADDING.left) / chartW;
|
||||||
|
if (ratio >= 0 && ratio <= 1) {
|
||||||
|
const maxDist = points[points.length - 1]!.distance;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
isExternalHover.current = false;
|
||||||
|
setHoverIdx(closest);
|
||||||
|
const p = points[closest]!;
|
||||||
|
onHover?.([p.lat, p.lon]);
|
||||||
|
}
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Two finger range select: update selection
|
||||||
|
const x1 = e.touches[0]!.clientX - rect.left;
|
||||||
|
const x2 = e.touches[1]!.clientX - rect.left;
|
||||||
|
dragStartX.current = Math.min(x1, x2);
|
||||||
|
dragCurrentX.current = Math.max(x1, x2);
|
||||||
|
isDragging.current = true;
|
||||||
|
drawChart(hoverIdx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[points, onHover, hoverIdx, drawChart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(
|
||||||
|
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) {
|
||||||
|
// Two finger range complete: zoom map
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||||
|
const maxDist = points[points.length - 1]!.distance;
|
||||||
|
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
|
||||||
|
const endRatio = Math.max(0, Math.min(1, (dragCurrentX.current - 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 (e.changedTouches.length === 1 && onClickPosition && dragStartClientX.current != null) {
|
||||||
|
// Single tap (no significant movement): pan map
|
||||||
|
const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current);
|
||||||
|
if (dx <= 10) {
|
||||||
|
// Treat as tap → pan
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas && points.length >= 2) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||||
|
const mouseX = e.changedTouches[0]!.clientX - rect.left;
|
||||||
|
const ratio = (mouseX - PADDING.left) / chartW;
|
||||||
|
if (ratio >= 0 && ratio <= 1) {
|
||||||
|
const maxDist = points[points.length - 1]!.distance;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear highlight on touch end
|
||||||
|
setHoverIdx(null);
|
||||||
|
onHover?.(null);
|
||||||
|
dragStartX.current = null;
|
||||||
|
dragStartClientX.current = null;
|
||||||
|
isDragging.current = false;
|
||||||
|
dragCurrentX.current = null;
|
||||||
|
drawChart(null);
|
||||||
|
},
|
||||||
|
[points, onHover, onClickPosition, onDragSelect, drawChart],
|
||||||
|
);
|
||||||
|
|
||||||
const setMode = useCallback((mode: string) => {
|
const setMode = useCallback((mode: string) => {
|
||||||
yjs.routeData.set("colorMode", mode);
|
yjs.routeData.set("colorMode", mode);
|
||||||
}, [yjs.routeData]);
|
}, [yjs.routeData]);
|
||||||
|
|
@ -581,7 +878,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||||
if (points.length < 2) return null;
|
if (points.length < 2) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-gray-200 px-2 py-2">
|
<div className="border-t border-gray-200 px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||||
<div className="mb-1 flex items-center gap-2 px-2">
|
<div className="mb-1 flex items-center gap-2 px-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const osmLinks: Record<string, string> = {
|
const osmLinks: Record<string, string> = {
|
||||||
|
|
@ -702,9 +999,14 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
||||||
</div>
|
</div>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="h-24 w-full cursor-crosshair"
|
className="h-24 w-full cursor-crosshair touch-none"
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export function ParticipantList({ yjs }: ParticipantListProps) {
|
||||||
onBlur={commitEdit}
|
onBlur={commitEdit}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className="w-20 rounded border border-gray-300 px-1 py-0 text-xs text-gray-700 focus:border-blue-500 focus:outline-none"
|
className="w-20 rounded border border-gray-300 px-1 py-0 text-base sm:text-xs text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
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 L from "leaflet";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -92,6 +92,7 @@ interface PlannerMapProps {
|
||||||
onImportError?: (message: string) => void;
|
onImportError?: (message: string) => void;
|
||||||
highlightPosition?: [number, number] | null;
|
highlightPosition?: [number, number] | null;
|
||||||
highlightedWaypoint?: number | null;
|
highlightedWaypoint?: number | null;
|
||||||
|
onRouteHover?: (distance: number | null) => void;
|
||||||
days?: DayStage[];
|
days?: DayStage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,7 +362,7 @@ function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
|
||||||
return null;
|
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 { t } = useTranslation("planner");
|
||||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||||
const poiState = usePois();
|
const poiState = usePois();
|
||||||
|
|
@ -607,6 +608,46 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
||||||
[yjs.waypoints],
|
[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) => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounterRef.current++;
|
dragCounterRef.current++;
|
||||||
|
|
@ -784,6 +825,17 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
||||||
suspendedRef={routeInteractionSuspendedRef}
|
suspendedRef={routeInteractionSuspendedRef}
|
||||||
disabled={noGoDrawing}
|
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 { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import L from "leaflet";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
||||||
|
|
@ -165,6 +166,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
const days = useDays(yjs);
|
const days = useDays(yjs);
|
||||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||||
const [highlightedWaypoint, setHighlightedWaypoint] = useState<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();
|
const { toasts, addToast } = useToasts();
|
||||||
useAwarenessToasts(yjs, t, addToast);
|
useAwarenessToasts(yjs, t, addToast);
|
||||||
|
|
||||||
|
|
@ -185,6 +188,31 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
setHighlightPosition(pos);
|
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) {
|
if (!yjs) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -195,14 +223,16 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-gray-200 px-4 py-2">
|
<header className="flex items-center justify-between gap-2 border-b border-gray-200 px-3 py-1.5 pt-[max(0.375rem,env(safe-area-inset-top))] sm:px-4 sm:py-2">
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-1.5 sm:gap-4 min-w-0">
|
||||||
<Link to="/" className="hidden text-lg font-semibold text-gray-900 hover:text-blue-600 sm:block">
|
<Link to="/" className="hidden text-lg font-semibold text-gray-900 hover:text-blue-600 sm:block">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</Link>
|
</Link>
|
||||||
<ProfileSelector yjs={yjs} />
|
<ProfileSelector yjs={yjs} />
|
||||||
<ParticipantList yjs={yjs} />
|
<div className="hidden sm:block">
|
||||||
<div className="flex gap-1">
|
<ParticipantList yjs={yjs} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={undo}
|
onClick={undo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
|
|
@ -221,7 +251,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||||
{callbackUrl && callbackToken && (
|
{callbackUrl && callbackToken && (
|
||||||
<SaveToJournalButton
|
<SaveToJournalButton
|
||||||
yjs={yjs}
|
yjs={yjs}
|
||||||
|
|
@ -232,9 +262,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
)}
|
)}
|
||||||
<ExportButton yjs={yjs} />
|
<ExportButton yjs={yjs} />
|
||||||
{computing && (
|
{computing && (
|
||||||
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
<span className="hidden text-xs text-blue-600 sm:inline">{t("computingRoute")}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-gray-500">
|
<span className="hidden text-sm text-gray-500 sm:inline">
|
||||||
{yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
|
{yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -256,11 +286,21 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
</div>
|
</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>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
<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>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
|
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
<html lang={i18n.language}>
|
<html lang={i18n.language}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Planner has one-directional interaction: hovering the elevation chart shows
|
||||||
|
a red dot on the map via `highlightPosition` state in `SessionView`. The reverse
|
||||||
|
direction (map → chart) and click/drag interactions don't exist.
|
||||||
|
|
||||||
|
The route coordinates are stored in Yjs `routeData` as a JSON array of
|
||||||
|
`[lon, lat, ele]` points. The elevation chart extracts these into
|
||||||
|
`ElevationPoint[]` with cumulative distance. The map renders the route via
|
||||||
|
`ColoredRoute` as Leaflet polylines.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Route hover → chart highlight
|
||||||
|
|
||||||
|
Add a `mousemove` handler on the `ColoredRoute` polyline segments. On hover,
|
||||||
|
find the closest route coordinate index, compute the cumulative distance at
|
||||||
|
that index, and pass it up to `SessionView` as `highlightChartDistance`. The
|
||||||
|
`ElevationChart` receives this distance and draws the crosshair at that
|
||||||
|
position — reusing the existing `drawChart(highlightIdx)` mechanism.
|
||||||
|
|
||||||
|
To avoid expensive per-pixel distance calculations on every mousemove, use
|
||||||
|
Leaflet's `closestLayerPoint` or project to screen coordinates and find the
|
||||||
|
nearest point in the coordinate array.
|
||||||
|
|
||||||
|
### D2: Chart click → map pan
|
||||||
|
|
||||||
|
Add an `onClick` handler to the elevation chart canvas. Convert the click
|
||||||
|
x-position to a distance along the route, find the corresponding coordinate,
|
||||||
|
and call `map.panTo([lat, lon])` via a callback. The map reference is exposed
|
||||||
|
via `window.__leafletMap` (already used by E2E tests).
|
||||||
|
|
||||||
|
### D3: Chart drag-select → map zoom
|
||||||
|
|
||||||
|
Add mousedown/mousemove/mouseup handlers to the chart canvas for range
|
||||||
|
selection. While dragging, draw a semi-transparent overlay on the selected
|
||||||
|
range. On mouseup, compute the route coordinates within the selected distance
|
||||||
|
range and call `map.fitBounds()` on their bounding box.
|
||||||
|
|
||||||
|
A visual "reset zoom" button appears after drag-zoom to return to the full
|
||||||
|
route view.
|
||||||
|
|
||||||
|
### D4: State flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SessionView
|
||||||
|
├── highlightPosition: [lat, lon] | null (chart → map, existing)
|
||||||
|
├── highlightChartDistance: number | null (map → chart, new)
|
||||||
|
└── onMapFitBounds: (bounds) => void (chart → map, new)
|
||||||
|
|
||||||
|
ElevationChart
|
||||||
|
├── onHover(position) — existing, chart → map
|
||||||
|
├── onClick(position) — new, chart → map pan
|
||||||
|
├── onDragSelect(bounds) — new, chart → map zoom
|
||||||
|
└── highlightDistance — new, map → chart
|
||||||
|
|
||||||
|
PlannerMap / ColoredRoute
|
||||||
|
└── onRouteHover(distance) — new, map → chart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Performance**: Route hover on the map triggers distance lookup on every
|
||||||
|
mousemove. Mitigate by throttling and using screen-space projection.
|
||||||
|
- **Polyline interactivity**: ColoredRoute renders many small polyline segments.
|
||||||
|
Making them all interactive adds event listeners. Alternative: use a single
|
||||||
|
invisible overlay polyline for hover detection.
|
||||||
|
- **Drag conflict**: Chart drag-select must not conflict with chart hover.
|
||||||
|
Use a minimum drag distance threshold (5px) before entering drag mode.
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Route hover highlights elevation chart
|
||||||
|
Hovering over the route polyline on the map SHALL highlight the corresponding position on the elevation chart.
|
||||||
|
|
||||||
|
#### Scenario: Hover route on map
|
||||||
|
- **WHEN** a user hovers over the route polyline on the map
|
||||||
|
- **THEN** the elevation chart shows a crosshair at the corresponding distance along the route
|
||||||
|
|
||||||
|
#### Scenario: Leave route on map
|
||||||
|
- **WHEN** a user moves the mouse away from the route polyline
|
||||||
|
- **THEN** the elevation chart crosshair disappears
|
||||||
|
|
||||||
|
### Requirement: Chart click pans map
|
||||||
|
Clicking on the elevation chart SHALL pan the map to center on that point along the route.
|
||||||
|
|
||||||
|
#### Scenario: Click chart
|
||||||
|
- **WHEN** a user clicks on the elevation chart
|
||||||
|
- **THEN** the map pans to center on the corresponding route coordinate
|
||||||
|
|
||||||
|
### Requirement: Chart drag-select zooms map
|
||||||
|
Dragging a range on the elevation chart SHALL zoom the map to fit that section of the route.
|
||||||
|
|
||||||
|
#### Scenario: Drag select range
|
||||||
|
- **WHEN** a user clicks and drags horizontally on the elevation chart
|
||||||
|
- **THEN** a visual highlight shows the selected range
|
||||||
|
- **AND** on mouse release, the map zooms to fit the route coordinates within that range
|
||||||
|
|
||||||
|
#### Scenario: Reset zoom
|
||||||
|
- **WHEN** the map has been zoomed via chart drag-select
|
||||||
|
- **THEN** a reset button appears to return to the full route view
|
||||||
|
|
||||||
|
### Requirement: Mobile touch interaction
|
||||||
|
The elevation chart SHALL support touch-based interaction on mobile devices.
|
||||||
|
|
||||||
|
#### Scenario: Single touch scrub
|
||||||
|
- **WHEN** a user touches and drags on the chart with one finger
|
||||||
|
- **THEN** the crosshair follows the finger position along the chart
|
||||||
|
- **AND** the map highlight dot follows in real-time
|
||||||
|
|
||||||
|
#### Scenario: Tap to pan
|
||||||
|
- **WHEN** a user taps the chart (touch without significant movement)
|
||||||
|
- **THEN** the map pans to that point along the route
|
||||||
|
|
||||||
|
#### Scenario: Two-finger range select
|
||||||
|
- **WHEN** a user places two fingers on the chart
|
||||||
|
- **THEN** the area between the fingers is highlighted as a selection range
|
||||||
|
- **AND** on release, the map zooms to fit the route coordinates in that range
|
||||||
|
|
||||||
|
#### Scenario: No page scroll on touch
|
||||||
|
- **WHEN** a user touches the elevation chart
|
||||||
|
- **THEN** page scrolling is prevented (touch-none CSS + preventDefault)
|
||||||
|
|
||||||
|
### Requirement: iOS safe area support
|
||||||
|
The Planner layout SHALL respect iOS safe area insets.
|
||||||
|
|
||||||
|
#### Scenario: Notch/Dynamic Island clearance
|
||||||
|
- **WHEN** the Planner is viewed on an iOS device with a notch or Dynamic Island
|
||||||
|
- **THEN** the header bar is padded below the safe area inset
|
||||||
|
|
||||||
|
#### Scenario: Home indicator clearance
|
||||||
|
- **WHEN** the Planner is viewed on an iOS device without a physical home button
|
||||||
|
- **THEN** the elevation chart area is padded above the home indicator
|
||||||
|
|
||||||
|
#### Scenario: No accidental page zoom
|
||||||
|
- **WHEN** a user pinches on the Planner UI
|
||||||
|
- **THEN** the browser-level page zoom does not trigger (viewport user-scalable=no)
|
||||||
|
|
||||||
|
### Requirement: Responsive mobile header
|
||||||
|
The Planner header SHALL adapt to small screens by hiding secondary controls.
|
||||||
|
|
||||||
|
#### Scenario: Small screen header
|
||||||
|
- **WHEN** the Planner is viewed on a mobile device
|
||||||
|
- **THEN** only the profile selector and export button are visible
|
||||||
|
- **AND** participant list, undo/redo, connection status, and computing text are hidden
|
||||||
|
|
||||||
|
#### Scenario: Desktop header
|
||||||
|
- **WHEN** the Planner is viewed on a desktop screen
|
||||||
|
- **THEN** all header controls are visible
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
## 1. Route Hover → Chart Highlight
|
||||||
|
|
||||||
|
- [x] 1.1 Add invisible interactive overlay polyline on the route in PlannerMap for hover detection (avoids adding listeners to every ColoredRoute segment)
|
||||||
|
- [x] 1.2 On polyline hover, find closest route coordinate index and compute cumulative distance
|
||||||
|
- [x] 1.3 Pass `highlightChartDistance` up from PlannerMap to SessionView
|
||||||
|
- [x] 1.4 Accept `highlightDistance` prop in ElevationChart, find closest point by distance, draw crosshair
|
||||||
|
|
||||||
|
## 2. Chart Click → Map Pan
|
||||||
|
|
||||||
|
- [x] 2.1 Add click handler to ElevationChart canvas — convert x-position to route coordinate
|
||||||
|
- [x] 2.2 Call `onClickPosition([lat, lon])` callback to pan the map
|
||||||
|
- [x] 2.3 Wire the callback through SessionView to call `map.panTo()` via the exposed map ref
|
||||||
|
|
||||||
|
## 3. Chart Drag-Select → Map Zoom
|
||||||
|
|
||||||
|
- [x] 3.1 Add mousedown/mousemove/mouseup handlers for drag selection on ElevationChart canvas
|
||||||
|
- [x] 3.2 Draw semi-transparent overlay rectangle during drag
|
||||||
|
- [x] 3.3 On mouseup, compute bounding box of route coordinates in the selected distance range
|
||||||
|
- [x] 3.4 Call `onDragSelect(bounds)` callback to zoom the map via `map.fitBounds()`
|
||||||
|
- [x] 3.5 Show "Reset zoom" button after drag-zoom, clicking it returns to full route bounds
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
- [x] 4.1 E2E test: hover chart → map dot appears (existing, verify still works)
|
||||||
|
- [x] 4.2 E2E test: click chart → map pans (verify map center changes)
|
||||||
79
openspec/specs/elevation-map-interaction/spec.md
Normal file
79
openspec/specs/elevation-map-interaction/spec.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Route hover highlights elevation chart
|
||||||
|
Hovering over the route polyline on the map SHALL highlight the corresponding position on the elevation chart.
|
||||||
|
|
||||||
|
#### Scenario: Hover route on map
|
||||||
|
- **WHEN** a user hovers over the route polyline on the map
|
||||||
|
- **THEN** the elevation chart shows a crosshair at the corresponding distance along the route
|
||||||
|
|
||||||
|
#### Scenario: Leave route on map
|
||||||
|
- **WHEN** a user moves the mouse away from the route polyline
|
||||||
|
- **THEN** the elevation chart crosshair disappears
|
||||||
|
|
||||||
|
### Requirement: Chart click pans map
|
||||||
|
Clicking on the elevation chart SHALL pan the map to center on that point along the route.
|
||||||
|
|
||||||
|
#### Scenario: Click chart
|
||||||
|
- **WHEN** a user clicks on the elevation chart
|
||||||
|
- **THEN** the map pans to center on the corresponding route coordinate
|
||||||
|
|
||||||
|
### Requirement: Chart drag-select zooms map
|
||||||
|
Dragging a range on the elevation chart SHALL zoom the map to fit that section of the route.
|
||||||
|
|
||||||
|
#### Scenario: Drag select range
|
||||||
|
- **WHEN** a user clicks and drags horizontally on the elevation chart
|
||||||
|
- **THEN** a visual highlight shows the selected range
|
||||||
|
- **AND** on mouse release, the map zooms to fit the route coordinates within that range
|
||||||
|
|
||||||
|
#### Scenario: Reset zoom
|
||||||
|
- **WHEN** the map has been zoomed via chart drag-select
|
||||||
|
- **THEN** a reset button appears to return to the full route view
|
||||||
|
|
||||||
|
### Requirement: Mobile touch interaction
|
||||||
|
The elevation chart SHALL support touch-based interaction on mobile devices.
|
||||||
|
|
||||||
|
#### Scenario: Single touch scrub
|
||||||
|
- **WHEN** a user touches and drags on the chart with one finger
|
||||||
|
- **THEN** the crosshair follows the finger position along the chart
|
||||||
|
- **AND** the map highlight dot follows in real-time
|
||||||
|
|
||||||
|
#### Scenario: Tap to pan
|
||||||
|
- **WHEN** a user taps the chart (touch without significant movement)
|
||||||
|
- **THEN** the map pans to that point along the route
|
||||||
|
|
||||||
|
#### Scenario: Two-finger range select
|
||||||
|
- **WHEN** a user places two fingers on the chart
|
||||||
|
- **THEN** the area between the fingers is highlighted as a selection range
|
||||||
|
- **AND** on release, the map zooms to fit the route coordinates in that range
|
||||||
|
|
||||||
|
#### Scenario: No page scroll on touch
|
||||||
|
- **WHEN** a user touches the elevation chart
|
||||||
|
- **THEN** page scrolling is prevented (touch-none CSS + preventDefault)
|
||||||
|
|
||||||
|
### Requirement: iOS safe area support
|
||||||
|
The Planner layout SHALL respect iOS safe area insets.
|
||||||
|
|
||||||
|
#### Scenario: Notch/Dynamic Island clearance
|
||||||
|
- **WHEN** the Planner is viewed on an iOS device with a notch or Dynamic Island
|
||||||
|
- **THEN** the header bar is padded below the safe area inset
|
||||||
|
|
||||||
|
#### Scenario: Home indicator clearance
|
||||||
|
- **WHEN** the Planner is viewed on an iOS device without a physical home button
|
||||||
|
- **THEN** the elevation chart area is padded above the home indicator
|
||||||
|
|
||||||
|
#### Scenario: No accidental page zoom
|
||||||
|
- **WHEN** a user pinches on the Planner UI
|
||||||
|
- **THEN** the browser-level page zoom does not trigger (viewport user-scalable=no)
|
||||||
|
|
||||||
|
### Requirement: Responsive mobile header
|
||||||
|
The Planner header SHALL adapt to small screens by hiding secondary controls.
|
||||||
|
|
||||||
|
#### Scenario: Small screen header
|
||||||
|
- **WHEN** the Planner is viewed on a mobile device
|
||||||
|
- **THEN** only the profile selector and export button are visible
|
||||||
|
- **AND** participant list, undo/redo, connection status, and computing text are hidden
|
||||||
|
|
||||||
|
#### Scenario: Desktop header
|
||||||
|
- **WHEN** the Planner is viewed on a desktop screen
|
||||||
|
- **THEN** all header controls are visible
|
||||||
Loading…
Add table
Add a link
Reference in a new issue