Merge pull request #208 from trails-cool/road-type

Add road type color mode
This commit is contained in:
Ullrich Schäfer 2026-04-11 11:17:01 +02:00 committed by GitHub
commit 80404b2494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1312 additions and 26 deletions

View file

@ -2,12 +2,18 @@ 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" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute";
interface ColoredRouteProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
colorMode: ColorMode;
surfaces?: string[];
highways?: string[];
maxspeeds?: string[];
smoothnesses?: string[];
tracktypes?: string[];
cycleways?: string[];
bikeroutes?: string[];
}
const SURFACE_COLORS: Record<string, string> = {
@ -32,6 +38,81 @@ 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";
const SMOOTHNESS_COLORS: Record<string, string> = {
excellent: "#22c55e",
good: "#16a34a",
intermediate: "#eab308",
bad: "#f97316",
very_bad: "#ef4444",
horrible: "#991b1b",
very_horrible: "#7f1d1d",
impassable: "#450a0a",
};
const DEFAULT_SMOOTHNESS_COLOR = "#9ca3af";
const TRACKTYPE_COLORS: Record<string, string> = {
grade1: "#22c55e",
grade2: "#84cc16",
grade3: "#eab308",
grade4: "#f97316",
grade5: "#ef4444",
};
const DEFAULT_TRACKTYPE_COLOR = "#9ca3af";
const CYCLEWAY_COLORS: Record<string, string> = {
track: "#22c55e",
lane: "#84cc16",
shared_lane: "#eab308",
share_busway: "#f97316",
opposite_lane: "#818cf8",
separate: "#16a34a",
no: "#ef4444",
};
const DEFAULT_CYCLEWAY_COLOR = "#9ca3af";
const BIKEROUTE_COLORS: Record<string, string> = {
icn: "#7c3aed", // purple — international
ncn: "#2563eb", // blue — national
rcn: "#0891b2", // teal — regional
lcn: "#059669", // emerald — local
none: "#d4d4d8", // gray — no route
};
const DEFAULT_BIKEROUTE_COLOR = "#d4d4d8";
export function routeGradeColor(grade: number): string {
const absGrade = Math.abs(grade);
if (absGrade < 3) return "#22c55e";
@ -51,7 +132,20 @@ export function elevationColor(t: number): string {
return `rgb(255, ${g}, 50)`;
}
export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) {
export function maxspeedColor(speed: string): string {
if (speed === "walk") return "#22c55e";
if (speed === "none") return "#991b1b";
const num = parseInt(speed, 10);
if (isNaN(num)) return "#9ca3af"; // unknown/gray
if (num <= 20) return "#22c55e";
if (num <= 30) return "#22c55e";
if (num <= 50) return "#eab308";
if (num <= 70) return "#f97316";
if (num <= 100) return "#ef4444";
return "#991b1b"; // >100 dark red
}
export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) {
const segments = useMemo(() => {
if (colorMode === "plain" || coordinates.length < 2) {
return null;
@ -98,6 +192,114 @@ 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;
}
// maxspeed mode
if (colorMode === "maxspeed") {
if (!maxspeeds || maxspeeds.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const speed = maxspeeds[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: maxspeedColor(speed),
});
}
return result;
}
// smoothness mode
if (colorMode === "smoothness") {
if (!smoothnesses || smoothnesses.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const smoothness = smoothnesses[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR,
});
}
return result;
}
// tracktype mode
if (colorMode === "tracktype") {
if (!tracktypes || tracktypes.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const tracktype = tracktypes[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR,
});
}
return result;
}
// cycleway mode
if (colorMode === "cycleway") {
if (!cycleways || cycleways.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const cycleway = cycleways[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR,
});
}
return result;
}
// bikeroute mode
if (colorMode === "bikeroute") {
if (!bikeroutes || bikeroutes.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const bikeroute = bikeroutes[i] ?? "none";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR,
});
}
return result;
}
// surface mode
if (!surfaces || surfaces.length < coordinates.length) return null;
@ -113,7 +315,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP
});
}
return result;
}, [coordinates, colorMode, surfaces]);
}, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]);
const plainPositions = useMemo(
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
@ -154,4 +356,11 @@ export function findSegmentForPoint(
return 0;
}
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR };
export {
SURFACE_COLORS, DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS, DEFAULT_SMOOTHNESS_COLOR,
TRACKTYPE_COLORS, DEFAULT_TRACKTYPE_COLOR,
CYCLEWAY_COLORS, DEFAULT_CYCLEWAY_COLOR,
BIKEROUTE_COLORS, DEFAULT_BIKEROUTE_COLOR,
};

View file

