Add route interactions: click-to-split, drag-to-reshape, colored route rendering

- Enrich BRouter response with per-point 3D coordinates and segment boundary
  tracking (EnrichedRoute interface)
- ColoredRoute component: plain, elevation gradient (green→yellow→red), and
  surface color modes with invisible wide polyline for click targeting
- Click-to-split: click on route polyline inserts waypoint at nearest point,
  mapped to correct segment via boundary indices
- MidpointHandles: draggable CircleMarkers at route segment midpoints for
  reshaping, hidden below zoom 12, opaque on hover
- Color mode toggle (select) synced via Yjs routeData
- i18n keys for color mode labels (en + de)
- Unit tests for segment boundary tracking (13 tests)
- E2E tests for enriched route response and color mode toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-26 08:21:24 +01:00
parent fe3621aab3
commit b080a15fb1
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
22 changed files with 1259 additions and 170 deletions

View file

@ -0,0 +1,127 @@
import { useMemo } from "react";
import { Polyline } from "react-leaflet";
import type L from "leaflet";
export type ColorMode = "plain" | "elevation" | "surface";
interface ColoredRouteProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
colorMode: ColorMode;
surfaces?: string[];
}
const SURFACE_COLORS: Record<string, string> = {
asphalt: "#6b7280",
concrete: "#9ca3af",
paved: "#6b7280",
paving_stones: "#78716c",
cobblestone: "#a8a29e",
gravel: "#92400e",
compacted: "#b45309",
"fine_gravel": "#d97706",
ground: "#65a30d",
dirt: "#84cc16",
grass: "#22c55e",
sand: "#fbbf24",
mud: "#713f12",
wood: "#a16207",
unpaved: "#ca8a04",
path: "#16a34a",
track: "#ea580c",
};
const DEFAULT_SURFACE_COLOR = "#9ca3af";
export function elevationColor(t: number): string {
// green (0) → yellow (0.5) → red (1)
if (t <= 0.5) {
const r = Math.round(255 * (t * 2));
return `rgb(${r}, 200, 50)`;
}
const g = Math.round(200 * (1 - (t - 0.5) * 2));
return `rgb(255, ${g}, 50)`;
}
export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) {
const segments = useMemo(() => {
if (colorMode === "plain" || coordinates.length < 2) {
return null;
}
if (colorMode === "elevation") {
const elevations = coordinates.map((c) => c[2]);
const minEle = Math.min(...elevations);
const maxEle = Math.max(...elevations);
const range = maxEle - minEle || 1;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const t = (elevations[i]! - minEle) / range;
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: elevationColor(t),
});
}
return result;
}
// surface mode
if (!surfaces || surfaces.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const surface = surfaces[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR,
});
}
return result;
}, [coordinates, colorMode, surfaces]);
const plainPositions = useMemo(
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
[coordinates],
);
if (!segments) {
return (
<Polyline
positions={plainPositions}
pathOptions={{ color: "#2563eb", weight: 4, opacity: 0.8 }}
interactive={false}
/>
);
}
return (
<>
{segments.map((seg, i) => (
<Polyline
key={i}
positions={seg.positions}
pathOptions={{ color: seg.color, weight: 4, opacity: 0.9 }}
interactive={false}
/>
))}
</>
);
}
export function findSegmentForPoint(
pointIndex: number,
segmentBoundaries: number[],
): number {
for (let i = segmentBoundaries.length - 1; i >= 0; i--) {
if (pointIndex >= segmentBoundaries[i]!) return i;
}
return 0;
}
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR };

View file

