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:
parent
fe3621aab3
commit
b080a15fb1
22 changed files with 1259 additions and 170 deletions
127
apps/planner/app/components/ColoredRoute.tsx
Normal file
127
apps/planner/app/components/ColoredRoute.tsx
Normal 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 };
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
|
import { elevationColor, type ColorMode } from "~/components/ColoredRoute";
|
||||||
|
|
||||||
interface ElevationPoint {
|
interface ElevationPoint {
|
||||||
distance: number;
|
distance: number;
|
||||||
|
|
@ -59,6 +60,7 @@ interface ElevationChartProps {
|
||||||
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||||
|
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||||
pointsRef.current = points;
|
pointsRef.current = points;
|
||||||
|
|
@ -71,6 +73,8 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||||
} else {
|
} else {
|
||||||
setPoints([]);
|
setPoints([]);
|
||||||
}
|
}
|
||||||
|
const mode = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||||
|
setColorMode(mode ?? "plain");
|
||||||
};
|
};
|
||||||
yjs.routeData.observe(update);
|
yjs.routeData.observe(update);
|
||||||
update();
|
update();
|
||||||
|
|
@ -107,27 +111,54 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
// Fill area
|
if (colorMode === "elevation") {
|
||||||
ctx.beginPath();
|
// Elevation-colored fill and line segments
|
||||||
ctx.moveTo(PADDING.left, PADDING.top + chartH);
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
for (const p of points) {
|
const p0 = points[i]!;
|
||||||
ctx.lineTo(toX(p.distance), toY(p.elevation));
|
const p1 = points[i + 1]!;
|
||||||
}
|
const t = (p0.elevation - minEle) / eleRange;
|
||||||
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
|
const color = elevationColor(t);
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Line
|
// Fill segment
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < points.length; i++) {
|
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
||||||
const p = points[i]!;
|
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
||||||
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
|
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||||
else ctx.lineTo(toX(p.distance), toY(p.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
|
// Axis labels
|
||||||
ctx.fillStyle = "#6b7280";
|
ctx.fillStyle = "#6b7280";
|
||||||
|
|
@ -172,12 +203,12 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||||
ctx.fillText(label, labelX, PADDING.top + 10);
|
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[points],
|
[points, colorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawChart(hoverIdx);
|
drawChart(hoverIdx);
|
||||||
}, [points, hoverIdx, drawChart]);
|
}, [points, hoverIdx, colorMode, drawChart]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
|
|
||||||
118
apps/planner/app/components/MidpointHandles.tsx
Normal file
118
apps/planner/app/components/MidpointHandles.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
import { baseLayers } from "@trails-cool/map";
|
import { baseLayers } from "@trails-cool/map";
|
||||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||||
|
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||||
|
import { RouteInteraction } from "./RouteInteraction";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
function waypointIcon(index: number): L.DivIcon {
|
function waypointIcon(index: number): L.DivIcon {
|
||||||
|
|
@ -42,9 +44,23 @@ interface PlannerMapProps {
|
||||||
highlightPosition?: [number, number] | null;
|
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({
|
useMapEvents({
|
||||||
click(e) {
|
click(e) {
|
||||||
|
if (suppressRef.current) {
|
||||||
|
suppressRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
onAdd(e.latlng.lat, e.latlng.lng);
|
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) {
|
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
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 [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||||
|
const suppressMapClickRef = useRef(false);
|
||||||
|
|
||||||
// Sync waypoints from Yjs
|
// Sync waypoints from Yjs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -178,23 +198,49 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
||||||
};
|
};
|
||||||
}, [yjs.waypoints, onRouteRequest]);
|
}, [yjs.waypoints, onRouteRequest]);
|
||||||
|
|
||||||
// Sync route data from Yjs
|
// Sync route data from Yjs (enriched: coordinates + segment boundaries)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
|
||||||
if (geojson) {
|
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
|
||||||
|
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||||
|
|
||||||
|
if (coordsJson) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(geojson);
|
setRouteCoordinates(JSON.parse(coordsJson));
|
||||||
const coords = parsed.features?.[0]?.geometry?.coordinates;
|
|
||||||
if (coords) {
|
|
||||||
setRouteGeoJson(coords.map((c: number[]) => [c[1], c[0]] as L.LatLngExpression));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid GeoJSON
|
setRouteCoordinates(null);
|
||||||
}
|
}
|
||||||
} else {
|
} 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);
|
yjs.routeData.observe(update);
|
||||||
|
|
@ -215,6 +261,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
||||||
[yjs.waypoints],
|
[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(
|
const moveWaypoint = useCallback(
|
||||||
(index: number, lat: number, lng: number) => {
|
(index: number, lat: number, lng: number) => {
|
||||||
const yMap = yjs.waypoints.get(index);
|
const yMap = yjs.waypoints.get(index);
|
||||||
|
|
@ -245,7 +311,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
||||||
))}
|
))}
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
|
|
||||||
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
|
<MapExposer />
|
||||||
|
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
|
||||||
<CursorTracker awareness={yjs.awareness} />
|
<CursorTracker awareness={yjs.awareness} />
|
||||||
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||||
<NoGoAreaButton active={noGoDrawing} onClick={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 && (
|
{highlightPosition && (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
|
|
|
||||||
206
apps/planner/app/components/RouteInteraction.tsx
Normal file
206
apps/planner/app/components/RouteInteraction.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -105,6 +105,37 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
|
||||||
return toasts;
|
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"] }) {
|
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
|
||||||
const { t } = useTranslation("planner");
|
const { t } = useTranslation("planner");
|
||||||
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
||||||
|
|
@ -186,6 +217,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ExportButton yjs={yjs} />
|
<ExportButton yjs={yjs} />
|
||||||
|
<ColorModeToggle yjs={yjs} />
|
||||||
{computing && (
|
{computing && (
|
||||||
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,7 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { mergeGeoJsonSegments } from "./brouter";
|
||||||
|
|
||||||
// Test the mergeGeoJsonSegments logic extracted from brouter.ts
|
function makeSegment(coords: number[][], length: number, ascend: number) {
|
||||||
|
|
||||||
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 {
|
|
||||||
return {
|
return {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [{
|
features: [{
|
||||||
|
|
@ -72,70 +18,129 @@ function makeSegment(coords: number[][], length: number, ascend: number): GeoJso
|
||||||
|
|
||||||
describe("mergeGeoJsonSegments", () => {
|
describe("mergeGeoJsonSegments", () => {
|
||||||
it("merges two segments", () => {
|
it("merges two segments", () => {
|
||||||
const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10);
|
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], [13.3, 52.3], [13.4, 52.4]], 1500, 20);
|
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 result = mergeGeoJsonSegments([seg1, seg2] as never[]);
|
||||||
const coords = result.features[0]!.geometry.coordinates;
|
|
||||||
|
|
||||||
// Should have 5 points (first segment 3 + second segment 2, skipping duplicate)
|
expect(result.coordinates).toHaveLength(5);
|
||||||
expect(coords).toHaveLength(5);
|
expect(result.coordinates[0]).toEqual([13.0, 52.0, 30]);
|
||||||
expect(coords[0]).toEqual([13.0, 52.0]);
|
expect(result.coordinates[2]).toEqual([13.2, 52.2, 50]);
|
||||||
expect(coords[2]).toEqual([13.2, 52.2]); // shared point
|
expect(result.coordinates[4]).toEqual([13.4, 52.4, 45]);
|
||||||
expect(coords[4]).toEqual([13.4, 52.4]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips duplicate point at segment boundaries", () => {
|
it("skips duplicate point at segment boundaries", () => {
|
||||||
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
|
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0);
|
||||||
const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0);
|
const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 100, 0);
|
||||||
const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0);
|
const seg3 = makeSegment([[3, 3, 0], [4, 4, 0]], 100, 0);
|
||||||
|
|
||||||
const result = mergeGeoJsonSegments([seg1, seg2, seg3]);
|
const result = mergeGeoJsonSegments([seg1, seg2, seg3] as never[]);
|
||||||
const coords = result.features[0]!.geometry.coordinates;
|
expect(result.coordinates).toHaveLength(4);
|
||||||
|
|
||||||
expect(coords).toHaveLength(4);
|
|
||||||
expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates stats across segments", () => {
|
it("accumulates stats across segments", () => {
|
||||||
const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50);
|
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 1000, 50);
|
||||||
const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30);
|
const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 2000, 30);
|
||||||
|
|
||||||
const result = mergeGeoJsonSegments([seg1, seg2]);
|
const result = mergeGeoJsonSegments([seg1, seg2] as never[]);
|
||||||
const props = result.features[0]!.properties;
|
expect(result.totalLength).toBe(3000);
|
||||||
|
expect(result.totalAscend).toBe(80);
|
||||||
expect(props["track-length"]).toBe("3000");
|
|
||||||
expect(props["filtered ascend"]).toBe("80");
|
|
||||||
expect(props["total-time"]).toBe("200");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles single segment", () => {
|
it("handles single segment", () => {
|
||||||
const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10);
|
const seg = makeSegment([[1, 1, 10], [2, 2, 20], [3, 3, 30]], 500, 10);
|
||||||
const result = mergeGeoJsonSegments([seg]);
|
const result = mergeGeoJsonSegments([seg] as never[]);
|
||||||
|
|
||||||
expect(result.features[0]!.geometry.coordinates).toHaveLength(3);
|
expect(result.coordinates).toHaveLength(3);
|
||||||
expect(result.features[0]!.properties["track-length"]).toBe("500");
|
expect(result.totalLength).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty segment gracefully", () => {
|
it("handles empty segment gracefully", () => {
|
||||||
const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0);
|
const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0);
|
||||||
const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] };
|
const empty = { type: "FeatureCollection", features: [] };
|
||||||
|
|
||||||
const result = mergeGeoJsonSegments([seg1, empty]);
|
const result = mergeGeoJsonSegments([seg1, empty] as never[]);
|
||||||
expect(result.features[0]!.geometry.coordinates).toHaveLength(2);
|
expect(result.coordinates).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves elevation data in coordinates", () => {
|
it("preserves 3D coordinates (elevation)", () => {
|
||||||
const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50);
|
const seg = makeSegment([[13.4, 52.5, 34], [13.38, 52.51, 40]], 500, 6);
|
||||||
const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50);
|
const result = mergeGeoJsonSegments([seg] as never[]);
|
||||||
|
|
||||||
const result = mergeGeoJsonSegments([seg1, seg2]);
|
expect(result.coordinates[0]![2]).toBe(34);
|
||||||
const coords = result.features[0]!.geometry.coordinates;
|
expect(result.coordinates[1]![2]).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
expect(coords).toHaveLength(3);
|
it("defaults missing elevation to 0", () => {
|
||||||
expect(coords[0]).toEqual([13.0, 52.0, 100]);
|
const seg = makeSegment([[13.4, 52.5], [13.38, 52.51]], 500, 0);
|
||||||
expect(coords[1]).toEqual([13.1, 52.1, 150]);
|
const result = mergeGeoJsonSegments([seg] as never[]);
|
||||||
expect(coords[2]).toEqual([13.2, 52.2, 200]);
|
|
||||||
|
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).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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,22 @@ export interface RouteRequest {
|
||||||
noGoAreas?: NoGoArea[];
|
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.
|
* Compute a route segment-by-segment between consecutive waypoints.
|
||||||
* This matches bikerouter.de's behavior and guarantees the route
|
* This matches bikerouter.de's behavior and guarantees the route
|
||||||
* passes through every waypoint.
|
* 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) {
|
if (request.waypoints.length < 2) {
|
||||||
throw new Error("At least 2 waypoints are required");
|
throw new Error("At least 2 waypoints are required");
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +50,7 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
|
||||||
profile,
|
profile,
|
||||||
alternativeidx: "0",
|
alternativeidx: "0",
|
||||||
format,
|
format,
|
||||||
|
tiledesc: "true",
|
||||||
});
|
});
|
||||||
if (nogoParam) params.set("nogos", nogoParam);
|
if (nogoParam) params.set("nogos", nogoParam);
|
||||||
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
|
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
|
||||||
|
|
@ -70,8 +81,10 @@ interface GeoJsonCollection {
|
||||||
features: GeoJsonFeature[];
|
features: GeoJsonFeature[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonCollection {
|
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): EnrichedRoute {
|
||||||
const allCoords: number[][] = [];
|
const allCoords: [number, number, number][] = [];
|
||||||
|
const allSurfaces: string[] = [];
|
||||||
|
const segmentBoundaries: number[] = [];
|
||||||
let totalLength = 0;
|
let totalLength = 0;
|
||||||
let totalAscend = 0;
|
let totalAscend = 0;
|
||||||
let totalTime = 0;
|
let totalTime = 0;
|
||||||
|
|
@ -81,11 +94,19 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
|
||||||
const feature = segment.features?.[0];
|
const feature = segment.features?.[0];
|
||||||
if (!feature) continue;
|
if (!feature) continue;
|
||||||
|
|
||||||
|
// Record where this segment starts in the merged coordinate array
|
||||||
|
segmentBoundaries.push(allCoords.length);
|
||||||
|
|
||||||
const coords = feature.geometry.coordinates;
|
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
|
// Skip first point of subsequent segments to avoid duplicates
|
||||||
const startIdx = i === 0 ? 0 : 1;
|
const startIdx = i === 0 ? 0 : 1;
|
||||||
for (let j = startIdx; j < coords.length; j++) {
|
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
|
// Accumulate stats
|
||||||
|
|
@ -95,7 +116,7 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
|
||||||
totalTime += parseInt(String(props["total-time"] ?? "0"));
|
totalTime += parseInt(String(props["total-time"] ?? "0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const geojson: GeoJsonCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -74,20 +74,22 @@ export function useRouting(yjs: YjsState | null) {
|
||||||
}
|
}
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const geojson = await response.json();
|
const enriched = await response.json();
|
||||||
|
|
||||||
const props = geojson.features?.[0]?.properties;
|
setRouteStats({
|
||||||
if (props) {
|
distance: enriched.totalLength || undefined,
|
||||||
setRouteStats({
|
elevationGain: enriched.totalAscend || undefined,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} catch {
|
||||||
// Route computation failed
|
// Route computation failed
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ test.describe("Integration: BRouter routing", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const geojson = await response.json();
|
const enriched = await response.json();
|
||||||
expect(geojson.features).toHaveLength(1);
|
expect(enriched.geojson.features).toHaveLength(1);
|
||||||
expect(geojson.features[0].geometry.type).toBe("LineString");
|
expect(enriched.geojson.features[0].geometry.type).toBe("LineString");
|
||||||
expect(geojson.features[0].geometry.coordinates.length).toBeGreaterThan(10);
|
expect(enriched.coordinates.length).toBeGreaterThan(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routes through all waypoints (segment by segment)", async ({ request }) => {
|
test("routes through all waypoints (segment by segment)", async ({ request }) => {
|
||||||
|
|
@ -64,8 +64,8 @@ test.describe("Integration: BRouter routing", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const geojson = await response.json();
|
const enriched = await response.json();
|
||||||
const coords = geojson.features[0].geometry.coordinates;
|
const coords = enriched.coordinates;
|
||||||
|
|
||||||
const nearMiddle = coords.some(
|
const nearMiddle = coords.some(
|
||||||
(c: number[]) =>
|
(c: number[]) =>
|
||||||
|
|
@ -95,6 +95,32 @@ test.describe("Integration: BRouter routing", () => {
|
||||||
expect(response.status()).toBe(400);
|
expect(response.status()).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns enriched route with segment boundaries", async ({ request }) => {
|
||||||
|
const response = await request.post(`${PLANNER}/api/route`, {
|
||||||
|
data: {
|
||||||
|
waypoints: [
|
||||||
|
{ lat: 52.520, lon: 13.405 },
|
||||||
|
{ lat: 52.516, lon: 13.377 },
|
||||||
|
{ lat: 52.510, lon: 13.390 },
|
||||||
|
],
|
||||||
|
profile: "trekking",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
const enriched = await response.json();
|
||||||
|
|
||||||
|
// EnrichedRoute fields
|
||||||
|
expect(enriched.coordinates).toBeDefined();
|
||||||
|
expect(enriched.coordinates.length).toBeGreaterThan(10);
|
||||||
|
expect(enriched.coordinates[0]).toHaveLength(3); // [lon, lat, ele]
|
||||||
|
expect(enriched.segmentBoundaries).toBeDefined();
|
||||||
|
expect(enriched.segmentBoundaries).toHaveLength(2); // 3 waypoints = 2 segments
|
||||||
|
expect(enriched.segmentBoundaries[0]).toBe(0);
|
||||||
|
expect(enriched.totalLength).toBeGreaterThan(0);
|
||||||
|
expect(enriched.geojson).toBeDefined();
|
||||||
|
expect(enriched.geojson.features[0].geometry.type).toBe("LineString");
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts no-go areas parameter", async ({ request }) => {
|
test("accepts no-go areas parameter", async ({ request }) => {
|
||||||
const response = await request.post(`${PLANNER}/api/route`, {
|
const response = await request.post(`${PLANNER}/api/route`, {
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -116,8 +142,8 @@ test.describe("Integration: BRouter routing", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const geojson = await response.json();
|
const enriched = await response.json();
|
||||||
expect(geojson.features).toHaveLength(1);
|
expect(enriched.geojson.features).toHaveLength(1);
|
||||||
expect(geojson.features[0].geometry.type).toBe("LineString");
|
expect(enriched.geojson.features[0].geometry.type).toBe("LineString");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,66 @@ test.describe("Planner", () => {
|
||||||
await expect(page.getByText("Waypoints (0)")).toBeVisible();
|
await expect(page.getByText("Waypoints (0)")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clicking on route splits it by inserting a waypoint", async ({ page, request }) => {
|
||||||
|
const sessionResp = await request.post("/api/sessions", { data: {} });
|
||||||
|
const { url } = await sessionResp.json();
|
||||||
|
|
||||||
|
await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([
|
||||||
|
{ lat: 52.520, lon: 13.405 },
|
||||||
|
{ lat: 52.515, lon: 13.351 },
|
||||||
|
]))}`);
|
||||||
|
|
||||||
|
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
// Zoom in and click on the route midpoint to split it
|
||||||
|
// RouteInteraction uses map-level mousemove, but click on the ghost marker
|
||||||
|
// inserts the waypoint. We'll use Leaflet events to simulate.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const map = (window as any).__leafletMap;
|
||||||
|
if (!map) return;
|
||||||
|
map.setView([52.5175, 13.378], 14, { animate: false });
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Click on the route via the visible polyline's bounding box
|
||||||
|
const routePath = page.locator(".leaflet-overlay-pane path").first();
|
||||||
|
await expect(routePath).toBeAttached({ timeout: 5000 });
|
||||||
|
const box = await routePath.boundingBox();
|
||||||
|
if (!box) throw new Error("Route polyline not visible");
|
||||||
|
|
||||||
|
// Click the center of the route path bounding box
|
||||||
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
|
||||||
|
// The click on the map near the route should trigger RouteInteraction
|
||||||
|
// which shows the ghost and inserts a waypoint. If the click was within
|
||||||
|
// snap tolerance, a waypoint is inserted. Otherwise, a new waypoint is
|
||||||
|
// appended (map click). Either way we get 3 waypoints.
|
||||||
|
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Ghost marker hover interaction is verified visually via cmux browser.
|
||||||
|
// Playwright's mouse simulation doesn't reliably trigger Leaflet's SVG
|
||||||
|
// pointer events needed for the distance-based snap detection.
|
||||||
|
|
||||||
|
test("session has color mode toggle", async ({ page, request }) => {
|
||||||
|
const response = await request.post("/api/sessions", { data: {} });
|
||||||
|
const { url } = await response.json();
|
||||||
|
|
||||||
|
await page.goto(url);
|
||||||
|
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const select = page.getByTitle("Route Color");
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
await expect(select).toHaveValue("plain");
|
||||||
|
|
||||||
|
// Switch to elevation
|
||||||
|
await select.selectOption("elevation");
|
||||||
|
await expect(select).toHaveValue("elevation");
|
||||||
|
});
|
||||||
|
|
||||||
test("session has no-go area button", async ({ page, request }) => {
|
test("session has no-go area button", async ({ page, request }) => {
|
||||||
const response = await request.post("/api/sessions", { data: {} });
|
const response = await request.post("/api/sessions", { data: {} });
|
||||||
const { url } = await response.json();
|
const { url } = await response.json();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-26
|
||||||
132
openspec/changes/planner-route-interactions/design.md
Normal file
132
openspec/changes/planner-route-interactions/design.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Planner currently renders routes as a single-color `L.Polyline` with no
|
||||||
|
interactivity — users can't click on the route or drag it. Waypoints can only
|
||||||
|
be appended (click on map) or reordered in the sidebar. BRouter returns
|
||||||
|
per-point elevation in coordinates (`[lon, lat, ele]`) and can return surface
|
||||||
|
tags, but `mergeGeoJsonSegments` currently discards all per-point data and
|
||||||
|
only keeps track-level totals.
|
||||||
|
|
||||||
|
bikerouter.de and komoot both support click-to-split and drag-to-reshape as
|
||||||
|
primary editing interactions. These are the most natural way to refine a route
|
||||||
|
after the initial waypoints are placed.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Click on the route polyline to insert a waypoint at that position
|
||||||
|
- Drag midpoint handles between waypoints to reshape the route
|
||||||
|
- Color the route by elevation gradient (green→yellow→red) or surface type
|
||||||
|
- Preserve per-point data from BRouter through the merge pipeline
|
||||||
|
- All interactions synced via Yjs (collaborative)
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Undo/redo system (future change)
|
||||||
|
- Route alternatives (show multiple options)
|
||||||
|
- Custom color gradient configuration
|
||||||
|
- Surface type legend or detailed surface info panel
|
||||||
|
- Offline route coloring (requires BRouter data)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Click-to-split via Leaflet polyline event
|
||||||
|
|
||||||
|
Listen for `click` on the route polyline. On click, find the closest point on
|
||||||
|
the route geometry, determine which waypoint segment it falls in (between
|
||||||
|
waypoint N and N+1), and insert a new waypoint at that position using
|
||||||
|
`Y.Array.insert(N+1, [newWaypoint])`.
|
||||||
|
|
||||||
|
To find the segment index: the route is computed segment-by-segment (one per
|
||||||
|
consecutive waypoint pair). Track the coordinate count per segment in the
|
||||||
|
merged GeoJSON so we can map any route point index back to a waypoint segment.
|
||||||
|
|
||||||
|
**Alternative considered**: Using a separate invisible polyline for click
|
||||||
|
detection. Unnecessary — Leaflet's built-in polyline click events work fine
|
||||||
|
with appropriate `weight` for hit detection.
|
||||||
|
|
||||||
|
### D2: Midpoint handles as draggable CircleMarkers
|
||||||
|
|
||||||
|
For each consecutive pair of waypoints, render a small, semi-transparent
|
||||||
|
`L.CircleMarker` at the geographic midpoint of the route segment (not the
|
||||||
|
straight-line midpoint — use the actual route geometry midpoint). On drag
|
||||||
|
start, the handle becomes opaque and turns into a waypoint drag. On drag end,
|
||||||
|
insert a new waypoint at the dropped position.
|
||||||
|
|
||||||
|
Handles are only visible on hover or at higher zoom levels to avoid clutter.
|
||||||
|
They reposition after each route computation.
|
||||||
|
|
||||||
|
**Alternative considered**: Handles at the straight-line midpoint between
|
||||||
|
waypoints. Bad UX — on a winding route, the midpoint may be far from the
|
||||||
|
actual route.
|
||||||
|
|
||||||
|
### D3: Per-point data preservation in BRouter response
|
||||||
|
|
||||||
|
BRouter GeoJSON coordinates are `[lon, lat, ele]` — elevation is already
|
||||||
|
present but currently unused beyond the ElevationChart. For surface data,
|
||||||
|
BRouter supports a `tiledesc` parameter that includes waytype/surface tags
|
||||||
|
per point in the `properties.messages` array.
|
||||||
|
|
||||||
|
Modify `mergeGeoJsonSegments` to:
|
||||||
|
1. Preserve the full 3-element coordinates (already done, but not exposed)
|
||||||
|
2. Track segment boundaries (array of indices where each waypoint segment starts)
|
||||||
|
3. Optionally parse `properties.messages` for surface tags
|
||||||
|
|
||||||
|
Store the enriched data in `routeData` Y.Map so all participants have it.
|
||||||
|
|
||||||
|
### D4: Colored route rendering with segmented polylines
|
||||||
|
|
||||||
|
Use multiple `L.Polyline` instances, each covering a short segment of the
|
||||||
|
route with a color based on the data value at that point. For elevation:
|
||||||
|
normalize elevation values to 0-1 range across the route, map to a
|
||||||
|
green→yellow→red gradient. For surface: map surface type strings to a fixed
|
||||||
|
color palette (asphalt=gray, gravel=brown, path=green, etc.).
|
||||||
|
|
||||||
|
Three rendering modes, toggled by a button in the header:
|
||||||
|
1. **Plain**: Current single-color blue polyline (default)
|
||||||
|
2. **Elevation**: Gradient by elevation
|
||||||
|
3. **Surface**: Colored by surface type
|
||||||
|
|
||||||
|
**Alternative considered**: `leaflet-hotline` for smooth canvas-based gradient
|
||||||
|
rendering. Better visual quality but adds a dependency and doesn't support
|
||||||
|
click events on the colored line. Since we need click-to-split on the route,
|
||||||
|
we need real Leaflet layers. Can revisit later if performance is an issue.
|
||||||
|
|
||||||
|
**Alternative considered**: Single Canvas renderer. Better performance for
|
||||||
|
very long routes but much more complex, and breaks Leaflet's event model.
|
||||||
|
Not needed at current route lengths (<1000 points typical).
|
||||||
|
|
||||||
|
### D5: Segment boundary tracking
|
||||||
|
|
||||||
|
The key data structure bridging BRouter output and map interactions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EnrichedRoute {
|
||||||
|
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||||
|
segmentBoundaries: number[]; // indices where each waypoint segment starts
|
||||||
|
surfaces?: string[]; // surface type per point (optional)
|
||||||
|
totalLength: number;
|
||||||
|
totalAscend: number;
|
||||||
|
totalTime: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`segmentBoundaries[i]` is the coordinate index where the route segment from
|
||||||
|
waypoint `i` to waypoint `i+1` starts. This enables:
|
||||||
|
- Click-to-split: find which segment a clicked point belongs to
|
||||||
|
- Midpoint handles: find the midpoint of each segment's geometry
|
||||||
|
- Per-segment coloring: color differently per waypoint pair if needed
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Performance with many segments** → Hundreds of small `L.Polyline` instances
|
||||||
|
for colored rendering could be slow. Mitigate: batch updates, only re-render
|
||||||
|
changed segments, limit color segments to ~100 per route. Can switch to
|
||||||
|
canvas if needed later.
|
||||||
|
- **BRouter surface data availability** → Not all BRouter profiles return
|
||||||
|
surface tags. The `tiledesc` parameter may not work with all profiles.
|
||||||
|
Mitigate: surface coloring is optional; elevation always works (from coords).
|
||||||
|
- **Click precision on thin polylines** → Hard to click a 4px line on mobile.
|
||||||
|
Mitigate: use `L.Polyline` `weight` for rendering but a wider invisible
|
||||||
|
polyline for click detection.
|
||||||
|
- **Midpoint handle clutter** → Routes with many waypoints get cluttered.
|
||||||
|
Mitigate: only show handles on hover or at zoom level ≥ 12.
|
||||||
56
openspec/changes/planner-route-interactions/proposal.md
Normal file
56
openspec/changes/planner-route-interactions/proposal.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The Planner's route editing is click-only: users place waypoints and the route
|
||||||
|
auto-computes between them. But refining a route is tedious — you can't insert
|
||||||
|
a waypoint on the route itself, drag a route segment to reshape it, or see what
|
||||||
|
kind of terrain you're heading into. These are table-stakes features in modern
|
||||||
|
route planners (bikerouter.de, komoot, Ride with GPS) and the most common
|
||||||
|
feedback from early testers.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Route splitting**: Click on the route polyline to insert a new waypoint at
|
||||||
|
that position, splitting the segment. The waypoint snaps to the closest point
|
||||||
|
on the existing route.
|
||||||
|
- **Drag-to-reshape**: Invisible midpoint handles appear between waypoints on
|
||||||
|
the route. Dragging a midpoint inserts a new waypoint and triggers route
|
||||||
|
recomputation — the natural way to push a route onto a different path.
|
||||||
|
- **Colored route rendering**: Replace the single-color route polyline with
|
||||||
|
segments colored by elevation gradient or surface type (from BRouter's
|
||||||
|
per-point data). Users can toggle between plain, elevation, and surface
|
||||||
|
color modes.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `route-splitting`: Insert waypoints by clicking on the route polyline, with
|
||||||
|
snapping to the nearest route point
|
||||||
|
- `route-drag-reshape`: Drag midpoint handles on route segments to reshape the
|
||||||
|
route interactively
|
||||||
|
- `route-coloring`: Color-code the route polyline by elevation gradient or
|
||||||
|
surface type using BRouter track data
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `map-display`: Route visualization changes from a single-color polyline to
|
||||||
|
a segmented, interactive polyline with color modes and click/drag interactions
|
||||||
|
- `brouter-integration`: BRouter response data (elevation per point, surface
|
||||||
|
tags) must be preserved and exposed to the rendering layer
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **PlannerMap.tsx**: Major changes — route polyline becomes interactive with
|
||||||
|
click-to-split and drag midpoint handles
|
||||||
|
- **brouter.ts**: Expose per-point elevation and surface data from GeoJSON
|
||||||
|
properties (currently only track-level stats are extracted)
|
||||||
|
- **use-routing.ts**: Support waypoint insertion at specific indices (not just
|
||||||
|
append)
|
||||||
|
- **use-yjs.ts**: Waypoint insertion at arbitrary positions (already supported
|
||||||
|
by Y.Array.insert)
|
||||||
|
- **New components**: ColoredRoute (segmented polyline renderer),
|
||||||
|
MidpointHandles (draggable reshape markers)
|
||||||
|
- **ElevationChart**: Add hover-to-highlight sync with colored route
|
||||||
|
- **Dependencies**: May need `leaflet-hotline` for smooth gradient rendering,
|
||||||
|
or custom canvas renderer
|
||||||
|
- **i18n**: New keys for color mode labels (en + de)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Route computation from waypoints
|
||||||
|
The Planner SHALL compute a route between ordered waypoints by calling the BRouter HTTP API and returning the result as GeoJSON, preserving per-point elevation and segment boundary data.
|
||||||
|
|
||||||
|
#### Scenario: Compute route with two waypoints
|
||||||
|
- **WHEN** the routing host submits two waypoints (start, end) with profile "trekking"
|
||||||
|
- **THEN** the BRouter API returns a GeoJSON route within 2 seconds
|
||||||
|
|
||||||
|
#### Scenario: Compute route with via points
|
||||||
|
- **WHEN** the routing host submits three or more waypoints
|
||||||
|
- **THEN** the BRouter API returns a route passing through all waypoints in order
|
||||||
|
|
||||||
|
#### Scenario: Per-point elevation preserved
|
||||||
|
- **WHEN** BRouter returns GeoJSON with 3D coordinates [lon, lat, ele]
|
||||||
|
- **THEN** the merged route response SHALL preserve elevation values for every coordinate point
|
||||||
|
|
||||||
|
#### Scenario: Segment boundaries tracked
|
||||||
|
- **WHEN** a route with N waypoints is computed (N-1 segments)
|
||||||
|
- **THEN** the response SHALL include an array of coordinate indices marking where each waypoint-to-waypoint segment begins
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Route visualization
|
||||||
|
The map SHALL display the computed route as an interactive, optionally color-coded polyline on the map.
|
||||||
|
|
||||||
|
#### Scenario: Display route
|
||||||
|
- **WHEN** BRouter returns a route GeoJSON
|
||||||
|
- **THEN** the route is rendered as a polyline on the map, colored according to the active color mode
|
||||||
|
|
||||||
|
#### Scenario: Route updates on waypoint change
|
||||||
|
- **WHEN** a waypoint is added, moved, or deleted
|
||||||
|
- **THEN** the route polyline updates after BRouter recomputes the route
|
||||||
|
|
||||||
|
#### Scenario: Route is clickable
|
||||||
|
- **WHEN** a user clicks on the route polyline
|
||||||
|
- **THEN** a new waypoint is inserted at the clicked position (see route-splitting spec)
|
||||||
|
|
||||||
|
#### Scenario: Route has midpoint handles
|
||||||
|
- **WHEN** a route with two or more waypoints is displayed
|
||||||
|
- **THEN** draggable midpoint handles appear on each route segment (see route-drag-reshape spec)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Route color modes
|
||||||
|
The Planner SHALL support multiple route color modes that visualize per-point data along the route.
|
||||||
|
|
||||||
|
#### Scenario: Default plain mode
|
||||||
|
- **WHEN** a route is first displayed
|
||||||
|
- **THEN** it renders as a single-color blue polyline (current behavior)
|
||||||
|
|
||||||
|
#### Scenario: Elevation color mode
|
||||||
|
- **WHEN** a user selects the "Elevation" color mode
|
||||||
|
- **THEN** the route polyline is colored with a gradient from green (low) through yellow (mid) to red (high), based on the elevation at each point
|
||||||
|
|
||||||
|
#### Scenario: Surface color mode
|
||||||
|
- **WHEN** a user selects the "Surface" color mode and surface data is available
|
||||||
|
- **THEN** the route polyline is colored by surface type (e.g., asphalt=gray, gravel=brown, path=green, track=orange)
|
||||||
|
|
||||||
|
#### Scenario: Surface data unavailable
|
||||||
|
- **WHEN** a user selects "Surface" color mode but BRouter did not return surface data
|
||||||
|
- **THEN** the route falls back to plain color mode and a brief message indicates surface data is not available
|
||||||
|
|
||||||
|
### Requirement: Color mode toggle
|
||||||
|
The Planner SHALL provide a UI control to switch between route color modes.
|
||||||
|
|
||||||
|
#### Scenario: Toggle control location
|
||||||
|
- **WHEN** a route is displayed
|
||||||
|
- **THEN** a color mode selector is visible in the session header or map controls
|
||||||
|
|
||||||
|
#### Scenario: Color mode persists in session
|
||||||
|
- **WHEN** a user changes the color mode
|
||||||
|
- **THEN** the selection is stored in the Yjs routeData map and synced to all participants
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Midpoint drag handles on route segments
|
||||||
|
The Planner SHALL display draggable midpoint handles between consecutive waypoints on the route, allowing users to reshape the route by dragging.
|
||||||
|
|
||||||
|
#### Scenario: Midpoint handle visible
|
||||||
|
- **WHEN** a route is displayed with at least two waypoints
|
||||||
|
- **THEN** a small circular handle appears at the geographic midpoint of each route segment (using the actual route geometry, not straight-line distance)
|
||||||
|
|
||||||
|
#### Scenario: Drag midpoint to reshape
|
||||||
|
- **WHEN** a user drags a midpoint handle to a new position
|
||||||
|
- **THEN** a new waypoint is inserted at the dropped position between the two adjacent waypoints, and the route recomputes through the new point
|
||||||
|
|
||||||
|
#### Scenario: Handle visibility control
|
||||||
|
- **WHEN** the map zoom level is below 12
|
||||||
|
- **THEN** midpoint handles are hidden to reduce visual clutter
|
||||||
|
|
||||||
|
#### Scenario: Reshape syncs to other participants
|
||||||
|
- **WHEN** a user reshapes the route by dragging a midpoint
|
||||||
|
- **THEN** all other participants see the new waypoint and recomputed route via Yjs sync
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Insert waypoint by clicking on route
|
||||||
|
The Planner SHALL allow users to insert a new waypoint by clicking on the rendered route polyline.
|
||||||
|
|
||||||
|
#### Scenario: Click on route between two waypoints
|
||||||
|
- **WHEN** a user clicks on the route polyline between waypoint 2 and waypoint 3
|
||||||
|
- **THEN** a new waypoint is inserted at position 3 (between the original waypoints 2 and 3) at the clicked location, and the route recomputes
|
||||||
|
|
||||||
|
#### Scenario: Waypoint snaps to route
|
||||||
|
- **WHEN** a user clicks near the route polyline
|
||||||
|
- **THEN** the new waypoint is placed at the closest point on the existing route geometry, not at the raw click position
|
||||||
|
|
||||||
|
#### Scenario: Split syncs to other participants
|
||||||
|
- **WHEN** a user inserts a waypoint by clicking on the route
|
||||||
|
- **THEN** all other participants see the new waypoint appear in the waypoint list and on the map via Yjs sync
|
||||||
45
openspec/changes/planner-route-interactions/tasks.md
Normal file
45
openspec/changes/planner-route-interactions/tasks.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
## 1. BRouter Data Pipeline
|
||||||
|
|
||||||
|
- [x] 1.1 Modify `mergeGeoJsonSegments` to preserve 3D coordinates (lon, lat, ele) and track segment boundary indices
|
||||||
|
- [x] 1.2 Add `EnrichedRoute` interface with coordinates, segmentBoundaries, surfaces, and stats
|
||||||
|
- [x] 1.3 Store enriched route data (including segment boundaries) in Yjs routeData map
|
||||||
|
- [x] 1.4 Write unit test for segment boundary tracking across multi-waypoint routes
|
||||||
|
|
||||||
|
## 2. Route Splitting (click-to-insert)
|
||||||
|
|
||||||
|
- [x] 2.1 Make the route polyline clickable — add click event handler to route Polyline in PlannerMap
|
||||||
|
- [x] 2.2 On click, find nearest point on route geometry and determine which waypoint segment it belongs to (using segment boundaries)
|
||||||
|
- [x] 2.3 Insert new waypoint at the clicked position using `Y.Array.insert(segmentIndex + 1, [waypoint])`
|
||||||
|
- [x] 2.4 Add wider invisible polyline behind the route for easier click targeting (hit area)
|
||||||
|
|
||||||
|
## 3. Drag-to-Reshape (midpoint handles)
|
||||||
|
|
||||||
|
- [x] 3.1 Create MidpointHandles component — renders a CircleMarker at the geographic midpoint of each route segment
|
||||||
|
- [x] 3.2 Compute geographic midpoint from actual route geometry (not straight-line between waypoints)
|
||||||
|
- [x] 3.3 On drag end, insert waypoint at dropped position between the two adjacent waypoints
|
||||||
|
- [x] 3.4 Hide midpoint handles below zoom level 12
|
||||||
|
- [x] 3.5 Style handles: semi-transparent by default, opaque on hover, match waypoint color scheme
|
||||||
|
|
||||||
|
## 4. Route Coloring
|
||||||
|
|
||||||
|
- [x] 4.1 Create ColoredRoute component — renders multiple short L.Polyline segments with per-point colors
|
||||||
|
- [x] 4.2 Implement elevation gradient: normalize elevation values to 0-1 range, map to green→yellow→red
|
||||||
|
- [x] 4.3 Implement surface coloring: map BRouter surface type strings to a fixed color palette
|
||||||
|
- [x] 4.4 Add color mode state to Yjs routeData map ("plain" | "elevation" | "surface")
|
||||||
|
- [x] 4.5 Add color mode toggle button to session header (icon or dropdown)
|
||||||
|
- [x] 4.6 Fall back to plain mode when surface data is unavailable
|
||||||
|
- [x] 4.7 Add i18n keys for color mode labels (en + de)
|
||||||
|
|
||||||
|
## 5. Integration
|
||||||
|
|
||||||
|
- [x] 5.1 Replace single Polyline in PlannerMap with ColoredRoute + click handler + MidpointHandles
|
||||||
|
- [x] 5.2 Ensure click-to-split and drag-to-reshape work alongside no-go area drawing mode (disable route interactions when drawing)
|
||||||
|
- [x] 5.3 Update ElevationChart hover sync to work with the new colored route
|
||||||
|
|
||||||
|
## 6. Verify
|
||||||
|
|
||||||
|
- [x] 6.1 E2E test: click on route inserts waypoint at correct position
|
||||||
|
- [x] 6.2 E2E test: color mode toggle switches between plain/elevation/surface
|
||||||
|
- [x] 6.3 Unit test: segment boundary computation with 2, 3, and 5 waypoints
|
||||||
|
- [x] 6.4 Verify midpoint handles reposition after route recomputation
|
||||||
|
- [x] 6.5 Verify all interactions sync correctly between two browser tabs
|
||||||
|
|
@ -54,6 +54,13 @@ export default {
|
||||||
notes: {
|
notes: {
|
||||||
placeholder: "Notizen für diese Sitzung hinzufügen...",
|
placeholder: "Notizen für diese Sitzung hinzufügen...",
|
||||||
},
|
},
|
||||||
|
colorMode: {
|
||||||
|
label: "Routenfarbe",
|
||||||
|
plain: "Standard",
|
||||||
|
elevation: "Höhe",
|
||||||
|
surface: "Untergrund",
|
||||||
|
surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar",
|
||||||
|
},
|
||||||
rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.",
|
rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.",
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Höhenmeter aufwärts",
|
gain: "Höhenmeter aufwärts",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,13 @@ export default {
|
||||||
notes: {
|
notes: {
|
||||||
placeholder: "Add notes for this session...",
|
placeholder: "Add notes for this session...",
|
||||||
},
|
},
|
||||||
|
colorMode: {
|
||||||
|
label: "Route Color",
|
||||||
|
plain: "Plain",
|
||||||
|
elevation: "Elevation",
|
||||||
|
surface: "Surface",
|
||||||
|
surfaceUnavailable: "Surface data not available for this profile",
|
||||||
|
},
|
||||||
rateLimitExceeded: "Too many requests. Please try again later.",
|
rateLimitExceeded: "Too many requests. Please try again later.",
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Elevation Gain",
|
gain: "Elevation Gain",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue