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

@ -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 };

View file

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

View file

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