From 6ee07f0570c25679c59f4e92ec5c265938226640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 07:30:00 +0100 Subject: [PATCH] Fix waypoint icons and add elevation chart scrubbing Waypoint icons: - Replace broken Leaflet default icons with numbered blue circles - Numbers match sidebar order (1, 2, 3...) Elevation chart: - Mouse hover shows crosshair, elevation, and distance at cursor - Red dot appears on the map at the corresponding route position - Dot disappears when mouse leaves the chart Co-Authored-By: Claude Opus 4.6 (1M context) --- .../planner/app/components/ElevationChart.tsx | 224 +++++++++++++----- apps/planner/app/components/PlannerMap.tsx | 36 ++- apps/planner/app/components/SessionView.tsx | 11 +- 3 files changed, 198 insertions(+), 73 deletions(-) diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 0c36eeb..214472a 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import type { YjsState } from "~/lib/use-yjs"; interface ElevationPoint { - distance: number; // meters from start - elevation: number; // meters + distance: number; + elevation: number; + lat: number; + lon: number; } function extractElevation(geojsonStr: string): ElevationPoint[] { @@ -22,7 +24,12 @@ function extractElevation(geojsonStr: string): ElevationPoint[] { totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!); } if (coords[i]![2] !== undefined) { - points.push({ distance: totalDist, elevation: coords[i]![2]! }); + points.push({ + distance: totalDist, + elevation: coords[i]![2]!, + lat: coords[i]![1]!, + lon: coords[i]![0]!, + }); } } return points; @@ -42,9 +49,19 @@ function haversine(lat1: number, lon1: number, lat2: number, lon2: number): numb return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } -export function ElevationChart({ yjs }: { yjs: YjsState }) { +const PADDING = { top: 10, right: 10, bottom: 25, left: 40 }; + +interface ElevationChartProps { + yjs: YjsState; + onHover?: (position: [number, number] | null) => void; +} + +export function ElevationChart({ yjs, onHover }: ElevationChartProps) { const [points, setPoints] = useState([]); + const [hoverIdx, setHoverIdx] = useState(null); const canvasRef = useRef(null); + const pointsRef = useRef([]); + pointsRef.current = points; useEffect(() => { const update = () => { @@ -60,76 +77,161 @@ export function ElevationChart({ yjs }: { yjs: YjsState }) { return () => yjs.routeData.unobserve(update); }, [yjs.routeData]); + const drawChart = useCallback( + (highlightIdx: number | null) => { + const canvas = canvasRef.current; + if (!canvas || points.length < 2) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const w = rect.width; + const h = rect.height; + const chartW = w - PADDING.left - PADDING.right; + const chartH = h - PADDING.top - PADDING.bottom; + + const maxDist = points[points.length - 1]!.distance; + const elevations = points.map((p) => p.elevation); + const minEle = Math.min(...elevations); + const maxEle = Math.max(...elevations); + const eleRange = maxEle - minEle || 1; + + const toX = (d: number) => PADDING.left + (d / maxDist) * chartW; + const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH; + + ctx.clearRect(0, 0, w, h); + + // Fill area + ctx.beginPath(); + ctx.moveTo(PADDING.left, PADDING.top + chartH); + for (const p of points) { + ctx.lineTo(toX(p.distance), toY(p.elevation)); + } + ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH); + ctx.closePath(); + ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; + ctx.fill(); + + // Line + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation)); + else ctx.lineTo(toX(p.distance), toY(p.elevation)); + } + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Axis labels + ctx.fillStyle = "#6b7280"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10); + ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH); + ctx.textAlign = "center"; + ctx.fillText("0 km", PADDING.left, h - 4); + ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4); + + // Hover crosshair + info + if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) { + const p = points[highlightIdx]!; + const hx = toX(p.distance); + const hy = toY(p.elevation); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(hx, PADDING.top); + ctx.lineTo(hx, PADDING.top + chartH); + ctx.strokeStyle = "rgba(239, 68, 68, 0.5)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Dot + ctx.beginPath(); + ctx.arc(hx, hy, 4, 0, Math.PI * 2); + ctx.fillStyle = "#ef4444"; + ctx.fill(); + ctx.strokeStyle = "white"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Info label + ctx.fillStyle = "#1f2937"; + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "left"; + const label = `${Math.round(p.elevation)}m ยท ${(p.distance / 1000).toFixed(1)}km`; + const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8; + ctx.textAlign = hx + 8 > w - 80 ? "right" : "left"; + ctx.fillText(label, labelX, PADDING.top + 10); + } + }, + [points], + ); + useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || points.length < 2) return; + drawChart(hoverIdx); + }, [points, hoverIdx, drawChart]); - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || points.length < 2) return; - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const chartW = rect.width - PADDING.left - PADDING.right; + const ratio = (mouseX - PADDING.left) / chartW; - const w = rect.width; - const h = rect.height; - const padding = { top: 10, right: 10, bottom: 25, left: 40 }; - const chartW = w - padding.left - padding.right; - const chartH = h - padding.top - padding.bottom; + if (ratio < 0 || ratio > 1) { + setHoverIdx(null); + onHover?.(null); + return; + } - const maxDist = points[points.length - 1]!.distance; - const elevations = points.map((p) => p.elevation); - const minEle = Math.min(...elevations); - const maxEle = Math.max(...elevations); - const eleRange = maxEle - minEle || 1; + const maxDist = points[points.length - 1]!.distance; + const targetDist = ratio * maxDist; - ctx.clearRect(0, 0, w, h); + // Find closest point + 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; + } + } - // Fill area - ctx.beginPath(); - ctx.moveTo(padding.left, padding.top + chartH); - for (const p of points) { - const x = padding.left + (p.distance / maxDist) * chartW; - const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH; - ctx.lineTo(x, y); - } - ctx.lineTo(padding.left + chartW, padding.top + chartH); - ctx.closePath(); - ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; - ctx.fill(); + setHoverIdx(closest); + const p = points[closest]!; + onHover?.([p.lat, p.lon]); + }, + [points, onHover], + ); - // Line - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const p = points[i]!; - const x = padding.left + (p.distance / maxDist) * chartW; - const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.strokeStyle = "#2563eb"; - ctx.lineWidth = 1.5; - ctx.stroke(); - - // Axis labels - ctx.fillStyle = "#6b7280"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "right"; - ctx.fillText(`${Math.round(maxEle)}m`, padding.left - 4, padding.top + 10); - ctx.fillText(`${Math.round(minEle)}m`, padding.left - 4, padding.top + chartH); - ctx.textAlign = "center"; - ctx.fillText("0 km", padding.left, h - 4); - ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, padding.left + chartW, h - 4); - }, [points]); + const handleMouseLeave = useCallback(() => { + setHoverIdx(null); + onHover?.(null); + }, [onHover]); if (points.length < 2) return null; return (

Elevation Profile

- +
); } diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 4c32011..eff4d01 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,17 +1,25 @@ import { useEffect, useState, useCallback } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet"; +import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet"; import L from "leaflet"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { baseLayers } from "@trails-cool/map"; import "leaflet/dist/leaflet.css"; -// Fix Leaflet default icon paths in bundled environment -import iconUrl from "leaflet/dist/images/marker-icon.png"; -import iconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png"; -import shadowUrl from "leaflet/dist/images/marker-shadow.png"; - -L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl }); +function waypointIcon(index: number): L.DivIcon { + return L.divIcon({ + className: "", + html: `
${index + 1}
`, + iconSize: [0, 0], + }); +} interface WaypointData { lat: number; @@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] interface PlannerMapProps { yjs: YjsState; onRouteRequest?: (waypoints: WaypointData[]) => void; + highlightPosition?: [number, number] | null; } function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) { @@ -94,7 +103,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { key={clientId} position={[cursor.lat, cursor.lng]} icon={L.divIcon({ - className: "cursor-marker", + className: "", html: `
${cursor.name}
`, iconSize: [0, 0], })} @@ -104,7 +113,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { ); } -export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { +export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) { const [waypoints, setWaypoints] = useState([]); const [routeGeoJson, setRouteGeoJson] = useState(null); @@ -201,6 +210,7 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { key={i} position={[wp.lat, wp.lon]} draggable + icon={waypointIcon(i)} eventHandlers={{ dragend: (e) => { const { lat, lng } = e.target.getLatLng(); @@ -215,6 +225,14 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { ))} {routeGeoJson && } + + {highlightPosition && ( + + )} ); } diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index ad4467a..f8ad6f1 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useState, useCallback } from "react"; import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; @@ -17,6 +17,11 @@ const ElevationChart = lazy(() => export function SessionView({ sessionId }: { sessionId: string }) { const yjs = useYjs(sessionId); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); + const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); + + const handleElevationHover = useCallback((pos: [number, number] | null) => { + setHighlightPosition(pos); + }, []); if (!yjs) { return ( @@ -58,11 +63,11 @@ export function SessionView({ sessionId }: { sessionId: string }) { } > - + - +