diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index a4b06d7..753429c 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -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 = { @@ -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), diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 0d8edc0..22cdb81 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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("plain"); const [surfaces, setSurfaces] = useState([]); const [highways, setHighways] = useState([]); + const [maxspeeds, setMaxspeeds] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); 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) {

- {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")}

{colorMode === "grade" && (<> @@ -436,6 +471,13 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ))} {[...new Set(highways)].length > 6 && +{[...new Set(highways)].length - 6}} )} + {colorMode === "maxspeed" && (<> + {"≤30"} + {"≤50"} + {"≤70"} + {"≤100"} + {"100+"} + )}
([]); const [surfaces, setSurfaces] = useState([]); const [highways, setHighways] = useState([]); + const [maxspeeds, setMaxspeeds] = useState([]); const [colorMode, setColorMode] = useState("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} /> []): 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[]): 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[]): Enric segmentBoundaries, surfaces: allSurfaces, highways: allHighways, + maxspeeds: allMaxspeeds, totalLength, totalAscend, totalTime, @@ -163,6 +167,7 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric interface WayTagData { surfaces: Map; highways: Map; + maxspeeds: Map; } /** @@ -173,12 +178,13 @@ interface WayTagData { function extractWayTagData(properties: Record): WayTagData { const surfaces = new Map(); const highways = new Map(); + const maxspeeds = new Map(); 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): 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 }; } /** diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index 7466cf3..9ef6108 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -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"); diff --git a/docker/brouter/Dockerfile b/docker/brouter/Dockerfile index ec8f0d8..24d1164 100644 --- a/docker/brouter/Dockerfile +++ b/docker/brouter/Dockerfile @@ -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 diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts index 137658f..8849ab1 100644 --- a/e2e/fixtures/brouter-mock.ts +++ b/e2e/fixtures/brouter-mock.ts @@ -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", + ], }; /** diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f883e08..d823b69 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4c9b8da..dc3546d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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", },