@ -2,7 +2,16 @@ 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, maxspeedColor,
SURFACE_COLORS, DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS, DEFAULT_SMOOTHNESS_COLOR,
TRACKTYPE_COLORS, DEFAULT_TRACKTYPE_COLOR,
CYCLEWAY_COLORS, DEFAULT_CYCLEWAY_COLOR,
BIKEROUTE_COLORS, DEFAULT_BIKEROUTE_COLOR,
type ColorMode,
} from "~/components/ColoredRoute";
function gradeColor(grade: number): string {
const absGrade = Math.abs(grade);
@ -75,6 +84,12 @@ 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 [maxspeeds, setMaxspeeds] = useState<string[]>([]);
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
const [tracktypes, setTracktypes] = useState<string[]>([]);
const [cycleways, setCycleways] = useState<string[]>([]);
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
pointsRef.current = points;
@ -95,6 +110,42 @@ 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([]);
}
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
if (maxspeedsJson) {
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
} else {
setMaxspeeds([]);
}
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
if (smoothnessesJson) {
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
} else {
setSmoothnesses([]);
}
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
if (tracktypesJson) {
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
} else {
setTracktypes([]);
}
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
if (cyclewaysJson) {
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
} else {
setCycleways([]);
}
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
if (bikeroutesJson) {
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
} else {
setBikeroutes([]);
}
};
yjs.routeData.observe(update);
update();
@ -203,6 +254,150 @@ 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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
// Maxspeed-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const speed = maxspeeds[i] ?? "unknown";
const color = maxspeedColor(speed);
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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
// Smoothness-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const smoothness = smoothnesses[i] ?? "unknown";
const color = SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
// Track type-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const tracktype = tracktypes[i] ?? "unknown";
const color = TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
// Cycleway-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const cycleway = cycleways[i] ?? "unknown";
const color = CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
// Bike route-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const bikeroute = bikeroutes[i] ?? "none";
const color = BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_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 +500,32 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
if (colorMode === "surface" && surfaces[highlightIdx]) {
label += ` · ${surfaces[highlightIdx]}`;
}
if (colorMode === "highway" && highways[highlightIdx]) {
label += ` · ${highways[highlightIdx]}`;
}
if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) {
const s = maxspeeds[highlightIdx]!;
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
}
if (colorMode === "smoothness" && smoothnesses[highlightIdx]) {
label += ` · ${smoothnesses[highlightIdx]}`;
}
if (colorMode === "tracktype" && tracktypes[highlightIdx]) {
label += ` · ${tracktypes[highlightIdx]}`;
}
if (colorMode === "cycleway" && cycleways[highlightIdx]) {
label += ` · ${cycleways[highlightIdx]}`;
}
if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) {
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
label += ` · ${names[bikeroutes[highlightIdx]!] ?? bikeroutes[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, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
);
useEffect(() => {
@ -368,9 +583,35 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
return (
<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")}
</p>
{(() => {
const osmLinks: Record<string, string> = {
highway: "https://wiki.openstreetmap.org/wiki/Key:highway",
maxspeed: "https://wiki.openstreetmap.org/wiki/Key:maxspeed",
surface: "https://wiki.openstreetmap.org/wiki/Key:surface",
smoothness: "https://wiki.openstreetmap.org/wiki/Key:smoothness",
tracktype: "https://wiki.openstreetmap.org/wiki/Key:tracktype",
cycleway: "https://wiki.openstreetmap.org/wiki/Key:cycleway",
bikeroute: "https://wiki.openstreetmap.org/wiki/Tag:route%3Dbicycle",
};
const titles: Record<string, string> = {
grade: t("elevation.grade"),
highway: t("elevation.highway"),
maxspeed: t("elevation.maxspeed"),
smoothness: t("elevation.smoothness"),
tracktype: t("elevation.tracktype"),
cycleway: t("elevation.cycleway"),
bikeroute: t("elevation.bikeroute"),
};
const title = titles[colorMode] ?? t("elevation.profile");
const link = osmLinks[colorMode];
return link ? (
<a href={link} target="_blank" rel="noopener" className="shrink-0 text-xs font-medium text-gray-500 hover:text-blue-600 hover:underline">
{title}
</a>
) : (
<p className="shrink-0 text-xs font-medium text-gray-500">{title}</p>
);
})()}
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-gray-400">
{colorMode === "grade" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />{"<3%"}</span>
@ -393,6 +634,54 @@ 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>}
</>)}
{colorMode === "maxspeed" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />{"≤30"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />{"≤50"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />{"≤70"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />{"≤100"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#991b1b" }} />{"100+"}</span>
</>)}
{colorMode === "smoothness" && smoothnesses.length > 0 && (<>
{[...new Set(smoothnesses)].slice(0, 6).map((s) => (
<span key={s} className="flex items-center gap-0.5">
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: SMOOTHNESS_COLORS[s] ?? DEFAULT_SMOOTHNESS_COLOR }} />
{s}
</span>
))}
{[...new Set(smoothnesses)].length > 6 && <span>+{[...new Set(smoothnesses)].length - 6}</span>}
</>)}
{colorMode === "tracktype" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />grade1</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#84cc16" }} />grade2</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />grade3</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />grade4</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />grade5</span>
</>)}
{colorMode === "cycleway" && cycleways.length > 0 && (<>
{[...new Set(cycleways)].slice(0, 6).map((c) => (
<span key={c} className="flex items-center gap-0.5">
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: CYCLEWAY_COLORS[c] ?? DEFAULT_CYCLEWAY_COLOR }} />
{c}
</span>
))}
{[...new Set(cycleways)].length > 6 && <span>+{[...new Set(cycleways)].length - 6}</span>}
</>)}
{colorMode === "bikeroute" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#7c3aed" }} />International</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#2563eb" }} />National</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#0891b2" }} />Regional</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#059669" }} />Local</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#d4d4d8" }} />None</span>
</>)}
</div>
<select
value={colorMode}
@ -401,8 +690,14 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
>
<option value="plain">{t("colorMode.plain")}</option>
<option value="elevation">{t("colorMode.elevation")}</option>
<option value="surface">{t("colorMode.surface")}</option>
<option value="grade">{t("colorMode.grade")}</option>
<option value="surface">{t("colorMode.surface")}</option>
<option value="highway">{t("colorMode.highway")}</option>
<option value="maxspeed">{t("colorMode.maxspeed")}</option>
<option value="smoothness">{t("colorMode.smoothness")}</option>
<option value="tracktype">{t("colorMode.tracktype")}</option>
<option value="cycleway">{t("colorMode.cycleway")}</option>
<option value="bikeroute">{t("colorMode.bikeroute")}</option>
</select>
</div>
<canvas

View file

@ -375,6 +375,12 @@ 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 [maxspeeds, setMaxspeeds] = useState<string[]>([]);
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
const [tracktypes, setTracktypes] = useState<string[]>([]);
const [cycleways, setCycleways] = useState<string[]>([]);
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const [noGoDrawing, setNoGoDrawing] = useState(false);
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
@ -442,6 +448,48 @@ 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([]);
}
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
if (maxspeedsJson) {
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
} else {
setMaxspeeds([]);
}
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
if (smoothnessesJson) {
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
} else {
setSmoothnesses([]);
}
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
if (tracktypesJson) {
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
} else {
setTracktypes([]);
}
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
if (cyclewaysJson) {
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
} else {
setCycleways([]);
}
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
if (bikeroutesJson) {
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
} else {
setBikeroutes([]);
}
if (modeVal) setColorMode(modeVal);
};
@ -722,6 +770,12 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
coordinates={routeCoordinates}
colorMode={colorMode}
surfaces={surfaces}
highways={highways}
maxspeeds={maxspeeds}
smoothnesses={smoothnesses}
tracktypes={tracktypes}
cycleways={cycleways}
bikeroutes={bikeroutes}
/>
<RouteInteraction
coordinates={routeCoordinates}

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,12 @@ 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")
maxspeeds: string[]; // speed limit per coordinate point (e.g. "30", "50", "none")
smoothnesses: string[]; // smoothness per coordinate point (e.g. "good", "bad")
tracktypes: string[]; // track type per coordinate point (e.g. "grade1", "grade5")
cycleways: string[]; // cycleway type per coordinate point (e.g. "track", "lane")
bikeroutes: string[]; // bicycle route network per coordinate point (e.g. "icn", "lcn")
totalLength: number;
totalAscend: number;
totalTime: number;
@ -94,6 +100,12 @@ interface GeoJsonCollection {
export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): EnrichedRoute {
const allCoords: [number, number, number][] = [];
const allSurfaces: string[] = [];
const allHighways: string[] = [];
const allMaxspeeds: string[] = [];
const allSmoothnesses: string[] = [];
const allTracktypes: string[] = [];
const allCycleways: string[] = [];
const allBikeroutes: string[] = [];
const segmentBoundaries: number[] = [];
let totalLength = 0;
let totalAscend = 0;
@ -108,15 +120,21 @@ 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");
allMaxspeeds.push(wayTagData.maxspeeds.get(j) ?? wayTagData.maxspeeds.get(j - 1) ?? "unknown");
allSmoothnesses.push(wayTagData.smoothnesses.get(j) ?? wayTagData.smoothnesses.get(j - 1) ?? "unknown");
allTracktypes.push(wayTagData.tracktypes.get(j) ?? wayTagData.tracktypes.get(j - 1) ?? "unknown");
allCycleways.push(wayTagData.cycleways.get(j) ?? wayTagData.cycleways.get(j - 1) ?? "unknown");
allBikeroutes.push(wayTagData.bikeroutes.get(j) ?? wayTagData.bikeroutes.get(j - 1) ?? "none");
}
// Accumulate stats
@ -149,6 +167,12 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
coordinates: allCoords,
segmentBoundaries,
surfaces: allSurfaces,
highways: allHighways,
maxspeeds: allMaxspeeds,
smoothnesses: allSmoothnesses,
tracktypes: allTracktypes,
cycleways: allCycleways,
bikeroutes: allBikeroutes,
totalLength,
totalAscend,
totalTime,
@ -156,27 +180,92 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
};
}
interface WayTagData {
surfaces: Map<number, string>;
highways: Map<number, string>;
maxspeeds: Map<number, string>;
smoothnesses: Map<number, string>;
tracktypes: Map<number, string>;
cycleways: Map<number, string>;
bikeroutes: 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 maxspeeds = new Map<number, string>();
const smoothnesses = new Map<number, string>();
const tracktypes = new Map<number, string>();
const cycleways = new Map<number, string>();
const bikeroutes = 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, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
const headers = messages[0]!;
const wayTagsIdx = headers.indexOf("WayTags");
if (wayTagsIdx === -1) return result;
if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
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");
// Extract maxspeed with direction handling
const reversedirection = /reversedirection=yes/.test(tags);
let speed: string | null = null;
if (!reversedirection) {
const fwd = tags.match(/maxspeed:forward=(\S+)/);
if (fwd) speed = fwd[1]!;
} else {
const bwd = tags.match(/maxspeed:backward=(\S+)/);
if (bwd) speed = bwd[1]!;
}
if (!speed) {
const plain = tags.match(/maxspeed=(\S+)/);
speed = plain ? plain[1]! : "unknown";
}
maxspeeds.set(i - 1, speed);
// Extract smoothness
const smoothnessMatch = tags.match(/smoothness=(\S+)/);
smoothnesses.set(i - 1, smoothnessMatch ? smoothnessMatch[1]! : "unknown");
// Extract tracktype
const tracktypeMatch = tags.match(/tracktype=(\S+)/);
tracktypes.set(i - 1, tracktypeMatch ? tracktypeMatch[1]! : "unknown");
// Extract cycleway with direction handling
let cycleway: string | null = null;
if (!reversedirection) {
const right = tags.match(/cycleway:right=(\S+)/);
if (right) cycleway = right[1]!;
} else {
const left = tags.match(/cycleway:left=(\S+)/);
if (left) cycleway = left[1]!;
}
if (!cycleway) {
const bare = tags.match(/cycleway=(\S+)/);
cycleway = bare ? bare[1]! : "unknown";
}
cycleways.set(i - 1, cycleway);
// Extract bicycle route network (priority: icn > ncn > rcn > lcn)
let bikeroute = "none";
if (/route_bicycle_icn=yes/.test(tags)) bikeroute = "icn";
else if (/route_bicycle_ncn=yes/.test(tags)) bikeroute = "ncn";
else if (/route_bicycle_rcn=yes/.test(tags)) bikeroute = "rcn";
else if (/route_bicycle_lcn=yes/.test(tags)) bikeroute = "lcn";
bikeroutes.set(i - 1, bikeroute);
}
return result;
return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
}
/**

View file

@ -126,6 +126,24 @@ 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));
}
if (enriched.maxspeeds?.length) {
yjs.routeData.set("maxspeeds", JSON.stringify(enriched.maxspeeds));
}
if (enriched.smoothnesses?.length) {
yjs.routeData.set("smoothnesses", JSON.stringify(enriched.smoothnesses));
}
if (enriched.tracktypes?.length) {
yjs.routeData.set("tracktypes", JSON.stringify(enriched.tracktypes));
}
if (enriched.cycleways?.length) {
yjs.routeData.set("cycleways", JSON.stringify(enriched.cycleways));
}
if (enriched.bikeroutes?.length) {
yjs.routeData.set("bikeroutes", JSON.stringify(enriched.bikeroutes));
}
});
} catch {
setRouteError("failed");

View file

@ -20,6 +20,17 @@ RUN addgroup --system app && adduser --system --ingroup app app \
RUN cp -r profiles2/* /data/profiles/ 2>/dev/null || true \
&& mv brouter-*-all.jar brouter.jar
# Patch profiles to include extra tags in WayTags output for visualization
# Each tag needs its own assign statement to appear in tiledesc WayTags
RUN for f in /data/profiles/*.brf; do \
if grep -q "dummyUsage" "$f"; then \
grep -q "maxspeed" "$f" || sed -i '/assign dummyUsage/a assign dummyUsage2 = maxspeed=' "$f"; \
grep -q "tracktype" "$f" || sed -i '/assign dummyUsage/a assign dummyUsage3 = tracktype=' "$f"; \
elif grep -q "context:node" "$f"; then \
sed -i '/---context:node/i assign dummyUsage2 = maxspeed=\nassign dummyUsage3 = tracktype=' "$f"; \
fi; \
done
USER app
EXPOSE 17777

View file

@ -32,7 +32,34 @@ const MOCK_ROUTE_3WP = {
segmentBoundaries: [0, 4],
totalLength: 3200,
totalAscend: 8,
surfaces: [],
surfaces: [
"asphalt", "asphalt", "cobblestone", "cobblestone", "gravel",
"gravel", "asphalt", "asphalt", "asphalt", "asphalt",
],
highways: [
"residential", "residential", "cycleway", "cycleway", "path",
"path", "tertiary", "tertiary", "residential", "residential",
],
maxspeeds: [
"30", "30", "unknown", "unknown", "unknown",
"unknown", "50", "50", "30", "30",
],
smoothnesses: [
"good", "good", "intermediate", "intermediate", "bad",
"bad", "good", "good", "excellent", "excellent",
],
tracktypes: [
"unknown", "unknown", "unknown", "unknown", "grade3",
"grade3", "unknown", "unknown", "unknown", "unknown",
],
cycleways: [
"lane", "lane", "track", "track", "unknown",
"unknown", "shared_lane", "shared_lane", "lane", "lane",
],
bikeroutes: [
"rcn", "rcn", "lcn", "lcn", "none",
"none", "ncn", "ncn", "rcn", "rcn",
],
};
/**
@ -63,7 +90,27 @@ const MOCK_ROUTE_2WP = {
segmentBoundaries: [0],
totalLength: 3800,
totalAscend: 6,
surfaces: [],
surfaces: [
"asphalt", "asphalt", "gravel", "gravel", "asphalt", "asphalt",
],
highways: [
"secondary", "secondary", "cycleway", "cycleway", "residential", "residential",
],
maxspeeds: [
"50", "50", "unknown", "unknown", "30", "30",
],
smoothnesses: [
"good", "good", "intermediate", "bad", "good", "good",
],
tracktypes: [
"unknown", "unknown", "grade2", "grade4", "unknown", "unknown",
],
cycleways: [
"lane", "lane", "track", "no", "shared_lane", "shared_lane",
],
bikeroutes: [
"ncn", "ncn", "rcn", "none", "lcn", "lcn",
],
};
/**

View file

@ -138,8 +138,9 @@ test.describe("Planner", () => {
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
// Wait for elevation chart to render with the color mode selector
const select = page.locator("select").last();
await expect(select).toBeVisible({ timeout: 10000 });
await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 });
const select = page.locator("select", { has: page.locator("option[value='highway']") });
await expect(select).toBeVisible({ timeout: 5000 });
await expect(select).toHaveValue("plain");
// Switch to elevation
@ -350,6 +351,72 @@ test.describe("Planner", () => {
expect(hillshadingRequests.length).toBeGreaterThan(0);
});
test("road type color mode renders chart and shows legend", async ({ page, request }) => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
await mockBRouter(page);
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 });
// Wait for the elevation chart to render, then find the color mode select (has a "highway" option)
await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 });
const select = page.locator("select", { has: page.locator("option[value='highway']") });
await expect(select).toBeVisible({ timeout: 5000 });
// Switch to highway (road type) mode
await select.selectOption("highway");
await expect(select).toHaveValue("highway");
// Chart title should show "Road Type Profile"
await expect(page.getByText("Road Type Profile")).toBeVisible({ timeout: 5000 });
// Legend should show highway types from mock data
await expect(page.getByText("secondary")).toBeVisible({ timeout: 5000 });
await expect(page.getByText("residential")).toBeVisible({ timeout: 5000 });
});
test("road type hover label shows highway type", async ({ page, request }) => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
await mockBRouter(page);
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 });
// Switch to highway mode
await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 });
const select = page.locator("select", { has: page.locator("option[value='highway']") });
await expect(select).toBeVisible({ timeout: 5000 });
await select.selectOption("highway");
// Hover the canvas to trigger the hover label
const canvas = page.locator("canvas");
await expect(canvas).toBeVisible({ timeout: 5000 });
const box = await canvas.boundingBox();
if (box) {
// Hover near the middle of the chart
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
}
// The hover label is drawn on canvas — we can't assert text directly,
// but we verify no errors occurred and the chart re-renders on hover.
// The canvas should still be visible (no crash).
await expect(canvas).toBeVisible();
});
test("enable POI category shows markers on map", async ({ page, request }) => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-11

View file

@ -0,0 +1,61 @@
## Context
The Planner's route visualization supports four color modes: plain, elevation, surface, and grade. These modes color both the map polyline (`ColoredRoute.tsx`) and the elevation chart (`ElevationChart.tsx`), synced via a `colorMode` key in Yjs `routeData`.
Surface data is already extracted from BRouter's tiledesc messages by parsing `surface=*` from the `WayTags` column. The same `WayTags` column contains `highway=*` tags (e.g., `highway=residential`, `highway=cycleway`, `highway=track`) which classify the road type — but this data is currently discarded.
The data pipeline is: BRouter response → `extractSurfacesFromMessages()``EnrichedRoute.surfaces[]` → Yjs `routeData.surfaces` → consumed by chart and map. Road type will follow the same path.
## Goals / Non-Goals
**Goals:**
- Add a "Road Type" color mode that colors the route by OSM highway classification
- Follow the exact same patterns as the existing surface color mode (extraction, storage, rendering)
- Provide a meaningful color palette that distinguishes road categories at a glance
- Support both EN and DE translations
**Non-Goals:**
- Custom color palettes or user-configurable road type colors
- Filtering or hiding certain road types from the route
- Road type data in the Journal app (Planner-only for now)
- Aggregated road type statistics (e.g., "42% cycleway") — can be added later
## Decisions
### 1. Extract highway tags alongside surface tags
**Decision**: Extend `extractSurfacesFromMessages` to also extract `highway=*` tags, returning both in a combined result. Add a `highways: string[]` field to `EnrichedRoute`.
**Rationale**: The WayTags column already contains both tags. Extracting them in the same pass avoids iterating the messages twice. Keeping `surfaces` and `highways` as separate parallel arrays maintains backward compatibility and matches the existing pattern.
**Alternative considered**: A single `extractTagsFromMessages` returning a map of tag→values. Rejected because it would change the surface extraction API for no benefit.
### 2. Color palette grouped by road category
**Decision**: Group OSM highway values into intuitive color families:
- **Major roads** (motorway, trunk, primary): reds/oranges — signals caution for cyclists
- **Urban roads** (secondary, tertiary, residential, unclassified): grays/blues — neutral
- **Paths & tracks** (cycleway, path, footway, track, bridleway): greens — generally preferred
- **Service & other** (service, pedestrian, steps, living_street): muted tones
**Rationale**: Cyclists typically want to see at a glance where their route uses dedicated cycling infrastructure vs. shared roads. The red-for-major, green-for-paths scheme makes this immediately visible.
### 3. Extend ColorMode union type
**Decision**: Add `"highway"` to the `ColorMode` type union in `ColoredRoute.tsx`. Use `"highway"` as the internal value (matching the OSM tag name) while displaying "Road Type" / "Straßentyp" to users.
**Rationale**: Internal names match OSM conventions. User-facing labels use i18n and can be friendlier.
### 4. Store highway data in Yjs like surfaces
**Decision**: Store as `yjs.routeData.set("highways", JSON.stringify(highways))`, following the exact pattern used for surfaces.
**Rationale**: Consistency. All per-point route metadata follows this pattern. No schema changes needed.
## Risks / Trade-offs
**[Risk] Missing highway tags in some BRouter profiles** → Some BRouter routing profiles may not include `highway` in WayTags. Mitigation: fall back to `"unknown"` (same as surface mode) and display in a neutral default color.
**[Risk] Too many highway values in the legend** → OSM has dozens of highway values. Mitigation: Show at most 6 in the inline legend (same cap as surface mode), with a "+N" overflow indicator.
**[Trade-off] `highway` internal name vs. `road-type` user-facing name** → Slight naming mismatch, but keeps code aligned with OSM terminology which is the standard data source.

View file

@ -0,0 +1,30 @@
## Why
The Planner already visualizes surface type, grade, and elevation along a route, but there is no way to see the **road type** (highway classification). Cyclists and hikers care whether their route follows a motorway-class road, a residential street, a cycle path, or a forest track — this affects comfort, safety, and legality. BRouter already returns `highway=*` tags in its tiledesc messages alongside surface data, so the information is available but unused.
## What Changes
- Add a new "Road Type" color mode to the route visualization (map polyline + elevation chart)
- Extract `highway=*` tags from BRouter tiledesc messages alongside existing `surface=*` extraction
- Pass road type data through the routing pipeline (BRouter → Yjs → chart/map)
- Add a road type color palette and legend
- Show road type name on chart hover
- Add i18n strings (EN + DE) for the new mode and chart label
## Capabilities
### New Capabilities
- `road-type-coloring`: Road type color mode for route visualization — extracting highway tags from BRouter, coloring the map polyline and elevation chart by road classification, with legend and hover info
### Modified Capabilities
- `route-coloring`: Add "Road Type" as a new color mode option in the color mode toggle, legend display, and chart hover behavior
## Impact
- `apps/planner/app/lib/brouter.ts` — extract `highway=*` tags, add `highways` field to `EnrichedRoute`
- `apps/planner/app/lib/use-routing.ts` — store highway data in Yjs routeData
- `apps/planner/app/components/ColoredRoute.tsx` — extend `ColorMode` type, add highway coloring logic
- `apps/planner/app/components/ElevationChart.tsx` — add highway chart rendering, legend, hover label
- `packages/i18n/src/locales/en.ts` + `de.ts` — new i18n keys
- `e2e/fixtures/brouter-mock.ts` — add highway data to mock responses
- E2E tests — cover the new color mode

View file

@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: Highway tag extraction from BRouter
The routing pipeline SHALL extract `highway=*` tags from BRouter tiledesc messages and include them in the enriched route data as a per-point `highways` array.
#### Scenario: Highway tags present in BRouter response
- **WHEN** BRouter returns a route with tiledesc messages containing `highway=*` in the WayTags column
- **THEN** the `EnrichedRoute` SHALL include a `highways` string array with one entry per coordinate point
#### Scenario: Highway tags missing from BRouter response
- **WHEN** BRouter returns a route without `highway=*` tags in WayTags
- **THEN** each entry in the `highways` array SHALL be `"unknown"`
#### Scenario: Highway data stored in Yjs
- **WHEN** a route is computed and enriched route data is received
- **THEN** the highway array SHALL be stored in Yjs `routeData` as a JSON-serialized string under the key `"highways"`
### Requirement: Road type color palette
The Planner SHALL define a color mapping for OSM highway classifications, grouped by road category.
#### Scenario: Major roads colored with warm tones
- **WHEN** a route segment has highway type `motorway`, `trunk`, or `primary`
- **THEN** the segment SHALL be colored in red/orange tones
#### Scenario: Urban roads colored with neutral tones
- **WHEN** a route segment has highway type `secondary`, `tertiary`, `residential`, or `unclassified`
- **THEN** the segment SHALL be colored in gray/blue tones
#### Scenario: Paths and cycling infrastructure colored with green tones
- **WHEN** a route segment has highway type `cycleway`, `path`, `footway`, `track`, or `bridleway`
- **THEN** the segment SHALL be colored in green tones
#### Scenario: Unknown highway type
- **WHEN** a route segment has an unrecognized or missing highway value
- **THEN** the segment SHALL be colored with a neutral default color
### Requirement: Road type map polyline coloring
The Planner map SHALL color the route polyline by highway classification when road type mode is active.
#### Scenario: Road type coloring on map
- **WHEN** the color mode is set to "highway"
- **THEN** the route polyline on the map SHALL be colored segment-by-segment using the road type color palette
#### Scenario: Fallback when highway data unavailable
- **WHEN** the color mode is set to "highway" but no highway data is available
- **THEN** the route SHALL fall back to plain color mode
### Requirement: Road type elevation chart coloring
The elevation chart SHALL color segments by highway classification when road type mode is active.
#### Scenario: Road type chart rendering
- **WHEN** the color mode is set to "highway"
- **THEN** the elevation chart line and fill segments SHALL be colored using the road type color palette, matching the map polyline
### Requirement: Road type legend
The elevation chart SHALL display a legend for road type mode showing the highway types present in the route.
#### Scenario: Road type legend display
- **WHEN** the color mode is "highway" and highway data is available
- **THEN** a legend SHALL show colored swatches with highway type labels for the types present in the current route, up to 6 entries with a "+N" overflow indicator
### Requirement: Road type hover information
The elevation chart hover label SHALL include the highway type when in road type mode.
#### Scenario: Road type hover label
- **WHEN** hovering the elevation chart in "highway" mode
- **THEN** the label SHALL show elevation, distance, and highway type name (e.g., "340m · 12.3km · cycleway")
### Requirement: Road type i18n
All user-facing strings for the road type color mode SHALL be translated in English and German.
#### Scenario: English labels
- **WHEN** the app language is English
- **THEN** the color mode dropdown SHALL show "Road Type" and the chart title SHALL show "Road Type Profile"
#### Scenario: German labels
- **WHEN** the app language is German
- **THEN** the color mode dropdown SHALL show "Straßentyp" and the chart title SHALL show "Straßentypenprofil"

View file

@ -0,0 +1,81 @@
## MODIFIED 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
#### 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 from BRouter tiledesc
- **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
#### Scenario: Grade color mode
- **WHEN** a user selects the "Grade" color mode
- **THEN** the route polyline is colored by steepness: green (<3%), yellow (<6%), orange (<10%), red (<15%), dark red (15%+)
#### Scenario: Road type color mode
- **WHEN** a user selects the "Road Type" color mode and highway data is available from BRouter tiledesc
- **THEN** the route polyline is colored by OSM highway classification using the road type color palette
#### Scenario: Road type data unavailable
- **WHEN** a user selects "Road Type" color mode but BRouter did not return highway data
- **THEN** the route falls back to plain color mode
### 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 select dropdown is visible inline with the elevation chart title
#### Scenario: Road type option in toggle
- **WHEN** the color mode dropdown is displayed
- **THEN** it SHALL include a "Road Type" option alongside Plain, Elevation, Surface, and Grade
#### 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
### Requirement: Color legends
The elevation chart SHALL display a legend matching the active color mode.
#### Scenario: Grade legend
- **WHEN** the color mode is "Grade"
- **THEN** the legend shows colored swatches with percentage thresholds (<3%, <6%, <10%, <15%, 15%+)
#### Scenario: Elevation legend
- **WHEN** the color mode is "Elevation"
- **THEN** the legend shows a gradient bar with the route's minimum and maximum elevation in meters
#### Scenario: Surface legend
- **WHEN** the color mode is "Surface"
- **THEN** the legend shows the surface types present in the route with colored swatches
#### Scenario: Road type legend
- **WHEN** the color mode is "Road Type"
- **THEN** the legend shows the highway types present in the route with colored swatches, up to 6 entries
### Requirement: Contextual hover information
The elevation chart hover label SHALL show mode-specific information.
#### Scenario: Grade hover
- **WHEN** hovering the chart in "Grade" mode
- **THEN** the label shows elevation, distance, and grade percentage (e.g. "340m · 12.3km · +4.2%")
#### Scenario: Surface hover
- **WHEN** hovering the chart in "Surface" mode
- **THEN** the label shows elevation, distance, and surface type name (e.g. "340m · 12.3km · asphalt")
#### Scenario: Road type hover
- **WHEN** hovering the chart in "Road Type" mode
- **THEN** the label shows elevation, distance, and highway type name (e.g. "340m · 12.3km · cycleway")

View file

@ -0,0 +1,41 @@
## 1. Data Extraction & Pipeline
- [x] 1.1 Extend `extractSurfacesFromMessages` in `brouter.ts` to also extract `highway=*` tags, returning a `Map<number, string>` for highways
- [x] 1.2 Add `highways: string[]` field to `EnrichedRoute` interface in `brouter.ts`
- [x] 1.3 Populate the `highways` array in `mergeGeoJsonSegments` using the extracted highway tags
- [x] 1.4 Store highway data in Yjs `routeData` in `use-routing.ts` (same pattern as surfaces)
## 2. Color Palette & Types
- [x] 2.1 Extend `ColorMode` type in `ColoredRoute.tsx` to include `"highway"`
- [x] 2.2 Define `HIGHWAY_COLORS` mapping and `DEFAULT_HIGHWAY_COLOR` in `ColoredRoute.tsx` (reds for major roads, grays/blues for urban, greens for paths/cycling)
- [x] 2.3 Export `HIGHWAY_COLORS` and `DEFAULT_HIGHWAY_COLOR` for use in the elevation chart
## 3. Map Polyline Coloring
- [x] 3.1 Add highway coloring branch in `ColoredRoute` component — color segments by highway type from a `highways` prop
- [x] 3.2 Pass `highways` data from `PlannerMap.tsx` to `ColoredRoute`
## 4. Elevation Chart
- [x] 4.1 Read `highways` from Yjs `routeData` in `ElevationChart.tsx` (same pattern as surfaces)
- [x] 4.2 Add highway-colored segment rendering in `drawChart` (fill + line, matching surface pattern)
- [x] 4.3 Add road type legend display when `colorMode === "highway"` (colored swatches, max 6 with overflow)
- [x] 4.4 Add highway type name to hover label when `colorMode === "highway"`
- [x] 4.5 Update chart title to show "Road Type Profile" when in highway mode
## 5. UI & Color Mode Toggle
- [x] 5.1 Add "Road Type" / "highway" option to the color mode `<select>` dropdown in `ElevationChart.tsx`
## 6. Internationalization
- [x] 6.1 Add English i18n keys: `colorMode.highway` ("Road Type"), `elevation.highway` ("Road Type Profile")
- [x] 6.2 Add German i18n keys: `colorMode.highway` ("Straßentyp"), `elevation.highway` ("Straßentypenprofil")
## 7. Testing
- [x] 7.1 Add `highways` data to BRouter mock fixtures in `e2e/fixtures/brouter-mock.ts`
- [x] 7.2 Add unit tests for highway tag extraction in `brouter.ts`
- [x] 7.3 Add E2E test: switch to road type color mode and verify chart renders
- [x] 7.4 Add E2E test: verify road type hover label shows highway type name

View file

@ -0,0 +1,82 @@
## Purpose
Road type visualization for route planning. Extracts OSM highway classification from BRouter tiledesc data and provides a color mode that shows road type on both the map polyline and elevation chart.
## Requirements
### Requirement: Highway tag extraction from BRouter
The routing pipeline SHALL extract `highway=*` tags from BRouter tiledesc messages and include them in the enriched route data as a per-point `highways` array.
#### Scenario: Highway tags present in BRouter response
- **WHEN** BRouter returns a route with tiledesc messages containing `highway=*` in the WayTags column
- **THEN** the `EnrichedRoute` SHALL include a `highways` string array with one entry per coordinate point
#### Scenario: Highway tags missing from BRouter response
- **WHEN** BRouter returns a route without `highway=*` tags in WayTags
- **THEN** each entry in the `highways` array SHALL be `"unknown"`
#### Scenario: Highway data stored in Yjs
- **WHEN** a route is computed and enriched route data is received
- **THEN** the highway array SHALL be stored in Yjs `routeData` as a JSON-serialized string under the key `"highways"`
### Requirement: Road type color palette
The Planner SHALL define a color mapping for OSM highway classifications, grouped by road category.
#### Scenario: Major roads colored with warm tones
- **WHEN** a route segment has highway type `motorway`, `trunk`, or `primary`
- **THEN** the segment SHALL be colored in red/orange tones
#### Scenario: Urban roads colored with neutral tones
- **WHEN** a route segment has highway type `secondary`, `tertiary`, `residential`, or `unclassified`
- **THEN** the segment SHALL be colored in gray/blue tones
#### Scenario: Paths and cycling infrastructure colored with green tones
- **WHEN** a route segment has highway type `cycleway`, `path`, `footway`, `track`, or `bridleway`
- **THEN** the segment SHALL be colored in green tones
#### Scenario: Unknown highway type
- **WHEN** a route segment has an unrecognized or missing highway value
- **THEN** the segment SHALL be colored with a neutral default color
### Requirement: Road type map polyline coloring
The Planner map SHALL color the route polyline by highway classification when road type mode is active.
#### Scenario: Road type coloring on map
- **WHEN** the color mode is set to "highway"
- **THEN** the route polyline on the map SHALL be colored segment-by-segment using the road type color palette
#### Scenario: Fallback when highway data unavailable
- **WHEN** the color mode is set to "highway" but no highway data is available
- **THEN** the route SHALL fall back to plain color mode
### Requirement: Road type elevation chart coloring
The elevation chart SHALL color segments by highway classification when road type mode is active.
#### Scenario: Road type chart rendering
- **WHEN** the color mode is set to "highway"
- **THEN** the elevation chart line and fill segments SHALL be colored using the road type color palette, matching the map polyline
### Requirement: Road type legend
The elevation chart SHALL display a legend for road type mode showing the highway types present in the route.
#### Scenario: Road type legend display
- **WHEN** the color mode is "highway" and highway data is available
- **THEN** a legend SHALL show colored swatches with highway type labels for the types present in the current route, up to 6 entries with a "+N" overflow indicator
### Requirement: Road type hover information
The elevation chart hover label SHALL include the highway type when in road type mode.
#### Scenario: Road type hover label
- **WHEN** hovering the elevation chart in "highway" mode
- **THEN** the label SHALL show elevation, distance, and highway type name (e.g., "340m · 12.3km · cycleway")
### Requirement: Road type i18n
All user-facing strings for the road type color mode SHALL be translated in English and German.
#### Scenario: English labels
- **WHEN** the app language is English
- **THEN** the color mode dropdown SHALL show "Road Type" and the chart title SHALL show "Road Type Profile"
#### Scenario: German labels
- **WHEN** the app language is German
- **THEN** the color mode dropdown SHALL show "Straßentyp" and the chart title SHALL show "Straßentypenprofil"

View file

@ -1,6 +1,6 @@
## Purpose
Multi-mode route visualization (plain, elevation gradient, surface type, grade) with session-synced color mode selection. Both the route polyline on the map and the elevation chart reflect the selected color mode.
Multi-mode route visualization (plain, elevation gradient, surface type, grade, road type) with session-synced color mode selection. Both the route polyline on the map and the elevation chart reflect the selected color mode.
## Requirements
@ -27,6 +27,14 @@ The Planner SHALL support multiple route color modes that visualize per-point da
- **WHEN** a user selects the "Grade" color mode
- **THEN** the route polyline is colored by steepness: green (<3%), yellow (<6%), orange (<10%), red (<15%), dark red (15%+)
#### Scenario: Road type color mode
- **WHEN** a user selects the "Road Type" color mode and highway data is available from BRouter tiledesc
- **THEN** the route polyline is colored by OSM highway classification using the road type color palette
#### Scenario: Road type data unavailable
- **WHEN** a user selects "Road Type" color mode but BRouter did not return highway data
- **THEN** the route falls back to plain color mode
### Requirement: Color mode toggle
The Planner SHALL provide a UI control to switch between route color modes.
@ -34,6 +42,10 @@ The Planner SHALL provide a UI control to switch between route color modes.
- **WHEN** a route is displayed
- **THEN** a color mode select dropdown is visible inline with the elevation chart title
#### Scenario: Road type option in toggle
- **WHEN** the color mode dropdown is displayed
- **THEN** it SHALL include a "Road Type" option alongside Plain, Elevation, Surface, and Grade
#### 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
@ -72,6 +84,10 @@ The elevation chart SHALL display a legend matching the active color mode.
- **WHEN** the color mode is "Surface"
- **THEN** the legend shows the surface types present in the route with colored swatches
#### Scenario: Road type legend
- **WHEN** the color mode is "Road Type"
- **THEN** the legend shows the highway types present in the route with colored swatches, up to 6 entries
### Requirement: Contextual hover information
The elevation chart hover label SHALL show mode-specific information.
@ -82,3 +98,7 @@ The elevation chart hover label SHALL show mode-specific information.
#### Scenario: Surface hover
- **WHEN** hovering the chart in "Surface" mode
- **THEN** the label shows elevation, distance, and surface type name (e.g. "340m · 12.3km · asphalt")
#### Scenario: Road type hover
- **WHEN** hovering the chart in "Road Type" mode
- **THEN** the label shows elevation, distance, and highway type name (e.g. "340m · 12.3km · cycleway")

View file

@ -102,6 +102,12 @@ export default {
elevation: "Höhe",
surface: "Untergrund",
grade: "Steigung",
highway: "Straßentyp",
maxspeed: "Tempolimit",
smoothness: "Fahrbahnqualität",
tracktype: "Wegtyp",
cycleway: "Radweg",
bikeroute: "Radroute",
surfaceLegend: "Farbe nach Straßenbelag",
surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar",
},
@ -111,6 +117,12 @@ export default {
loss: "Höhenmeter abwärts",
profile: "Höhenprofil",
grade: "Steigungsprofil",
highway: "Straßentypenprofil",
maxspeed: "Tempolimitprofil",
smoothness: "Fahrbahnqualitätsprofil",
tracktype: "Wegtypenprofil",
cycleway: "Radwegprofil",
bikeroute: "Radroutenprofil",
low: "Tief",
high: "Hoch",
},

View file

@ -102,6 +102,12 @@ export default {
elevation: "Elevation",
surface: "Surface",
grade: "Grade",
highway: "Road Type",
maxspeed: "Speed Limit",
smoothness: "Smoothness",
tracktype: "Track Type",
cycleway: "Cycleway",
bikeroute: "Bike Route",
surfaceLegend: "Color by road surface type",
surfaceUnavailable: "Surface data not available for this profile",
},
@ -111,6 +117,12 @@ export default {
loss: "Elevation Loss",
profile: "Elevation Profile",
grade: "Grade Profile",
highway: "Road Type Profile",
maxspeed: "Speed Limit Profile",
smoothness: "Smoothness Profile",
tracktype: "Track Type Profile",
cycleway: "Cycleway Profile",
bikeroute: "Bike Route Profile",
low: "Low",
high: "High",
},