Add smoothness, track type, cycleway, and bike route color modes

Four new route visualization modes extracted from BRouter WayTags:

- Smoothness: excellent (green) → impassable (dark red)
- Track Type: grade1 (green, best) → grade5 (red, worst)
- Cycleway: track/lane/shared_lane/no with direction awareness
- Bike Route: icn/ncn/rcn/lcn priority from route_bicycle_* tags

Each mode includes map polyline coloring, elevation chart coloring,
inline legend, hover label, i18n (EN+DE), and mock fixture data.

Dockerfile patched to also expose tracktype in BRouter WayTags
(smoothness, cycleway, and route_bicycle already present).

Dropdown order: plain, elevation, grade, surface, road type, speed
limit, smoothness, track type, cycleway, bike route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 09:58:25 +02:00
parent 768fa9df44
commit 6ec95216e1
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
9 changed files with 472 additions and 15 deletions

View file

@ -2,7 +2,7 @@ import { useMemo } from "react";
import { Polyline } from "react-leaflet";
import type L from "leaflet";
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed";
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute";
interface ColoredRouteProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
@ -10,6 +10,10 @@ interface ColoredRouteProps {
surfaces?: string[];
highways?: string[];
maxspeeds?: string[];
smoothnesses?: string[];
tracktypes?: string[];
cycleways?: string[];
bikeroutes?: string[];
}
const SURFACE_COLORS: Record<string, string> = {
@ -64,6 +68,51 @@ const HIGHWAY_COLORS: Record<string, string> = {
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: "#dc2626",
ncn: "#f97316",
rcn: "#eab308",
lcn: "#22c55e",
none: "#d4d4d8",
};
const DEFAULT_BIKEROUTE_COLOR = "#d4d4d8";
export function routeGradeColor(grade: number): string {
const absGrade = Math.abs(grade);
if (absGrade < 3) return "#22c55e";
@ -96,7 +145,7 @@ export function maxspeedColor(speed: string): string {
return "#991b1b"; // >100 dark red
}
export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds }: ColoredRouteProps) {
export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) {
const segments = useMemo(() => {
if (colorMode === "plain" || coordinates.length < 2) {
return null;
@ -179,6 +228,78 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxsp
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;
@ -194,7 +315,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxsp
});
}
return result;
}, [coordinates, colorMode, surfaces, highways, maxspeeds]);
}, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]);
const plainPositions = useMemo(
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
@ -235,4 +356,11 @@ export function findSegmentForPoint(
return 0;
}
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_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, maxspeedColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_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);
@ -77,6 +86,10 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
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;
@ -109,6 +122,30 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
} 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();
@ -265,6 +302,102 @@ 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 === "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));
@ -374,12 +507,24 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
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]) {
label += ` · ${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, highways, maxspeeds, days, t],
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
);
useEffect(() => {
@ -438,7 +583,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") : colorMode === "highway" ? t("elevation.highway") : colorMode === "maxspeed" ? t("elevation.maxspeed") : t("elevation.profile")}
{colorMode === "grade" ? t("elevation.grade") : colorMode === "highway" ? t("elevation.highway") : colorMode === "maxspeed" ? t("elevation.maxspeed") : colorMode === "smoothness" ? t("elevation.smoothness") : colorMode === "tracktype" ? t("elevation.tracktype") : colorMode === "cycleway" ? t("elevation.cycleway") : colorMode === "bikeroute" ? t("elevation.bikeroute") : t("elevation.profile")}
</p>
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-gray-400">
{colorMode === "grade" && (<>
@ -478,6 +623,38 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
<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: "#dc2626" }} />icn</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />ncn</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />rcn</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />lcn</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}
@ -490,6 +667,10 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
<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

@ -377,6 +377,10 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
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), []);
@ -458,6 +462,34 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
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);
};
@ -740,6 +772,10 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
surfaces={surfaces}
highways={highways}
maxspeeds={maxspeeds}
smoothnesses={smoothnesses}
tracktypes={tracktypes}
cycleways={cycleways}
bikeroutes={bikeroutes}
/>
<RouteInteraction
coordinates={routeCoordinates}

View file

@ -18,6 +18,10 @@ export interface EnrichedRoute {
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;
@ -98,6 +102,10 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
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;
@ -123,6 +131,10 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
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
@ -157,6 +169,10 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
surfaces: allSurfaces,
highways: allHighways,
maxspeeds: allMaxspeeds,
smoothnesses: allSmoothnesses,
tracktypes: allTracktypes,
cycleways: allCycleways,
bikeroutes: allBikeroutes,
totalLength,
totalAscend,
totalTime,
@ -168,6 +184,10 @@ 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>;
}
/**
@ -179,12 +199,16 @@ 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 { surfaces, highways, maxspeeds };
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 { surfaces, highways, maxspeeds };
if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
for (let i = 1; i < messages.length; i++) {
const row = messages[i]!;
@ -209,8 +233,39 @@ function extractWayTagData(properties: Record<string, unknown>): WayTagData {
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 { surfaces, highways, maxspeeds };
return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes };
}
/**

View file

@ -132,6 +132,18 @@ export function useRouting(yjs: YjsState | null) {
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,13 +20,14 @@ 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 maxspeed in WayTags output (for speed limit visualization)
# 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" && ! grep -q "maxspeed" "$f"; then \
sed -i '/assign dummyUsage/a assign dummyUsage2 = maxspeed=' "$f"; \
elif ! grep -q "maxspeed" "$f" && grep -q "context:node" "$f"; then \
sed -i '/---context:node/i assign dummyUsage2 = maxspeed=' "$f"; \
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

View file

@ -44,6 +44,22 @@ const MOCK_ROUTE_3WP = {
"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",
],
};
/**
@ -83,6 +99,18 @@ const MOCK_ROUTE_2WP = {
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

@ -104,6 +104,10 @@ export default {
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",
},
@ -115,6 +119,10 @@ export default {
grade: "Steigungsprofil",
highway: "Straßentypenprofil",
maxspeed: "Tempolimitprofil",
smoothness: "Fahrbahnqualitätsprofil",
tracktype: "Wegtypenprofil",
cycleway: "Radwegprofil",
bikeroute: "Radroutenprofil",
low: "Tief",
high: "Hoch",
},

View file

@ -104,6 +104,10 @@ export default {
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",
},
@ -115,6 +119,10 @@ export default {
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",
},