@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react";
import type { YjsState } from "~/lib/use-yjs";
import { elevationColor, type ColorMode } from "~/components/ColoredRoute";
interface ElevationPoint {
distance: number;
@ -59,6 +60,7 @@ interface ElevationChartProps {
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
const [points, setPoints] = useState<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
pointsRef.current = points;
@ -71,6 +73,8 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
} else {
setPoints([]);
}
const mode = yjs.routeData.get("colorMode") as ColorMode | undefined;
setColorMode(mode ?? "plain");
};
yjs.routeData.observe(update);
update();
@ -107,27 +111,54 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
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();
if (colorMode === "elevation") {
// Elevation-colored fill and line segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const t = (p0.elevation - minEle) / eleRange;
const color = elevationColor(t);
// 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));
// Fill segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)");
ctx.fill();
// Line segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else {
// Plain fill and line
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();
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();
}
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 1.5;
ctx.stroke();
// Axis labels
ctx.fillStyle = "#6b7280";
@ -172,12 +203,12 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
ctx.fillText(label, labelX, PADDING.top + 10);
}
},
[points],
[points, colorMode],
);
useEffect(() => {
drawChart(hoverIdx);
}, [points, hoverIdx, drawChart]);
}, [points, hoverIdx, colorMode, drawChart]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {

View file

@ -0,0 +1,118 @@
import { useMemo, useState, useEffect } from "react";
import { Marker, useMap } from "react-leaflet";
import L from "leaflet";
interface MidpointHandlesProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
segmentBoundaries: number[];
onInsertWaypoint: (segmentIndex: number, lat: number, lon: number) => void;
}
function getSegmentMidpoint(
coordinates: [number, number, number][],
startIdx: number,
endIdx: number,
): [number, number] | null {
if (endIdx <= startIdx) return null;
const midCoordIdx = Math.floor((startIdx + endIdx) / 2);
const c = coordinates[midCoordIdx];
if (!c) return null;
return [c[1], c[0]]; // [lat, lon]
}
const midpointIcon = L.divIcon({
className: "",
html: `<div style="
width:12px;height:12px;border-radius:50%;
background:#93c5fd;border:2px solid #2563eb;
cursor:grab;transform:translate(-6px,-6px);
opacity:0.7;
" data-midpoint="true"></div>`,
iconSize: [0, 0],
});
const dragIcon = L.divIcon({
className: "",
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:#3b82f6;border:2px solid white;
box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-7px,-7px);
"></div>`,
iconSize: [0, 0],
});
export function MidpointHandles({
coordinates,
segmentBoundaries,
onInsertWaypoint,
}: MidpointHandlesProps) {
const map = useMap();
const [zoom, setZoom] = useState(map.getZoom());
const [dragging, setDragging] = useState<{ segmentIndex: number; lat: number; lon: number } | null>(null);
// Track zoom changes
useEffect(() => {
const onZoom = () => setZoom(map.getZoom());
map.on("zoomend", onZoom);
return () => { map.off("zoomend", onZoom); };
}, [map]);
const midpoints = useMemo(() => {
if (segmentBoundaries.length < 1) return [];
const result: { lat: number; lon: number; segmentIndex: number }[] = [];
for (let i = 0; i < segmentBoundaries.length; i++) {
const startIdx = segmentBoundaries[i]!;
const endIdx = i + 1 < segmentBoundaries.length
? segmentBoundaries[i + 1]!
: coordinates.length;
const mid = getSegmentMidpoint(coordinates, startIdx, endIdx);
if (mid) {
result.push({ lat: mid[0], lon: mid[1], segmentIndex: i });
}
}
return result;
}, [coordinates, segmentBoundaries]);
// Hide at low zoom levels
if (zoom < 12) return null;
return (
<>
{midpoints.map((mp) => (
<Marker
key={mp.segmentIndex}
position={[mp.lat, mp.lon]}
icon={midpointIcon}
eventHandlers={{
mousedown: () => {
setDragging({ segmentIndex: mp.segmentIndex, lat: mp.lat, lon: mp.lon });
const onMouseMove = (e: L.LeafletMouseEvent) => {
setDragging({ segmentIndex: mp.segmentIndex, lat: e.latlng.lat, lon: e.latlng.lng });
};
const onMouseUp = (e: L.LeafletMouseEvent) => {
map.off("mousemove", onMouseMove);
map.off("mouseup", onMouseUp);
map.dragging.enable();
setDragging(null);
onInsertWaypoint(mp.segmentIndex, e.latlng.lat, e.latlng.lng);
};
map.dragging.disable();
map.on("mousemove", onMouseMove);
map.on("mouseup", onMouseUp);
},
}}
/>
))}
{dragging && (
<Marker
position={[dragging.lat, dragging.lon]}
icon={dragIcon}
interactive={false}
/>
)}
</>
);
}

