diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index b0e9215..cd832d4 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -720,6 +720,157 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio [points, onClickPosition, onDragSelect, hoverIdx, drawChart], ); + // Touch handlers for mobile + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + 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) => { + 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) => { + 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) => { yjs.routeData.set("colorMode", mode); }, [yjs.routeData]); @@ -848,11 +999,14 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio ); diff --git a/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md b/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md index 305d02e..a4bd198 100644 --- a/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md +++ b/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md @@ -29,3 +29,24 @@ Dragging a range on the elevation chart SHALL zoom the map to fit that section o #### 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)