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

@ -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 {