View file

@ -1,10 +1,12 @@
import { useEffect, useState, useCallback } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet";
import { useEffect, useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, 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 { NoGoAreaLayer } from "./NoGoAreaLayer";
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
import { RouteInteraction } from "./RouteInteraction";
import "leaflet/dist/leaflet.css";
function waypointIcon(index: number): L.DivIcon {
@ -42,9 +44,23 @@ interface PlannerMapProps {
highlightPosition?: [number, number] | null;
}
function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) {
function MapExposer() {
const map = useMap();
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as Record<string, unknown>).__leafletMap = map;
}
}, [map]);
return null;
}
function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject<boolean> }) {
useMapEvents({
click(e) {
if (suppressRef.current) {
suppressRef.current = false;
return;
}
onAdd(e.latlng.lat, e.latlng.lng);
},
});
@ -156,9 +172,13 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
const [surfaces, setSurfaces] = useState<string[]>([]);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const [noGoDrawing, setNoGoDrawing] = useState(false);
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
const suppressMapClickRef = useRef(false);
// Sync waypoints from Yjs
useEffect(() => {
@ -178,23 +198,49 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
};
}, [yjs.waypoints, onRouteRequest]);
// Sync route data from Yjs
// Sync route data from Yjs (enriched: coordinates + segment boundaries)
useEffect(() => {
const update = () => {
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
if (coordsJson) {
try {
const parsed = JSON.parse(geojson);
const coords = parsed.features?.[0]?.geometry?.coordinates;
if (coords) {
setRouteGeoJson(coords.map((c: number[]) => [c[1], c[0]] as L.LatLngExpression));
}
setRouteCoordinates(JSON.parse(coordsJson));
} catch {
// Invalid GeoJSON
setRouteCoordinates(null);
}
} else {
setRouteGeoJson(null);
// Fallback: parse from geojson for backwards compat
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
try {
const parsed = JSON.parse(geojson);
const coords = parsed.features?.[0]?.geometry?.coordinates;
if (coords) {
setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]));
}
} catch { setRouteCoordinates(null); }
} else {
setRouteCoordinates(null);
}
}
if (boundsJson) {
try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); }
} else {
setSegmentBoundaries([]);
}
const surfacesJson = yjs.routeData.get("surfaces") as string | undefined;
if (surfacesJson) {
try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); }
} else {
setSurfaces([]);
}
if (modeVal) setColorMode(modeVal);
};
yjs.routeData.observe(update);
@ -215,6 +261,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
[yjs.waypoints],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
const yMap = new Y.Map();
yMap.set("lat", lat);
yMap.set("lon", lon);
// Insert after the segment's start waypoint
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
},
[yjs.waypoints],
);
const handleRouteInsert = useCallback(
(pointIndex: number, lat: number, lon: number) => {
suppressMapClickRef.current = true;
const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries);
insertWaypointAtSegment(segIdx, lat, lon);
},
[segmentBoundaries, insertWaypointAtSegment],
);
const moveWaypoint = useCallback(
(index: number, lat: number, lng: number) => {
const yMap = yjs.waypoints.get(index);
@ -245,7 +311,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
))}
</LayersControl>
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
<MapExposer />
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
<CursorTracker awareness={yjs.awareness} />
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
@ -269,7 +336,21 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
/>
))}
{routeGeoJson && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
{routeCoordinates && routeCoordinates.length >= 2 && (
<>
<ColoredRoute
coordinates={routeCoordinates}
colorMode={colorMode}
surfaces={surfaces}
/>
<RouteInteraction
coordinates={routeCoordinates}
segmentBoundaries={segmentBoundaries}
onInsertWaypoint={handleRouteInsert}
disabled={noGoDrawing}
/>
</>
)}
{highlightPosition && (
<CircleMarker

View file

@ -0,0 +1,206 @@
import { useEffect, useRef, useCallback } from "react";
import { useMap } from "react-leaflet";
import L from "leaflet";
interface RouteInteractionProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
segmentBoundaries: number[];
onInsertWaypoint: (pointIndex: number, lat: number, lon: number) => void;
disabled?: boolean;
}
const SNAP_TOLERANCE_PX = 15;
const ghostIcon = L.divIcon({
className: "route-ghost-marker",
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:white;border:3px solid #2563eb;
box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-7px,-7px);
cursor:grab;
"></div>`,
iconSize: [0, 0],
});
/**
* Route interaction layer: shows a ghost marker on hover that can be
* clicked or dragged to insert a waypoint. Follows brouter-web's pattern
* of a single persistent draggable Marker with distance-based mouseout.
*/
export function RouteInteraction({
coordinates,
segmentBoundaries,
onInsertWaypoint,
disabled,
}: RouteInteractionProps) {
const map = useMap();
const markerRef = useRef<L.Marker | null>(null);
const draggingRef = useRef(false);
const snappedIdxRef = useRef(0);
const coordinatesRef = useRef(coordinates);
const segBoundsRef = useRef(segmentBoundaries);
const onInsertRef = useRef(onInsertWaypoint);
coordinatesRef.current = coordinates;
segBoundsRef.current = segmentBoundaries;
onInsertRef.current = onInsertWaypoint;
// Trailer lines (dashed lines from ghost to adjacent waypoints)
const trailer1Ref = useRef<L.Polyline | null>(null);
const trailer2Ref = useRef<L.Polyline | null>(null);
const findClosestOnRoute = useCallback((latlng: L.LatLng): { idx: number; lat: number; lon: number; distPx: number } | null => {
const coords = coordinatesRef.current;
if (coords.length < 2) return null;
let minDistPx = Infinity;
let bestIdx = 0;
let bestLat = 0;
let bestLon = 0;
// Find closest point on route in pixel space for accuracy
const clickPt = map.latLngToContainerPoint(latlng);
for (let i = 0; i < coords.length; i++) {
const c = coords[i]!;
const pt = map.latLngToContainerPoint([c[1], c[0]]);
const dx = pt.x - clickPt.x;
const dy = pt.y - clickPt.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDistPx) {
minDistPx = dist;
bestIdx = i;
bestLat = c[1];
bestLon = c[0];
}
}
return { idx: bestIdx, lat: bestLat, lon: bestLon, distPx: minDistPx };
}, [map]);
useEffect(() => {
if (disabled) return;
// Create persistent ghost marker (hidden until hover)
const marker = L.marker([0, 0], {
icon: ghostIcon,
draggable: true,
interactive: true,
zIndexOffset: 1000,
});
markerRef.current = marker;
// Trailer lines
const trailer1 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
const trailer2 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
trailer1Ref.current = trailer1;
trailer2Ref.current = trailer2;
let shown = false;
const showMarker = (lat: number, lon: number) => {
marker.setLatLng([lat, lon]);
if (!shown) {
marker.addTo(map);
shown = true;
}
};
const hideMarker = () => {
if (shown && !draggingRef.current) {
marker.remove();
shown = false;
}
};
// Listen for mousemove on the map (distance-based approach, not polyline events)
const onMouseMove = (e: L.LeafletMouseEvent) => {
if (draggingRef.current) return;
const snap = findClosestOnRoute(e.latlng);
if (!snap || snap.distPx > SNAP_TOLERANCE_PX) {
hideMarker();
return;
}
snappedIdxRef.current = snap.idx;
showMarker(snap.lat, snap.lon);
};
const onMouseOut = () => {
hideMarker();
};
// Drag events on the ghost marker (Leaflet's L.Draggable handles text selection)
marker.on("dragstart", () => {
draggingRef.current = true;
trailer1.setStyle({ opacity: 0.6 });
trailer2.setStyle({ opacity: 0.6 });
});
marker.on("drag", (e) => {
const latlng = (e.target as L.Marker).getLatLng();
// Update trailer lines to adjacent waypoints
const idx = snappedIdxRef.current;
const bounds = segBoundsRef.current;
const coords = coordinatesRef.current;
// Find which segment this point is in
let segIdx = 0;
for (let i = bounds.length - 1; i >= 0; i--) {
if (idx >= bounds[i]!) { segIdx = i; break; }
}
// Trailer to previous waypoint (segment start)
if (segIdx >= 0) {
const startCoordIdx = bounds[segIdx]!;
const c = coords[startCoordIdx];
if (c) trailer1.setLatLngs([latlng, [c[1], c[0]]]);
}
// Trailer to next waypoint (next segment start or end)
const nextStart = segIdx + 1 < bounds.length ? bounds[segIdx + 1]! : coords.length - 1;
const nc = coords[nextStart];
if (nc) trailer2.setLatLngs([latlng, [nc[1], nc[0]]]);
});
marker.on("dragend", (e) => {
const latlng = (e.target as L.Marker).getLatLng();
draggingRef.current = false;
trailer1.setStyle({ opacity: 0 });
trailer2.setStyle({ opacity: 0 });
marker.remove();
shown = false;
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
});
// Click on ghost marker inserts at snapped position
marker.on("click", () => {
if (draggingRef.current) return;
const latlng = marker.getLatLng();
marker.remove();
shown = false;
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
});
// Prevent double-click zoom when clicking ghost marker
marker.on("dblclick", (e) => {
L.DomEvent.stop(e as unknown as Event);
});
map.on("mousemove", onMouseMove);
map.on("mouseout", onMouseOut);
return () => {
map.off("mousemove", onMouseMove);
map.off("mouseout", onMouseOut);
if (shown) marker.remove();
trailer1.remove();
trailer2.remove();
markerRef.current = null;
};
}, [map, disabled, findClosestOnRoute]);
return null;
}

