From 1e2f6bededb0e9eedcea8e6bbc0ae22e0e588394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 03:41:49 +0200 Subject: [PATCH 1/9] Add road type color mode to route visualization Extract highway=* tags from BRouter tiledesc messages alongside surface data and add a new "Road Type" color mode that colors the route polyline and elevation chart by OSM highway classification (cycleway, residential, path, etc.). Includes color palette, legend, hover labels, i18n (EN+DE), unit tests for tag extraction, and E2E tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ColoredRoute.tsx | 57 ++++++++++++- .../planner/app/components/ElevationChart.tsx | 50 ++++++++++- apps/planner/app/components/PlannerMap.tsx | 9 ++ apps/planner/app/lib/brouter.test.ts | 77 +++++++++++++++++ apps/planner/app/lib/brouter.ts | 34 +++++--- apps/planner/app/lib/use-routing.ts | 3 + e2e/fixtures/brouter-mock.ts | 16 +++- e2e/planner.test.ts | 64 +++++++++++++++ .../2026-04-11-road-type-chart/.openspec.yaml | 2 + .../2026-04-11-road-type-chart/design.md | 61 ++++++++++++++ .../2026-04-11-road-type-chart/proposal.md | 30 +++++++ .../specs/road-type-coloring/spec.md | 78 ++++++++++++++++++ .../specs/route-coloring/spec.md | 81 ++++++++++++++++++ .../2026-04-11-road-type-chart/tasks.md | 41 ++++++++++ openspec/specs/road-type-coloring/spec.md | 82 +++++++++++++++++++ openspec/specs/route-coloring/spec.md | 22 ++++- packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 18 files changed, 690 insertions(+), 21 deletions(-) create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/design.md create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/proposal.md create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/specs/road-type-coloring/spec.md create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/specs/route-coloring/spec.md create mode 100644 openspec/changes/archive/2026-04-11-road-type-chart/tasks.md create mode 100644 openspec/specs/road-type-coloring/spec.md diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index ad000dd..a4b06d7 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -2,12 +2,13 @@ import { useMemo } from "react"; import { Polyline } from "react-leaflet"; import type L from "leaflet"; -export type ColorMode = "plain" | "elevation" | "surface" | "grade"; +export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] colorMode: ColorMode; surfaces?: string[]; + highways?: string[]; } const SURFACE_COLORS: Record = { @@ -32,6 +33,36 @@ const SURFACE_COLORS: Record = { const DEFAULT_SURFACE_COLOR = "#9ca3af"; +const HIGHWAY_COLORS: Record = { + // Major roads — warm tones (caution for cyclists) + motorway: "#dc2626", + motorway_link: "#dc2626", + trunk: "#ea580c", + trunk_link: "#ea580c", + primary: "#f97316", + primary_link: "#f97316", + // Urban roads — neutral tones + secondary: "#6366f1", + secondary_link: "#6366f1", + tertiary: "#818cf8", + tertiary_link: "#818cf8", + residential: "#6b7280", + unclassified: "#9ca3af", + living_street: "#a78bfa", + // Paths & cycling infrastructure — green tones + cycleway: "#16a34a", + path: "#22c55e", + footway: "#4ade80", + track: "#65a30d", + bridleway: "#84cc16", + // Service & other — muted tones + service: "#d4d4d8", + pedestrian: "#c084fc", + steps: "#f472b6", +}; + +const DEFAULT_HIGHWAY_COLOR = "#9ca3af"; + export function routeGradeColor(grade: number): string { const absGrade = Math.abs(grade); if (absGrade < 3) return "#22c55e"; @@ -51,7 +82,7 @@ export function elevationColor(t: number): string { return `rgb(255, ${g}, 50)`; } -export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) { +export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: ColoredRouteProps) { const segments = useMemo(() => { if (colorMode === "plain" || coordinates.length < 2) { return null; @@ -98,6 +129,24 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP return result; } + // highway mode + if (colorMode === "highway") { + if (!highways || highways.length < coordinates.length) return null; + + const result: { positions: L.LatLngExpression[]; color: string }[] = []; + for (let i = 0; i < coordinates.length - 1; i++) { + const highway = highways[i] ?? "unknown"; + result.push({ + positions: [ + [coordinates[i]![1], coordinates[i]![0]], + [coordinates[i + 1]![1], coordinates[i + 1]![0]], + ], + color: HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR, + }); + } + return result; + } + // surface mode if (!surfaces || surfaces.length < coordinates.length) return null; @@ -113,7 +162,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP }); } return result; - }, [coordinates, colorMode, surfaces]); + }, [coordinates, colorMode, surfaces, highways]); const plainPositions = useMemo( () => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression), @@ -154,4 +203,4 @@ export function findSegmentForPoint( return 0; } -export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR }; +export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR }; diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index fe5c9e6..0d8edc0 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, type ColorMode } from "~/components/ColoredRoute"; +import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute"; function gradeColor(grade: number): string { const absGrade = Math.abs(grade); @@ -75,6 +75,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { const [hoverIdx, setHoverIdx] = useState(null); const [colorMode, setColorMode] = useState("plain"); const [surfaces, setSurfaces] = useState([]); + const [highways, setHighways] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = points; @@ -95,6 +96,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { } else { setSurfaces([]); } + const highwaysJson = yjs.routeData.get("highways") as string | undefined; + if (highwaysJson) { + try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); } + } else { + setHighways([]); + } }; yjs.routeData.observe(update); update(); @@ -203,6 +210,30 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ctx.fillStyle = color + "40"; ctx.fill(); + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + } else if (colorMode === "highway" && highways.length >= points.length) { + // Highway-colored segments + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]!; + const p1 = points[i + 1]!; + const highway = highways[i] ?? "unknown"; + const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR; + + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), PADDING.top + chartH); + ctx.lineTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.lineTo(toX(p1.distance), PADDING.top + chartH); + ctx.closePath(); + ctx.fillStyle = color + "40"; + ctx.fill(); + ctx.beginPath(); ctx.moveTo(toX(p0.distance), toY(p0.elevation)); ctx.lineTo(toX(p1.distance), toY(p1.elevation)); @@ -305,12 +336,15 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { if (colorMode === "surface" && surfaces[highlightIdx]) { label += ` · ${surfaces[highlightIdx]}`; } + if (colorMode === "highway" && highways[highlightIdx]) { + label += ` · ${highways[highlightIdx]}`; + } const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8; ctx.textAlign = hx + 8 > w - 80 ? "right" : "left"; ctx.fillText(label, labelX, PADDING.top + 10); } }, - [points, colorMode, surfaces, days, t], + [points, colorMode, surfaces, highways, days, t], ); useEffect(() => { @@ -369,7 +403,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {

- {colorMode === "grade" ? t("elevation.grade") : t("elevation.profile")} + {colorMode === "grade" ? t("elevation.grade") : colorMode === "highway" ? t("elevation.highway") : t("elevation.profile")}

{colorMode === "grade" && (<> @@ -393,6 +427,15 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ))} {[...new Set(surfaces)].length > 6 && +{[...new Set(surfaces)].length - 6}} )} + {colorMode === "highway" && highways.length > 0 && (<> + {[...new Set(highways)].slice(0, 6).map((h) => ( + + + {h} + + ))} + {[...new Set(highways)].length > 6 && +{[...new Set(highways)].length - 6}} + )}
(null); const [segmentBoundaries, setSegmentBoundaries] = useState([]); const [surfaces, setSurfaces] = useState([]); + const [highways, setHighways] = useState([]); const [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); @@ -442,6 +443,13 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted setSurfaces([]); } + const highwaysJson = yjs.routeData.get("highways") as string | undefined; + if (highwaysJson) { + try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); } + } else { + setHighways([]); + } + if (modeVal) setColorMode(modeVal); }; @@ -722,6 +730,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted coordinates={routeCoordinates} colorMode={colorMode} surfaces={surfaces} + highways={highways} /> { }); }); +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 = [ diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index 4e0bfd6..db55297 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -16,6 +16,7 @@ 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") totalLength: number; totalAscend: number; totalTime: number; @@ -94,6 +95,7 @@ interface GeoJsonCollection { export function mergeGeoJsonSegments(segments: Record[]): EnrichedRoute { const allCoords: [number, number, number][] = []; const allSurfaces: string[] = []; + const allHighways: string[] = []; const segmentBoundaries: number[] = []; let totalLength = 0; let totalAscend = 0; @@ -108,15 +110,16 @@ export function mergeGeoJsonSegments(segments: Record[]): 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"); } // Accumulate stats @@ -149,6 +152,7 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric coordinates: allCoords, segmentBoundaries, surfaces: allSurfaces, + highways: allHighways, totalLength, totalAscend, totalTime, @@ -156,27 +160,35 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric }; } +interface WayTagData { + surfaces: Map; + highways: Map; +} + /** - * 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): Map { - const result = new Map(); +function extractWayTagData(properties: Record): WayTagData { + const surfaces = new Map(); + const highways = new Map(); const messages = properties.messages as string[][] | undefined; - if (!messages || messages.length < 2) return result; + if (!messages || messages.length < 2) return { surfaces, highways }; const headers = messages[0]!; const wayTagsIdx = headers.indexOf("WayTags"); - if (wayTagsIdx === -1) return result; + if (wayTagsIdx === -1) return { surfaces, highways }; 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"); } - return result; + return { surfaces, highways }; } /** diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index bac87e1..7466cf3 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -126,6 +126,9 @@ 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)); + } }); } catch { setRouteError("failed"); diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts index 27bb9c8..137658f 100644 --- a/e2e/fixtures/brouter-mock.ts +++ b/e2e/fixtures/brouter-mock.ts @@ -32,7 +32,14 @@ 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", + ], }; /** @@ -63,7 +70,12 @@ 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", + ], }; /** diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 8dbd270..b153c62 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -350,6 +350,70 @@ 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 elevation chart color mode selector + const select = page.locator("select").last(); + await expect(select).toBeVisible({ timeout: 10000 }); + + // 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("cycleway")).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 + const select = page.locator("select").last(); + await expect(select).toBeVisible({ timeout: 10000 }); + 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(); diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/.openspec.yaml b/openspec/changes/archive/2026-04-11-road-type-chart/.openspec.yaml new file mode 100644 index 0000000..11393ea --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-11 diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/design.md b/openspec/changes/archive/2026-04-11-road-type-chart/design.md new file mode 100644 index 0000000..419e083 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/design.md @@ -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. diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/proposal.md b/openspec/changes/archive/2026-04-11-road-type-chart/proposal.md new file mode 100644 index 0000000..03c9b5d --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/specs/road-type-coloring/spec.md b/openspec/changes/archive/2026-04-11-road-type-chart/specs/road-type-coloring/spec.md new file mode 100644 index 0000000..5df0c58 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/specs/road-type-coloring/spec.md @@ -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" diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/specs/route-coloring/spec.md b/openspec/changes/archive/2026-04-11-road-type-chart/specs/route-coloring/spec.md new file mode 100644 index 0000000..753cad1 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/specs/route-coloring/spec.md @@ -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") diff --git a/openspec/changes/archive/2026-04-11-road-type-chart/tasks.md b/openspec/changes/archive/2026-04-11-road-type-chart/tasks.md new file mode 100644 index 0000000..0f5d81f --- /dev/null +++ b/openspec/changes/archive/2026-04-11-road-type-chart/tasks.md @@ -0,0 +1,41 @@ +## 1. Data Extraction & Pipeline + +- [x] 1.1 Extend `extractSurfacesFromMessages` in `brouter.ts` to also extract `highway=*` tags, returning a `Map` 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 `{t("colorMode.surface")} +
([]); 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,17 @@ 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"); + // Handle maxspeed variants: maxspeed:forward, maxspeed:backward, maxspeed + const hasReverse = tags.includes("reversedirection=yes"); + const forwardMatch = tags.match(/maxspeed:forward=(\S+)/); + const backwardMatch = tags.match(/maxspeed:backward=(\S+)/); + const plainMatch = tags.match(/(? { 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 @@ -364,9 +365,10 @@ test.describe("Planner", () => { await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - // Wait for elevation chart color mode selector - const select = page.locator("select").last(); - await expect(select).toBeVisible({ timeout: 10000 }); + // 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"); @@ -395,8 +397,9 @@ test.describe("Planner", () => { await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Switch to highway mode - 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 select.selectOption("highway"); // Hover the canvas to trigger the hover label 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", }, From cec613d989f14cc235b9a688f3ac76da16d1d3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 09:06:30 +0200 Subject: [PATCH 3/9] Fix E2E select locator, remove maxspeed mode (data unavailable) E2E fixes: - Color mode select locator uses option[value='highway'] filter instead of fragile page.locator("select").last() Removed maxspeed color mode: - BRouter does not expose maxspeed in WayTags output despite the tag existing in its lookup table. The dummyUsage profile trick only affects cost calculation, not tiledesc output. - Can revisit if BRouter adds maxspeed to WayTags in a future version or if we fork/patch the BRouter profiles more deeply. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ColoredRoute.tsx | 40 ++-------------- .../planner/app/components/ElevationChart.tsx | 47 ++----------------- apps/planner/app/components/PlannerMap.tsx | 9 ---- apps/planner/app/lib/brouter.ts | 21 ++------- apps/planner/app/lib/use-routing.ts | 3 -- e2e/fixtures/brouter-mock.ts | 7 --- packages/i18n/src/locales/de.ts | 2 - packages/i18n/src/locales/en.ts | 2 - 8 files changed, 9 insertions(+), 122 deletions(-) diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index 469c27e..a4b06d7 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -2,14 +2,13 @@ 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"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] colorMode: ColorMode; surfaces?: string[]; highways?: string[]; - maxspeeds?: string[]; } const SURFACE_COLORS: Record = { @@ -64,22 +63,6 @@ const HIGHWAY_COLORS: Record = { const DEFAULT_HIGHWAY_COLOR = "#9ca3af"; -export function maxspeedColor(speed: string): string { - const num = parseInt(speed, 10); - if (isNaN(num)) { - // Handle text values: "walk", "none", "unknown" - if (speed === "walk") return "#22c55e"; - if (speed === "none") return "#991b1b"; - return "#9ca3af"; - } - if (num <= 20) return "#22c55e"; // green: walking pace - if (num <= 30) return "#16a34a"; // green: slow zone - if (num <= 50) return "#eab308"; // yellow: urban - if (num <= 70) return "#f97316"; // orange: suburban - if (num <= 100) return "#ef4444"; // red: rural - return "#991b1b"; // dark red: highway -} - export function routeGradeColor(grade: number): string { const absGrade = Math.abs(grade); if (absGrade < 3) return "#22c55e"; @@ -99,7 +82,7 @@ export function elevationColor(t: number): string { return `rgb(255, ${g}, 50)`; } -export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds }: ColoredRouteProps) { +export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: ColoredRouteProps) { const segments = useMemo(() => { if (colorMode === "plain" || coordinates.length < 2) { return null; @@ -164,23 +147,6 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxsp 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++) { - result.push({ - positions: [ - [coordinates[i]![1], coordinates[i]![0]], - [coordinates[i + 1]![1], coordinates[i + 1]![0]], - ], - color: maxspeedColor(maxspeeds[i] ?? "unknown"), - }); - } - return result; - } - // surface mode if (!surfaces || surfaces.length < coordinates.length) return null; @@ -196,7 +162,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxsp }); } return result; - }, [coordinates, colorMode, surfaces, highways, maxspeeds]); + }, [coordinates, colorMode, surfaces, highways]); 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 0cecc7f..0d8edc0 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, maxspeedColor, type ColorMode } from "~/components/ColoredRoute"; +import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute"; function gradeColor(grade: number): string { const absGrade = Math.abs(grade); @@ -76,7 +76,6 @@ 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; @@ -103,12 +102,6 @@ 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(); @@ -241,29 +234,6 @@ 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 color = maxspeedColor(maxspeeds[i] ?? "unknown"); - - 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)); @@ -369,15 +339,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { if (colorMode === "highway" && highways[highlightIdx]) { label += ` · ${highways[highlightIdx]}`; } - if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) { - label += ` · ${maxspeeds[highlightIdx]} 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, maxspeeds, days, t], + [points, colorMode, surfaces, highways, days, t], ); useEffect(() => { @@ -436,7 +403,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {

- {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") : t("elevation.profile")}

{colorMode === "grade" && (<> @@ -469,13 +436,6 @@ 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), []); @@ -451,13 +450,6 @@ 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); }; @@ -739,7 +731,6 @@ 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; @@ -122,7 +120,6 @@ 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 @@ -156,7 +153,6 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric segmentBoundaries, surfaces: allSurfaces, highways: allHighways, - maxspeeds: allMaxspeeds, totalLength, totalAscend, totalTime, @@ -167,7 +163,6 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric interface WayTagData { surfaces: Map; highways: Map; - maxspeeds: Map; } /** @@ -178,13 +173,12 @@ 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, maxspeeds }; + if (!messages || messages.length < 2) return { surfaces, highways }; const headers = messages[0]!; const wayTagsIdx = headers.indexOf("WayTags"); - if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds }; + if (wayTagsIdx === -1) return { surfaces, highways }; for (let i = 1; i < messages.length; i++) { const row = messages[i]!; @@ -193,17 +187,8 @@ 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"); - // Handle maxspeed variants: maxspeed:forward, maxspeed:backward, maxspeed - const hasReverse = tags.includes("reversedirection=yes"); - const forwardMatch = tags.match(/maxspeed:forward=(\S+)/); - const backwardMatch = tags.match(/maxspeed:backward=(\S+)/); - const plainMatch = tags.match(/(? Date: Sat, 11 Apr 2026 09:44:40 +0200 Subject: [PATCH 4/9] Re-add speed limit color mode with BRouter profile patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/planner/app/components/ColoredRoute.tsx | 38 ++++++++++++-- .../planner/app/components/ElevationChart.tsx | 49 +++++++++++++++++-- apps/planner/app/components/PlannerMap.tsx | 9 ++++ apps/planner/app/lib/brouter.ts | 28 +++++++++-- apps/planner/app/lib/use-routing.ts | 3 ++ docker/brouter/Dockerfile | 10 ++++ e2e/fixtures/brouter-mock.ts | 7 +++ packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 9 files changed, 139 insertions(+), 9 deletions(-) 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", }, From 768fa9df44d15c743b5edf5161ba82f0230fc1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 09:46:01 +0200 Subject: [PATCH 5/9] Reorder color mode dropdown: plain, elevation, grade, surface, road type, speed limit Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ElevationChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 22cdb81..648135a 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -486,8 +486,8 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { > - + From 6ec95216e1bf9b14266ff84735fd8fc2717b446f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 09:58:25 +0200 Subject: [PATCH 6/9] Add smoothness, track type, cycleway, and bike route color modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/planner/app/components/ColoredRoute.tsx | 136 ++++++++++++- .../planner/app/components/ElevationChart.tsx | 187 +++++++++++++++++- apps/planner/app/components/PlannerMap.tsx | 36 ++++ apps/planner/app/lib/brouter.ts | 61 +++++- apps/planner/app/lib/use-routing.ts | 12 ++ docker/brouter/Dockerfile | 11 +- e2e/fixtures/brouter-mock.ts | 28 +++ packages/i18n/src/locales/de.ts | 8 + packages/i18n/src/locales/en.ts | 8 + 9 files changed, 472 insertions(+), 15 deletions(-) diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index 753429c..c8b8f36 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -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 = { @@ -64,6 +68,51 @@ const HIGHWAY_COLORS: Record = { const DEFAULT_HIGHWAY_COLOR = "#9ca3af"; +const SMOOTHNESS_COLORS: Record = { + 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 = { + grade1: "#22c55e", + grade2: "#84cc16", + grade3: "#eab308", + grade4: "#f97316", + grade5: "#ef4444", +}; + +const DEFAULT_TRACKTYPE_COLOR = "#9ca3af"; + +const CYCLEWAY_COLORS: Record = { + 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 = { + 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, +}; diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 648135a..382d917 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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([]); const [highways, setHighways] = useState([]); const [maxspeeds, setMaxspeeds] = useState([]); + const [smoothnesses, setSmoothnesses] = useState([]); + const [tracktypes, setTracktypes] = useState([]); + const [cycleways, setCycleways] = useState([]); + const [bikeroutes, setBikeroutes] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); 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) {

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

{colorMode === "grade" && (<> @@ -478,6 +623,38 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { {"≤100"} {"100+"} )} + {colorMode === "smoothness" && smoothnesses.length > 0 && (<> + {[...new Set(smoothnesses)].slice(0, 6).map((s) => ( + + + {s} + + ))} + {[...new Set(smoothnesses)].length > 6 && +{[...new Set(smoothnesses)].length - 6}} + )} + {colorMode === "tracktype" && (<> + grade1 + grade2 + grade3 + grade4 + grade5 + )} + {colorMode === "cycleway" && cycleways.length > 0 && (<> + {[...new Set(cycleways)].slice(0, 6).map((c) => ( + + + {c} + + ))} + {[...new Set(cycleways)].length > 6 && +{[...new Set(cycleways)].length - 6}} + )} + {colorMode === "bikeroute" && (<> + icn + ncn + rcn + lcn + none + )}
([]); const [highways, setHighways] = useState([]); const [maxspeeds, setMaxspeeds] = useState([]); + const [smoothnesses, setSmoothnesses] = useState([]); + const [tracktypes, setTracktypes] = useState([]); + const [cycleways, setCycleways] = useState([]); + const [bikeroutes, setBikeroutes] = useState([]); const [colorMode, setColorMode] = useState("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} /> []): 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[]): 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[]): 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; highways: Map; maxspeeds: Map; + smoothnesses: Map; + tracktypes: Map; + cycleways: Map; + bikeroutes: Map; } /** @@ -179,12 +199,16 @@ function extractWayTagData(properties: Record): WayTagData { const surfaces = new Map(); const highways = new Map(); const maxspeeds = new Map(); + const smoothnesses = new Map(); + const tracktypes = new Map(); + const cycleways = new Map(); + const bikeroutes = new Map(); 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): 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 }; } /** diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index 9ef6108..61ad504 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -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"); diff --git a/docker/brouter/Dockerfile b/docker/brouter/Dockerfile index 24d1164..13a2cc1 100644 --- a/docker/brouter/Dockerfile +++ b/docker/brouter/Dockerfile @@ -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 diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts index 8849ab1..864712e 100644 --- a/e2e/fixtures/brouter-mock.ts +++ b/e2e/fixtures/brouter-mock.ts @@ -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", + ], }; /** diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d823b69..c7110c0 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index dc3546d..a97bbd1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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", }, From ffb53483965a9f7e39024120b6cd9c0a2f982594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 11:02:07 +0200 Subject: [PATCH 7/9] Link elevation chart title to OSM wiki for each color mode Clicking the chart title (e.g. "Road Type Profile") opens the corresponding OSM wiki page. Plain, elevation, and grade modes remain as plain text since they're not OSM tag-based. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../planner/app/components/ElevationChart.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 382d917..6d60b66 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -582,9 +582,35 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { return (
-

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

+ {(() => { + const osmLinks: Record = { + 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 = { + 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 ? ( + + {title} + + ) : ( +

{title}

+ ); + })()}
{colorMode === "grade" && (<> {"<3%"} From 3787821571c88b96fe8ca95445acbd3a1f20a17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 11:05:15 +0200 Subject: [PATCH 8/9] Improve bike route legend: human-readable names, non-judgmental colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Legend: International / National / Regional / Local / None - Hover: shows full names instead of icn/ncn/rcn/lcn - Palette: purple (intl) → blue (national) → teal (regional) → emerald (local) → gray (none). No red/green implied quality. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ColoredRoute.tsx | 10 +++++----- apps/planner/app/components/ElevationChart.tsx | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index c8b8f36..ea2b026 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -104,11 +104,11 @@ const CYCLEWAY_COLORS: Record = { const DEFAULT_CYCLEWAY_COLOR = "#9ca3af"; const BIKEROUTE_COLORS: Record = { - icn: "#dc2626", - ncn: "#f97316", - rcn: "#eab308", - lcn: "#22c55e", - none: "#d4d4d8", + 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"; diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 6d60b66..b7cd549 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -517,7 +517,8 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { label += ` · ${cycleways[highlightIdx]}`; } if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) { - label += ` · ${bikeroutes[highlightIdx]}`; + const names: Record = { 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"; @@ -675,11 +676,11 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { {[...new Set(cycleways)].length > 6 && +{[...new Set(cycleways)].length - 6}} )} {colorMode === "bikeroute" && (<> - icn - ncn - rcn - lcn - none + International + National + Regional + Local + None )}