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] 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 `