View file

@ -105,6 +105,37 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
return toasts;
}
function ColorModeToggle({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const [current, setCurrent] = useState<string>("plain");
useEffect(() => {
const update = () => {
setCurrent((yjs.routeData.get("colorMode") as string) ?? "plain");
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const setMode = (mode: string) => {
yjs.routeData.set("colorMode", mode);
};
return (
<select
value={current}
onChange={(e) => setMode(e.target.value)}
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700"
title={t("colorMode.label")}
>
<option value="plain">{t("colorMode.plain")}</option>
<option value="elevation">{t("colorMode.elevation")}</option>
<option value="surface">{t("colorMode.surface")}</option>
</select>
);
}
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
const { t } = useTranslation("planner");
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
@ -186,6 +217,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
/>
)}
<ExportButton yjs={yjs} />
<ColorModeToggle yjs={yjs} />
{computing && (
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
)}

View file

@ -1,61 +1,7 @@
import { describe, it, expect } from "vitest";
import { mergeGeoJsonSegments } from "./brouter";
// Test the mergeGeoJsonSegments logic extracted from brouter.ts
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
function mergeGeoJsonSegments(segments: GeoJsonCollection[]): GeoJsonCollection {
const allCoords: number[][] = [];
let totalLength = 0;
let totalAscend = 0;
let totalTime = 0;
for (let i = 0; i < segments.length; i++) {
const feature = segments[i]!.features?.[0];
if (!feature) continue;
const coords = feature.geometry.coordinates;
const startIdx = i === 0 ? 0 : 1;
for (let j = startIdx; j < coords.length; j++) {
allCoords.push(coords[j]!);
}
const props = feature.properties;
totalLength += parseInt(String(props["track-length"] ?? "0"));
totalAscend += parseInt(String(props["filtered ascend"] ?? "0"));
totalTime += parseInt(String(props["total-time"] ?? "0"));
}
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
"track-length": String(totalLength),
"filtered ascend": String(totalAscend),
"total-time": String(totalTime),
creator: "trails.cool (BRouter segments)",
},
geometry: {
type: "LineString",
coordinates: allCoords,
},
},
],
};
}
function makeSegment(coords: number[][], length: number, ascend: number): GeoJsonCollection {
function makeSegment(coords: number[][], length: number, ascend: number) {
return {
type: "FeatureCollection",
features: [{
@ -72,70 +18,129 @@ function makeSegment(coords: number[][], length: number, ascend: number): GeoJso
describe("mergeGeoJsonSegments", () => {
it("merges two segments", () => {
const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10);
const seg2 = makeSegment([[13.2, 52.2], [13.3, 52.3], [13.4, 52.4]], 1500, 20);
const seg1 = makeSegment([[13.0, 52.0, 30], [13.1, 52.1, 40], [13.2, 52.2, 50]], 1000, 10);
const seg2 = makeSegment([[13.2, 52.2, 50], [13.3, 52.3, 60], [13.4, 52.4, 45]], 1500, 20);
const result = mergeGeoJsonSegments([seg1, seg2]);
const coords = result.features[0]!.geometry.coordinates;
const result = mergeGeoJsonSegments([seg1, seg2] as never[]);
// Should have 5 points (first segment 3 + second segment 2, skipping duplicate)
expect(coords).toHaveLength(5);
expect(coords[0]).toEqual([13.0, 52.0]);
expect(coords[2]).toEqual([13.2, 52.2]); // shared point
expect(coords[4]).toEqual([13.4, 52.4]);
expect(result.coordinates).toHaveLength(5);
expect(result.coordinates[0]).toEqual([13.0, 52.0, 30]);
expect(result.coordinates[2]).toEqual([13.2, 52.2, 50]);
expect(result.coordinates[4]).toEqual([13.4, 52.4, 45]);
});
it("skips duplicate point at segment boundaries", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0);
const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0);
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0);
const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 100, 0);
const seg3 = makeSegment([[3, 3, 0], [4, 4, 0]], 100, 0);
const result = mergeGeoJsonSegments([seg1, seg2, seg3]);
const coords = result.features[0]!.geometry.coordinates;
expect(coords).toHaveLength(4);
expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]);
const result = mergeGeoJsonSegments([seg1, seg2, seg3] as never[]);
expect(result.coordinates).toHaveLength(4);
});
it("accumulates stats across segments", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50);
const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30);
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 1000, 50);
const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 2000, 30);
const result = mergeGeoJsonSegments([seg1, seg2]);
const props = result.features[0]!.properties;
expect(props["track-length"]).toBe("3000");
expect(props["filtered ascend"]).toBe("80");
expect(props["total-time"]).toBe("200");
const result = mergeGeoJsonSegments([seg1, seg2] as never[]);
expect(result.totalLength).toBe(3000);
expect(result.totalAscend).toBe(80);
});
it("handles single segment", () => {
const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10);
const result = mergeGeoJsonSegments([seg]);
const seg = makeSegment([[1, 1, 10], [2, 2, 20], [3, 3, 30]], 500, 10);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.features[0]!.geometry.coordinates).toHaveLength(3);
expect(result.features[0]!.properties["track-length"]).toBe("500");
expect(result.coordinates).toHaveLength(3);
expect(result.totalLength).toBe(500);
});
it("handles empty segment gracefully", () => {
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] };
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0);
const empty = { type: "FeatureCollection", features: [] };
const result = mergeGeoJsonSegments([seg1, empty]);
expect(result.features[0]!.geometry.coordinates).toHaveLength(2);
const result = mergeGeoJsonSegments([seg1, empty] as never[]);
expect(result.coordinates).toHaveLength(2);
});
it("preserves elevation data in coordinates", () => {
const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50);
const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50);
it("preserves 3D coordinates (elevation)", () => {
const seg = makeSegment([[13.4, 52.5, 34], [13.38, 52.51, 40]], 500, 6);
const result = mergeGeoJsonSegments([seg] as never[]);
const result = mergeGeoJsonSegments([seg1, seg2]);
const coords = result.features[0]!.geometry.coordinates;
expect(result.coordinates[0]![2]).toBe(34);
expect(result.coordinates[1]![2]).toBe(40);
});
expect(coords).toHaveLength(3);
expect(coords[0]).toEqual([13.0, 52.0, 100]);
expect(coords[1]).toEqual([13.1, 52.1, 150]);
expect(coords[2]).toEqual([13.2, 52.2, 200]);
it("defaults missing elevation to 0", () => {
const seg = makeSegment([[13.4, 52.5], [13.38, 52.51]], 500, 0);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.coordinates[0]![2]).toBe(0);
expect(result.coordinates[1]![2]).toBe(0);
});
// Segment boundary tests
it("tracks segment boundaries with 1 segment", () => {
const seg = makeSegment([[0, 0, 0], [1, 1, 10], [2, 2, 20]], 100, 20);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.segmentBoundaries).toEqual([0]);
});
it("tracks segment boundaries with 2 segments", () => {
const seg1 = makeSegment([[0, 0, 0], [1, 1, 10], [2, 2, 20]], 100, 20);
const seg2 = makeSegment([[2, 2, 20], [3, 3, 30], [4, 4, 40]], 100, 20);
const result = mergeGeoJsonSegments([seg1, seg2] as never[]);
expect(result.segmentBoundaries).toEqual([0, 3]);
expect(result.coordinates).toHaveLength(5);
});
it("tracks segment boundaries with 4 segments (5 waypoints)", () => {
const segments = [
makeSegment([[0, 0, 0], [1, 1, 10]], 100, 10),
makeSegment([[1, 1, 10], [2, 2, 20]], 100, 10),
makeSegment([[2, 2, 20], [3, 3, 30]], 100, 10),
makeSegment([[3, 3, 30], [4, 4, 40]], 100, 10),
];
const result = mergeGeoJsonSegments(segments as never[]);
expect(result.segmentBoundaries).toEqual([0, 2, 3, 4]);
expect(result.coordinates).toHaveLength(5);
expect(result.totalLength).toBe(400);
});
it("segment boundaries allow mapping point index to waypoint segment", () => {
const seg1 = makeSegment([[0, 0, 0], [0.5, 0.5, 5], [1, 1, 10]], 100, 10);
const seg2 = makeSegment([[1, 1, 10], [1.5, 1.5, 15], [2, 2, 20]], 100, 10);
const result = mergeGeoJsonSegments([seg1, seg2] as never[]);
// boundaries: [0, 3] — segment 0 is coords 0-2, segment 1 is coords 3-4
function findSegment(pointIndex: number): number {
const bounds = result.segmentBoundaries;
for (let i = bounds.length - 1; i >= 0; i--) {
if (pointIndex >= bounds[i]!) return i;
}
return 0;
}
expect(findSegment(0)).toBe(0); // first point → segment 0
expect(findSegment(1)).toBe(0); // mid of segment 0
expect(findSegment(2)).toBe(0); // last point of segment 0
expect(findSegment(3)).toBe(1); // first point of segment 1
expect(findSegment(4)).toBe(1); // last point
});
it("geojson field is backwards compatible", () => {
const seg = makeSegment([[13.0, 52.0, 30], [13.1, 52.1, 40]], 500, 10);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.geojson.type).toBe("FeatureCollection");
expect(result.geojson.features).toHaveLength(1);
expect(result.geojson.features[0]!.geometry.type).toBe("LineString");
expect(result.geojson.features[0]!.properties["track-length"]).toBe("500");
});
});
@ -154,14 +159,5 @@ describe("waypoint to BRouter segments", () => {
}
expect(pairs).toHaveLength(3);
expect(pairs[0]).toEqual([waypoints[0], waypoints[1]]);
expect(pairs[1]).toEqual([waypoints[1], waypoints[2]]);
expect(pairs[2]).toEqual([waypoints[2], waypoints[3]]);
});
it("formats lon,lat correctly for BRouter", () => {
const wp = { lat: 52.5277, lon: 13.4033 };
const lonlat = `${wp.lon},${wp.lat}`;
expect(lonlat).toBe("13.4033,52.5277");
});
});

