Re-add speed limit color mode with BRouter profile patch
The dummyUsage trick needs a separate assign per tag: assign dummyUsage2 = maxspeed= (not appended to the existing smoothness= line) - Dockerfile: Patch all BRouter profiles to include maxspeed in WayTags output via separate assign dummyUsage2 - brouter.ts: Extract maxspeed with direction handling (maxspeed:forward/backward + reversedirection awareness) - ColoredRoute: maxspeed color mode (green ≤30, yellow ≤50, orange ≤70, red ≤100, dark red 100+) - ElevationChart: maxspeed chart rendering, legend, hover "X km/h" - PlannerMap: maxspeeds state + Yjs read + prop passing - i18n: EN "Speed Limit" / DE "Tempolimit" - Mock fixtures: maxspeed data for E2E tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cec613d989
commit
61d34821df
9 changed files with 139 additions and 9 deletions
|
|
@ -2,13 +2,14 @@ import { useMemo } from "react";
|
|||
import { Polyline } from "react-leaflet";
|
||||
import type L from "leaflet";
|
||||
|
||||
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway";
|
||||
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed";
|
||||
|
||||
interface ColoredRouteProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
colorMode: ColorMode;
|
||||
surfaces?: string[];
|
||||
highways?: string[];
|
||||
maxspeeds?: string[];
|
||||
}
|
||||
|
||||
const SURFACE_COLORS: Record<string, string> = {
|
||||
|
|
@ -82,7 +83,20 @@ export function elevationColor(t: number): string {
|
|||
return `rgb(255, ${g}, 50)`;
|
||||
}
|
||||
|
||||
export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: 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 }: ColoredRouteProps) {
|
||||
const segments = useMemo(() => {
|
||||
if (colorMode === "plain" || coordinates.length < 2) {
|
||||
return null;
|
||||
|
|
@ -147,6 +161,24 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: Col
|
|||
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;
|
||||
}
|
||||
|
||||
// surface mode
|
||||
if (!surfaces || surfaces.length < coordinates.length) return null;
|
||||
|
||||
|
|
@ -162,7 +194,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: Col
|
|||
});
|
||||
}
|
||||
return result;
|
||||
}, [coordinates, colorMode, surfaces, highways]);
|
||||
}, [coordinates, colorMode, surfaces, highways, maxspeeds]);
|
||||
|
||||
const plainPositions = useMemo(
|
||||
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute";
|
||||
import { elevationColor, maxspeedColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute";
|
||||
|
||||
function gradeColor(grade: number): string {
|
||||
const absGrade = Math.abs(grade);
|
||||
|
|
@ -76,6 +76,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
|
|
@ -102,6 +103,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
} else {
|
||||
setHighways([]);
|
||||
}
|
||||
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
|
||||
if (maxspeedsJson) {
|
||||
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
|
||||
} else {
|
||||
setMaxspeeds([]);
|
||||
}
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
|
|
@ -234,6 +241,30 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
ctx.fillStyle = color + "40";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (colorMode === "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));
|
||||
|
|
@ -339,12 +370,16 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
if (colorMode === "highway" && highways[highlightIdx]) {
|
||||
label += ` · ${highways[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) {
|
||||
const s = maxspeeds[highlightIdx]!;
|
||||
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
|
||||
}
|
||||
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, days, t],
|
||||
[points, colorMode, surfaces, highways, maxspeeds, days, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -403,7 +438,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") : t("elevation.profile")}
|
||||
{colorMode === "grade" ? t("elevation.grade") : colorMode === "highway" ? t("elevation.highway") : colorMode === "maxspeed" ? t("elevation.maxspeed") : t("elevation.profile")}
|
||||
</p>
|
||||
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-gray-400">
|
||||
{colorMode === "grade" && (<>
|
||||
|
|
@ -436,6 +471,13 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
))}
|
||||
{[...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>
|
||||
</>)}
|
||||
</div>
|
||||
<select
|
||||
value={colorMode}
|
||||
|
|
@ -447,6 +489,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
<option value="surface">{t("colorMode.surface")}</option>
|
||||
<option value="grade">{t("colorMode.grade")}</option>
|
||||
<option value="highway">{t("colorMode.highway")}</option>
|
||||
<option value="maxspeed">{t("colorMode.maxspeed")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
|
|
@ -450,6 +451,13 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
setHighways([]);
|
||||
}
|
||||
|
||||
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
|
||||
if (maxspeedsJson) {
|
||||
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
|
||||
} else {
|
||||
setMaxspeeds([]);
|
||||
}
|
||||
|
||||
if (modeVal) setColorMode(modeVal);
|
||||
};
|
||||
|
||||
|
|
@ -731,6 +739,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
colorMode={colorMode}
|
||||
surfaces={surfaces}
|
||||
highways={highways}
|
||||
maxspeeds={maxspeeds}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface EnrichedRoute {
|
|||
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")
|
||||
totalLength: number;
|
||||
totalAscend: number;
|
||||
totalTime: number;
|
||||
|
|
@ -96,6 +97,7 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
|
|||
const allCoords: [number, number, number][] = [];
|
||||
const allSurfaces: string[] = [];
|
||||
const allHighways: string[] = [];
|
||||
const allMaxspeeds: string[] = [];
|
||||
const segmentBoundaries: number[] = [];
|
||||
let totalLength = 0;
|
||||
let totalAscend = 0;
|
||||
|
|
@ -120,6 +122,7 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
|
|||
allCoords.push([c[0]!, c[1]!, c[2] ?? 0]);
|
||||
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");
|
||||
}
|
||||
|
||||
// Accumulate stats
|
||||
|
|
@ -153,6 +156,7 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
|
|||
segmentBoundaries,
|
||||
surfaces: allSurfaces,
|
||||
highways: allHighways,
|
||||
maxspeeds: allMaxspeeds,
|
||||
totalLength,
|
||||
totalAscend,
|
||||
totalTime,
|
||||
|
|
@ -163,6 +167,7 @@ export function mergeGeoJsonSegments(segments: Record<string, unknown>[]): Enric
|
|||
interface WayTagData {
|
||||
surfaces: Map<number, string>;
|
||||
highways: Map<number, string>;
|
||||
maxspeeds: Map<number, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -173,12 +178,13 @@ interface WayTagData {
|
|||
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 messages = properties.messages as string[][] | undefined;
|
||||
if (!messages || messages.length < 2) return { surfaces, highways };
|
||||
if (!messages || messages.length < 2) return { surfaces, highways, maxspeeds };
|
||||
|
||||
const headers = messages[0]!;
|
||||
const wayTagsIdx = headers.indexOf("WayTags");
|
||||
if (wayTagsIdx === -1) return { surfaces, highways };
|
||||
if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds };
|
||||
|
||||
for (let i = 1; i < messages.length; i++) {
|
||||
const row = messages[i]!;
|
||||
|
|
@ -187,8 +193,24 @@ function extractWayTagData(properties: Record<string, unknown>): WayTagData {
|
|||
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);
|
||||
}
|
||||
return { surfaces, highways };
|
||||
return { surfaces, highways, maxspeeds };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ export function useRouting(yjs: YjsState | null) {
|
|||
if (enriched.highways?.length) {
|
||||
yjs.routeData.set("highways", JSON.stringify(enriched.highways));
|
||||
}
|
||||
if (enriched.maxspeeds?.length) {
|
||||
yjs.routeData.set("maxspeeds", JSON.stringify(enriched.maxspeeds));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
setRouteError("failed");
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ 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)
|
||||
# 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"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
USER app
|
||||
EXPOSE 17777
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ const MOCK_ROUTE_3WP = {
|
|||
"residential", "residential", "cycleway", "cycleway", "path",
|
||||
"path", "tertiary", "tertiary", "residential", "residential",
|
||||
],
|
||||
maxspeeds: [
|
||||
"30", "30", "unknown", "unknown", "unknown",
|
||||
"unknown", "50", "50", "30", "30",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +80,9 @@ const MOCK_ROUTE_2WP = {
|
|||
highways: [
|
||||
"secondary", "secondary", "cycleway", "cycleway", "residential", "residential",
|
||||
],
|
||||
maxspeeds: [
|
||||
"50", "50", "unknown", "unknown", "30", "30",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export default {
|
|||
surface: "Untergrund",
|
||||
grade: "Steigung",
|
||||
highway: "Straßentyp",
|
||||
maxspeed: "Tempolimit",
|
||||
surfaceLegend: "Farbe nach Straßenbelag",
|
||||
surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar",
|
||||
},
|
||||
|
|
@ -113,6 +114,7 @@ export default {
|
|||
profile: "Höhenprofil",
|
||||
grade: "Steigungsprofil",
|
||||
highway: "Straßentypenprofil",
|
||||
maxspeed: "Tempolimitprofil",
|
||||
low: "Tief",
|
||||
high: "Hoch",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export default {
|
|||
surface: "Surface",
|
||||
grade: "Grade",
|
||||
highway: "Road Type",
|
||||
maxspeed: "Speed Limit",
|
||||
surfaceLegend: "Color by road surface type",
|
||||
surfaceUnavailable: "Surface data not available for this profile",
|
||||
},
|
||||
|
|
@ -113,6 +114,7 @@ export default {
|
|||
profile: "Elevation Profile",
|
||||
grade: "Grade Profile",
|
||||
highway: "Road Type Profile",
|
||||
maxspeed: "Speed Limit Profile",
|
||||
low: "Low",
|
||||
high: "High",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue