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:
parent
f4f0cbc4c8
commit
1e2f6beded
18 changed files with 690 additions and 21 deletions
|
|
@ -2,12 +2,13 @@ import { useMemo } from "react";
|
|||
import { Polyline } from "react-leaflet";
|
||||
import type L from "leaflet";
|
||||
|
||||
export type ColorMode = "plain" | "elevation" | "surface" | "grade";
|
||||
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway";
|
||||
|
||||
interface ColoredRouteProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
colorMode: ColorMode;
|
||||
surfaces?: string[];
|
||||
highways?: string[];
|
||||
}
|
||||
|
||||
const SURFACE_COLORS: Record<string, string> = {
|
||||
|
|
@ -32,6 +33,36 @@ const SURFACE_COLORS: Record<string, string> = {
|
|||
|
||||
const DEFAULT_SURFACE_COLOR = "#9ca3af";
|
||||
|
||||
const HIGHWAY_COLORS: Record<string, string> = {
|
||||
// Major roads — warm tones (caution for cyclists)
|
||||
motorway: "#dc2626",
|
||||
motorway_link: "#dc2626",
|
||||
trunk: "#ea580c",
|
||||
trunk_link: "#ea580c",
|
||||
primary: "#f97316",
|
||||
primary_link: "#f97316",
|
||||
// Urban roads — neutral tones
|
||||
secondary: "#6366f1",
|
||||
secondary_link: "#6366f1",
|
||||
tertiary: "#818cf8",
|
||||
tertiary_link: "#818cf8",
|
||||
residential: "#6b7280",
|
||||
unclassified: "#9ca3af",
|
||||
living_street: "#a78bfa",
|
||||
// Paths & cycling infrastructure — green tones
|
||||
cycleway: "#16a34a",
|
||||
path: "#22c55e",
|
||||
footway: "#4ade80",
|
||||
track: "#65a30d",
|
||||
bridleway: "#84cc16",
|
||||
// Service & other — muted tones
|
||||
service: "#d4d4d8",
|
||||
pedestrian: "#c084fc",
|
||||
steps: "#f472b6",
|
||||
};
|
||||
|
||||
const DEFAULT_HIGHWAY_COLOR = "#9ca3af";
|
||||
|
||||
export function routeGradeColor(grade: number): string {
|
||||
const absGrade = Math.abs(grade);
|
||||
if (absGrade < 3) return "#22c55e";
|
||||
|
|
@ -51,7 +82,7 @@ export function elevationColor(t: number): string {
|
|||
return `rgb(255, ${g}, 50)`;
|
||||
}
|
||||
|
||||
export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) {
|
||||
export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: ColoredRouteProps) {
|
||||
const segments = useMemo(() => {
|
||||
if (colorMode === "plain" || coordinates.length < 2) {
|
||||
return null;
|
||||
|
|
@ -98,6 +129,24 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP
|
|||
return result;
|
||||
}
|
||||
|
||||
// highway mode
|
||||
if (colorMode === "highway") {
|
||||
if (!highways || highways.length < coordinates.length) return null;
|
||||
|
||||
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
|
||||
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||
const highway = highways[i] ?? "unknown";
|
||||
result.push({
|
||||
positions: [
|
||||
[coordinates[i]![1], coordinates[i]![0]],
|
||||
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
|
||||
],
|
||||
color: HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// surface mode
|
||||
if (!surfaces || surfaces.length < coordinates.length) return null;
|
||||
|
||||
|
|
@ -113,7 +162,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP
|
|||
});
|
||||
}
|
||||
return result;
|
||||
}, [coordinates, colorMode, surfaces]);
|
||||
}, [coordinates, colorMode, surfaces, highways]);
|
||||
|
||||
const plainPositions = useMemo(
|
||||
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
|
||||
|
|
@ -154,4 +203,4 @@ export function findSegmentForPoint(
|
|||
return 0;
|
||||
}
|
||||
|
||||
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR };
|
||||
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, type ColorMode } from "~/components/ColoredRoute";
|
||||
import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute";
|
||||
|
||||
function gradeColor(grade: number): string {
|
||||
const absGrade = Math.abs(grade);
|
||||
|
|
@ -75,6 +75,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
|
|
@ -95,6 +96,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
} else {
|
||||
setSurfaces([]);
|
||||
}
|
||||
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
|
||||
if (highwaysJson) {
|
||||
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
|
||||
} else {
|
||||
setHighways([]);
|
||||
}
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
|
|
@ -203,6 +210,30 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
ctx.fillStyle = color + "40";
|
||||
ctx.fill();
|
||||
|
||||
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 if (colorMode === "highway" && highways.length >= points.length) {
|
||||
// Highway-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const highway = highways[i] ?? "unknown";
|
||||
const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
||||
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color + "40";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
|
|
@ -305,12 +336,15 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
if (colorMode === "surface" && surfaces[highlightIdx]) {
|
||||
label += ` · ${surfaces[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "highway" && highways[highlightIdx]) {
|
||||
label += ` · ${highways[highlightIdx]}`;
|
||||
}
|
||||
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
|
||||
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
||||
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||
}
|
||||
},
|
||||
[points, colorMode, surfaces, days, t],
|
||||
[points, colorMode, surfaces, highways, days, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -369,7 +403,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
<div className="border-t border-gray-200 px-2 py-2">
|
||||
<div className="mb-1 flex items-center gap-2 px-2">
|
||||
<p className="shrink-0 text-xs font-medium text-gray-500">
|
||||
{colorMode === "grade" ? t("elevation.grade") : t("elevation.profile")}
|
||||
{colorMode === "grade" ? t("elevation.grade") : colorMode === "highway" ? t("elevation.highway") : t("elevation.profile")}
|
||||
</p>
|
||||
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-gray-400">
|
||||
{colorMode === "grade" && (<>
|
||||
|
|
@ -393,6 +427,15 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
))}
|
||||
{[...new Set(surfaces)].length > 6 && <span>+{[...new Set(surfaces)].length - 6}</span>}
|
||||
</>)}
|
||||
{colorMode === "highway" && highways.length > 0 && (<>
|
||||
{[...new Set(highways)].slice(0, 6).map((h) => (
|
||||
<span key={h} className="flex items-center gap-0.5">
|
||||
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: HIGHWAY_COLORS[h] ?? DEFAULT_HIGHWAY_COLOR }} />
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
{[...new Set(highways)].length > 6 && <span>+{[...new Set(highways)].length - 6}</span>}
|
||||
</>)}
|
||||
</div>
|
||||
<select
|
||||
value={colorMode}
|
||||
|
|
@ -403,6 +446,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
<option value="elevation">{t("colorMode.elevation")}</option>
|
||||
<option value="surface">{t("colorMode.surface")}</option>
|
||||
<option value="grade">{t("colorMode.grade")}</option>
|
||||
<option value="highway">{t("colorMode.highway")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
|
|
@ -442,6 +443,13 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
setSurfaces([]);
|
||||
}
|
||||
|
||||
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
|
||||
if (highwaysJson) {
|
||||
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
|
||||
} else {
|
||||
setHighways([]);
|
||||
}
|
||||
|
||||
if (modeVal) setColorMode(modeVal);
|
||||
};
|
||||
|
||||
|
|
@ -722,6 +730,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
coordinates={routeCoordinates}
|
||||
colorMode={colorMode}
|
||||
surfaces={surfaces}
|
||||
highways={highways}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue