From 3345ef127e932d43d791cb6013c74e2b18d795b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:23:37 +0200 Subject: [PATCH 1/8] =?UTF-8?q?Add=20bidirectional=20elevation=20chart=20?= =?UTF-8?q?=E2=86=94=20map=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../planner/app/components/ElevationChart.tsx | 152 +++++++++++++++++- apps/planner/app/components/PlannerMap.tsx | 56 ++++++- apps/planner/app/components/SessionView.tsx | 42 ++++- .../elevation-map-interaction/design.md | 69 ++++++++ .../specs/elevation-map-interaction/spec.md | 31 ++++ .../elevation-map-interaction/tasks.md | 25 +++ 6 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/elevation-map-interaction/design.md create mode 100644 openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md create mode 100644 openspec/changes/elevation-map-interaction/tasks.md diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index b7cd549..b0e9215 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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([]); const [hoverIdx, setHoverIdx] = useState(null); @@ -93,6 +96,36 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = points; + const isExternalHover = useRef(false); + const dragStartX = useRef(null); + const dragStartClientX = useRef(null); + const isDragging = useRef(false); + const dragCurrentX = useRef(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) => { + 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) => { + 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} /> ); diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 4b62318..98b5b3f 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -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 }) { 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([]); 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 && ( + [c[1]!, c[0]!] as [number, number])} + pathOptions={{ weight: 20, opacity: 0, interactive: true }} + eventHandlers={{ + mouseover: handleRoutePolylineHover, + mousemove: handleRoutePolylineHover, + mouseout: handleRoutePolylineOut, + }} + /> + )} )} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 08fb9f1..4f66b20 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -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(null); + const [highlightChartDistance, setHighlightChartDistance] = useState(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).__leafletMap as L.Map | undefined; + map?.panTo(position); + }, []); + + const handleChartDragSelect = useCallback((bounds: [[number, number], [number, number]]) => { + const map = (window as unknown as Record).__leafletMap as L.Map | undefined; + map?.fitBounds(bounds, { padding: [30, 30] }); + setIsZoomedByChart(true); + }, []); + + const handleResetZoom = useCallback(() => { + const map = (window as unknown as Record).__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 (
@@ -256,11 +284,21 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
} > - addToast(msg, "error")} days={days} /> + addToast(msg, "error")} days={days} /> - +
+ + {isZoomedByChart && ( + + )} +
diff --git a/openspec/changes/elevation-map-interaction/design.md b/openspec/changes/elevation-map-interaction/design.md new file mode 100644 index 0000000..561f8c5 --- /dev/null +++ b/openspec/changes/elevation-map-interaction/design.md @@ -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. 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 new file mode 100644 index 0000000..305d02e --- /dev/null +++ b/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md @@ -0,0 +1,31 @@ +## 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 diff --git a/openspec/changes/elevation-map-interaction/tasks.md b/openspec/changes/elevation-map-interaction/tasks.md new file mode 100644 index 0000000..de88751 --- /dev/null +++ b/openspec/changes/elevation-map-interaction/tasks.md @@ -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) From 66885c82f26945e3c42f80e76ac28ef9d4a2e57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:26:14 +0200 Subject: [PATCH 2/8] Add mobile touch support for elevation chart interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single touch + drag: scrub the highlight point along the chart, map dot follows in real-time - Tap: pan map to that point (10px threshold for movement) - Two-finger touch: range select — area between fingers highlighted, on release zooms map to fit that route section - touch-none CSS + preventDefault prevents page scrolling on chart Spec updated with mobile touch interaction scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../planner/app/components/ElevationChart.tsx | 156 +++++++++++++++++- .../specs/elevation-map-interaction/spec.md | 21 +++ 2 files changed, 176 insertions(+), 1 deletion(-) 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) From ca482842527ac9468522167d1789e0f04510f630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:34:25 +0200 Subject: [PATCH 3/8] Fix iOS safe area: header respects notch, chart respects home indicator - viewport-fit=cover enables safe area insets in Safari - Header: pt-[max(0.5rem,env(safe-area-inset-top))] clears the notch - Elevation chart: pb-[max(0.5rem,env(safe-area-inset-bottom))] clears the home indicator bar Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ElevationChart.tsx | 2 +- apps/planner/app/components/SessionView.tsx | 2 +- apps/planner/app/root.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index cd832d4..fa98b32 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -878,7 +878,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio if (points.length < 2) return null; return ( -
+
{(() => { const osmLinks: Record = { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 4f66b20..8131d42 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -223,7 +223,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, return ( <> -
+
{t("title")} diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index cb6df2b..bf20d8e 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -13,7 +13,7 @@ export function Layout({ children }: { children: React.ReactNode }) { - + From 9b3c0a8d444064f6c5eac70df2b8ec41fbacee73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:35:56 +0200 Subject: [PATCH 4/8] Disable page zoom on iOS to prevent accidental pinch-to-zoom maximum-scale=1 + user-scalable=no prevents the browser UI from zooming when pinching. Map zoom (Leaflet) still works since it handles touch events independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index bf20d8e..b86db49 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -13,7 +13,7 @@ export function Layout({ children }: { children: React.ReactNode }) { - + From 650fa0f36ae938fdec45007bdb5d3180d3e0b895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:39:19 +0200 Subject: [PATCH 5/8] Fix mobile header layout: hide secondary items on small screens - Participant list: hidden on mobile (sm:block) - Undo/redo buttons: hidden on mobile (sm:flex) - Connection status: hidden on mobile (sm:inline) - Header: no-wrap, tighter padding on mobile - Username input: text-base on mobile to prevent iOS auto-zoom Keeps profile selector and export button visible on all sizes. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ParticipantList.tsx | 2 +- apps/planner/app/components/SessionView.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/planner/app/components/ParticipantList.tsx b/apps/planner/app/components/ParticipantList.tsx index d3837a2..154c3d9 100644 --- a/apps/planner/app/components/ParticipantList.tsx +++ b/apps/planner/app/components/ParticipantList.tsx @@ -109,7 +109,7 @@ export function ParticipantList({ yjs }: ParticipantListProps) { onBlur={commitEdit} onKeyDown={handleKeyDown} 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" /> ) : (
-
+
{callbackUrl && callbackToken && ( {t("computingRoute")} )} - + {yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
From 7c0a45abe5a3efcfb975479225251fe56d9190bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:39:55 +0200 Subject: [PATCH 6/8] Hide computing route text on mobile to prevent layout reflow Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/SessionView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 312316f..0d51754 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -262,7 +262,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, )} {computing && ( - {t("computingRoute")} + {t("computingRoute")} )} {yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)} From a9a576081e8447ba5616d0311fff888745ec469e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:41:17 +0200 Subject: [PATCH 7/8] Update spec with iOS safe area, responsive header, and mobile touch Added requirements for: - iOS safe area insets (notch, home indicator) - No accidental page zoom (viewport user-scalable=no) - Responsive header (hide secondary controls on mobile) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/elevation-map-interaction/spec.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 a4bd198..eeea56b 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 @@ -50,3 +50,30 @@ The elevation chart SHALL support touch-based interaction on mobile devices. #### 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 From 471ecb2309539a32386e2660f8f4320d40997bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 19:43:03 +0200 Subject: [PATCH 8/8] Archive elevation-map-interaction, sync spec to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced new spec (chart ↔ map interaction, mobile touch, iOS safe area, responsive header) to openspec/specs/elevation-map-interaction/. All 4 artifacts complete. All 14 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/elevation-map-interaction/spec.md | 0 .../tasks.md | 0 .../specs/elevation-map-interaction/spec.md | 79 +++++++++++++++++++ 6 files changed, 79 insertions(+) rename openspec/changes/{elevation-map-interaction => archive/2026-04-12-elevation-map-interaction}/.openspec.yaml (100%) rename openspec/changes/{elevation-map-interaction => archive/2026-04-12-elevation-map-interaction}/design.md (100%) rename openspec/changes/{elevation-map-interaction => archive/2026-04-12-elevation-map-interaction}/proposal.md (100%) rename openspec/changes/{elevation-map-interaction => archive/2026-04-12-elevation-map-interaction}/specs/elevation-map-interaction/spec.md (100%) rename openspec/changes/{elevation-map-interaction => archive/2026-04-12-elevation-map-interaction}/tasks.md (100%) create mode 100644 openspec/specs/elevation-map-interaction/spec.md diff --git a/openspec/changes/elevation-map-interaction/.openspec.yaml b/openspec/changes/archive/2026-04-12-elevation-map-interaction/.openspec.yaml similarity index 100% rename from openspec/changes/elevation-map-interaction/.openspec.yaml rename to openspec/changes/archive/2026-04-12-elevation-map-interaction/.openspec.yaml diff --git a/openspec/changes/elevation-map-interaction/design.md b/openspec/changes/archive/2026-04-12-elevation-map-interaction/design.md similarity index 100% rename from openspec/changes/elevation-map-interaction/design.md rename to openspec/changes/archive/2026-04-12-elevation-map-interaction/design.md diff --git a/openspec/changes/elevation-map-interaction/proposal.md b/openspec/changes/archive/2026-04-12-elevation-map-interaction/proposal.md similarity index 100% rename from openspec/changes/elevation-map-interaction/proposal.md rename to openspec/changes/archive/2026-04-12-elevation-map-interaction/proposal.md diff --git a/openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md b/openspec/changes/archive/2026-04-12-elevation-map-interaction/specs/elevation-map-interaction/spec.md similarity index 100% rename from openspec/changes/elevation-map-interaction/specs/elevation-map-interaction/spec.md rename to openspec/changes/archive/2026-04-12-elevation-map-interaction/specs/elevation-map-interaction/spec.md diff --git a/openspec/changes/elevation-map-interaction/tasks.md b/openspec/changes/archive/2026-04-12-elevation-map-interaction/tasks.md similarity index 100% rename from openspec/changes/elevation-map-interaction/tasks.md rename to openspec/changes/archive/2026-04-12-elevation-map-interaction/tasks.md diff --git a/openspec/specs/elevation-map-interaction/spec.md b/openspec/specs/elevation-map-interaction/spec.md new file mode 100644 index 0000000..eeea56b --- /dev/null +++ b/openspec/specs/elevation-map-interaction/spec.md @@ -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