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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 07:30:00 +01:00
parent ce0cd84562
commit 6ee07f0570
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
3 changed files with 198 additions and 73 deletions

View file

@ -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<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
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<HTMLCanvasElement>) => {
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 (
<div className="border-t border-gray-200 px-2 py-2">
<p className="mb-1 px-2 text-xs font-medium text-gray-500">Elevation Profile</p>
<canvas ref={canvasRef} className="h-24 w-full" />
<canvas
ref={canvasRef}
className="h-24 w-full cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
</div>
);
}

View file

@ -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: `<div style="
width:24px;height:24px;border-radius:50%;
background:#2563eb;color:white;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-12px,-12px);
">${index + 1}</div>`,
iconSize: [0, 0],
});
}
interface WaypointData {
lat: number;
@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): 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: `<div style="background:${cursor.color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;transform:translate(10px,-50%)">${cursor.name}</div>`,
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<WaypointData[]>([]);
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(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 && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
{highlightPosition && (
<CircleMarker
center={highlightPosition}
radius={6}
pathOptions={{ color: "#ef4444", fillColor: "#ef4444", fillOpacity: 1, weight: 2 }}
/>
)}
</MapContainer>
);
}

View file

@ -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 }) {
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
</Suspense>
</div>
<Suspense fallback={null}>
<ElevationChart yjs={yjs} />
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
</Suspense>
</main>
<aside className="w-72 border-l border-gray-200 bg-white">