Add road type color mode to route visualization

Extract highway=* tags from BRouter tiledesc messages alongside surface
data and add a new "Road Type" color mode that colors the route polyline
and elevation chart by OSM highway classification (cycleway, residential,
path, etc.). Includes color palette, legend, hover labels, i18n (EN+DE),
unit tests for tag extraction, and E2E tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 03:41:49 +02:00
parent f4f0cbc4c8
commit 1e2f6beded
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
18 changed files with 690 additions and 21 deletions

View file

@ -144,6 +144,83 @@ describe("mergeGeoJsonSegments", () => {
});
});
describe("highway tag extraction", () => {
function makeSegmentWithTags(coords: number[][], tags: string[]) {
return {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: {
"track-length": "100",
"filtered ascend": "0",
"total-time": "100",
messages: [
["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags"],
...tags.map((t, i) => [String(coords[i]?.[0] ?? 0), String(coords[i]?.[1] ?? 0), "0", "100", "0", "0", "0", "0", "0", t]),
],
},
geometry: { type: "LineString", coordinates: coords },
}],
};
}
it("extracts highway tags from WayTags", () => {
const seg = makeSegmentWithTags(
[[13.0, 52.0, 30], [13.1, 52.1, 40], [13.2, 52.2, 50]],
["highway=cycleway surface=asphalt", "highway=residential surface=cobblestone", "highway=path surface=gravel"],
);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.highways[0]).toBe("cycleway");
expect(result.highways[1]).toBe("residential");
expect(result.highways[2]).toBe("path");
});
it("extracts surface and highway tags together", () => {
const seg = makeSegmentWithTags(
[[13.0, 52.0, 30], [13.1, 52.1, 40]],
["highway=tertiary surface=asphalt", "highway=cycleway surface=gravel"],
);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.surfaces[0]).toBe("asphalt");
expect(result.highways[0]).toBe("tertiary");
expect(result.surfaces[1]).toBe("gravel");
expect(result.highways[1]).toBe("cycleway");
});
it("defaults to unknown when highway tag is missing", () => {
const seg = makeSegmentWithTags(
[[13.0, 52.0, 30], [13.1, 52.1, 40]],
["surface=asphalt", "surface=gravel"],
);
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.highways[0]).toBe("unknown");
expect(result.highways[1]).toBe("unknown");
});
it("handles missing WayTags column", () => {
const seg = {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: {
"track-length": "100",
"filtered ascend": "0",
"total-time": "100",
messages: [["Longitude", "Latitude"]],
},
geometry: { type: "LineString", coordinates: [[13.0, 52.0, 30], [13.1, 52.1, 40]] },
}],
};
const result = mergeGeoJsonSegments([seg] as never[]);
expect(result.highways[0]).toBe("unknown");
expect(result.highways[1]).toBe("unknown");
});
});
describe("waypoint to BRouter segments", () => {
it("splits N waypoints into N-1 pairs", () => {
const waypoints = [

View file

@ -16,6 +16,7 @@ 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")
highways: string[]; // highway classification per coordinate point (e.g. "cycleway", "residential")
totalLength: number;
totalAscend: number;
totalTime: number;
@ -94,6 +95,7 @@ interface GeoJsonCollection {
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): EnrichedRoute {
const allCoords: [number, number, number][] = [];
const allSurfaces: string[] = [];
const allHighways: string[] = [];
const segmentBoundaries: number[] = [];
let totalLength = 0;
let totalAscend = 0;
@ -108,15 +110,16 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
segmentBoundaries.push(allCoords.length);
const coords = feature.geometry.coordinates;
// Extract surface data from BRouter messages (tiledesc=true)
const surfaceMap = extractSurfacesFromMessages(feature.properties);
// Extract surface and highway data from BRouter messages (tiledesc=true)
const wayTagData = extractWayTagData(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++) {
const c = coords[j]!;
allCoords.push([c[0]!, c[1]!, c[2] ?? 0]);
allSurfaces.push(surfaceMap.get(j) ?? surfaceMap.get(j - 1) ?? "unknown");
allSurfaces.push(wayTagData.surfaces.get(j) ?? wayTagData.surfaces.get(j - 1) ?? "unknown");
allHighways.push(wayTagData.highways.get(j) ?? wayTagData.highways.get(j - 1) ?? "unknown");
}
// Accumulate stats
@ -149,6 +152,7 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
coordinates: allCoords,
segmentBoundaries,
surfaces: allSurfaces,
highways: allHighways,
totalLength,
totalAscend,
totalTime,
@ -156,27 +160,35 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
};
}
interface WayTagData {
surfaces: Map<number, string>;
highways: Map<number, string>;
}
/**
* Extract surface type per message row from BRouter's tiledesc messages.
* Extract surface and highway 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".
* We look for the "WayTags" column which contains OSM tags like "surface=asphalt" and "highway=cycleway".
*/
function extractSurfacesFromMessages(properties: Record<string, unknown>): Map<number, string> {
const result = new Map<number, string>();
function extractWayTagData(properties: Record<string, unknown>): WayTagData {
const surfaces = new Map<number, string>();
const highways = new Map<number, string>();
const messages = properties.messages as string[][] | undefined;
if (!messages || messages.length < 2) return result;
if (!messages || messages.length < 2) return { surfaces, highways };
const headers = messages[0]!;
const wayTagsIdx = headers.indexOf("WayTags");
if (wayTagsIdx === -1) return result;
if (wayTagsIdx === -1) return { surfaces, highways };
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");
surfaces.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown");
const highwayMatch = tags.match(/highway=(\S+)/);
highways.set(i - 1, highwayMatch ? highwayMatch[1]! : "unknown");
}
return result;
return { surfaces, highways };
}
/**

View file

@ -126,6 +126,9 @@ export function useRouting(yjs: YjsState | null) {
if (enriched.surfaces?.length) {
yjs.routeData.set("surfaces", JSON.stringify(enriched.surfaces));
}
if (enriched.highways?.length) {
yjs.routeData.set("highways", JSON.stringify(enriched.highways));
}
});
} catch {
setRouteError("failed");