View file

@ -12,12 +12,22 @@ export interface RouteRequest {
noGoAreas?: NoGoArea[];
}
export interface EnrichedRoute {
coordinates: [number, number, number][]; // [lon, lat, ele]
segmentBoundaries: number[]; // coordinate index where each waypoint segment starts
surfaces: string[]; // surface type per coordinate point (e.g. "asphalt", "gravel")
totalLength: number;
totalAscend: number;
totalTime: number;
geojson: GeoJsonCollection; // original merged GeoJSON for backwards compat
}
/**
* Compute a route segment-by-segment between consecutive waypoints.
* This matches bikerouter.de's behavior and guarantees the route
* passes through every waypoint.
*/
export async function computeRoute(request: RouteRequest): Promise<unknown> {
export async function computeRoute(request: RouteRequest): Promise<EnrichedRoute> {
if (request.waypoints.length < 2) {
throw new Error("At least 2 waypoints are required");
}
@ -40,6 +50,7 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
profile,
alternativeidx: "0",
format,
tiledesc: "true",
});
if (nogoParam) params.set("nogos", nogoParam);
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
@ -70,8 +81,10 @@ interface GeoJsonCollection {
features: GeoJsonFeature[];
}
function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonCollection {
const allCoords: number[][] = [];
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): EnrichedRoute {
const allCoords: [number, number, number][] = [];
const allSurfaces: string[] = [];
const segmentBoundaries: number[] = [];
let totalLength = 0;
let totalAscend = 0;
let totalTime = 0;
@ -81,11 +94,19 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
const feature = segment.features?.[0];
if (!feature) continue;
// Record where this segment starts in the merged coordinate array
segmentBoundaries.push(allCoords.length);
const coords = feature.geometry.coordinates;
// Extract surface data from BRouter messages (tiledesc=true)
const surfaceMap = extractSurfacesFromMessages(feature.properties);
// Skip first point of subsequent segments to avoid duplicates
const startIdx = i === 0 ? 0 : 1;
for (let j = startIdx; j < coords.length; j++) {
allCoords.push(coords[j]!);
const c = coords[j]!;
allCoords.push([c[0]!, c[1]!, c[2] ?? 0]);
allSurfaces.push(surfaceMap.get(j) ?? surfaceMap.get(j - 1) ?? "unknown");
}
// Accumulate stats
@ -95,7 +116,7 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
totalTime += parseInt(String(props["total-time"] ?? "0"));
}
return {
const geojson: GeoJsonCollection = {
type: "FeatureCollection",
features: [
{
@ -113,6 +134,39 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
},
],
};
return {
coordinates: allCoords,
segmentBoundaries,
surfaces: allSurfaces,
totalLength,
totalAscend,
totalTime,
geojson,
};
}
/**
* Extract surface type per message row from BRouter's tiledesc messages.
* Messages is an array of arrays: first row is headers, subsequent rows are data.
* We look for the "WayTags" column which contains OSM tags like "surface=asphalt".
*/
function extractSurfacesFromMessages(properties: Record<string, unknown>): Map<number, string> {
const result = new Map<number, string>();
const messages = properties.messages as string[][] | undefined;
if (!messages || messages.length < 2) return result;
const headers = messages[0]!;
const wayTagsIdx = headers.indexOf("WayTags");
if (wayTagsIdx === -1) return result;
for (let i = 1; i < messages.length; i++) {
const row = messages[i]!;
const tags = row[wayTagsIdx] ?? "";
const surfaceMatch = tags.match(/surface=(\S+)/);
result.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown");
}
return result;
}
/**

View file

@ -74,20 +74,22 @@ export function useRouting(yjs: YjsState | null) {
}
if (!response.ok) return;
const geojson = await response.json();
const enriched = await response.json();
const props = geojson.features?.[0]?.properties;
if (props) {
setRouteStats({
distance: props["track-length"] ? parseInt(props["track-length"]) : undefined,
elevationGain: props["filtered ascend"] ? parseInt(props["filtered ascend"]) : undefined,
elevationLoss: props["plain-ascend"]
? Math.abs(parseInt(props["plain-ascend"]))
: undefined,
});
}
setRouteStats({
distance: enriched.totalLength || undefined,
elevationGain: enriched.totalAscend || undefined,
});
yjs.routeData.set("geojson", JSON.stringify(geojson));
// Store enriched route data in Yjs for all participants
yjs.doc.transact(() => {
yjs.routeData.set("geojson", JSON.stringify(enriched.geojson));
yjs.routeData.set("coordinates", JSON.stringify(enriched.coordinates));
yjs.routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries));
if (enriched.surfaces?.length) {
yjs.routeData.set("surfaces", JSON.stringify(enriched.surfaces));
}
});
} catch {
// Route computation failed
} finally {