diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index ad000dd..ea2b026 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -2,12 +2,18 @@ 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" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] colorMode: ColorMode; surfaces?: string[]; + highways?: string[]; + maxspeeds?: string[]; + smoothnesses?: string[]; + tracktypes?: string[]; + cycleways?: string[]; + bikeroutes?: string[]; } const SURFACE_COLORS: Record = { @@ -32,6 +38,81 @@ 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"; + +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: "#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"; + export function routeGradeColor(grade: number): string { const absGrade = Math.abs(grade); if (absGrade < 3) return "#22c55e"; @@ -51,7 +132,20 @@ export function elevationColor(t: number): string { return `rgb(255, ${g}, 50)`; } -export function ColoredRoute({ coordinates, colorMode, surfaces }: 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, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) { const segments = useMemo(() => { if (colorMode === "plain" || coordinates.length < 2) { return null; @@ -98,6 +192,114 @@ 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; + } + + // 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; + } + + // 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; @@ -113,7 +315,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP }); } return result; - }, [coordinates, colorMode, surfaces]); + }, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]); const plainPositions = useMemo( () => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression), @@ -154,4 +356,11 @@ export function findSegmentForPoint( return 0; } -export { SURFACE_COLORS, DEFAULT_SURFACE_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 fe5c9e6..b7cd549 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, SURFACE_COLORS, DEFAULT_SURFACE_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); @@ -75,6 +84,12 @@ 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 [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; @@ -95,6 +110,42 @@ 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([]); + } + const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; + if (maxspeedsJson) { + try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } + } 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(); @@ -203,6 +254,150 @@ 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)); + 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)); + 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)); @@ -305,12 +500,32 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { if (colorMode === "surface" && surfaces[highlightIdx]) { label += ` · ${surfaces[highlightIdx]}`; } + if (colorMode === "highway" && highways[highlightIdx]) { + label += ` · ${highways[highlightIdx]}`; + } + if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) { + 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]) { + 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"; ctx.fillText(label, labelX, PADDING.top + 10); } }, - [points, colorMode, surfaces, days, t], + [points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t], ); useEffect(() => { @@ -368,9 +583,35 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { return (
-

- {colorMode === "grade" ? t("elevation.grade") : 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%"} @@ -393,6 +634,54 @@ 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}} + )} + {colorMode === "maxspeed" && (<> + {"≤30"} + {"≤50"} + {"≤70"} + {"≤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" && (<> + International + National + Regional + Local + None + )}
(null); const [segmentBoundaries, setSegmentBoundaries] = useState([]); 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 [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); @@ -442,6 +448,48 @@ 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([]); + } + + const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; + if (maxspeedsJson) { + try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } + } 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([]); + } + if (modeVal) setColorMode(modeVal); }; @@ -722,6 +770,12 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted coordinates={routeCoordinates} colorMode={colorMode} surfaces={surfaces} + highways={highways} + maxspeeds={maxspeeds} + smoothnesses={smoothnesses} + tracktypes={tracktypes} + cycleways={cycleways} + bikeroutes={bikeroutes} /> { }); }); +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..d4a3974 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -16,6 +16,12 @@ 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") + maxspeeds: string[]; // speed limit per coordinate point (e.g. "30", "50", "none") + smoothnesses: string[]; // smoothness per coordinate point (e.g. "good", "bad") + tracktypes: string[]; // track type per coordinate point (e.g. "grade1", "grade5") + cycleways: string[]; // cycleway type per coordinate point (e.g. "track", "lane") + bikeroutes: string[]; // bicycle route network per coordinate point (e.g. "icn", "lcn") totalLength: number; totalAscend: number; totalTime: number; @@ -94,6 +100,12 @@ interface GeoJsonCollection { export function mergeGeoJsonSegments(segments: Record[]): EnrichedRoute { const allCoords: [number, number, number][] = []; 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; @@ -108,15 +120,21 @@ 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"); + 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 @@ -149,6 +167,12 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric coordinates: allCoords, segmentBoundaries, surfaces: allSurfaces, + highways: allHighways, + maxspeeds: allMaxspeeds, + smoothnesses: allSmoothnesses, + tracktypes: allTracktypes, + cycleways: allCycleways, + bikeroutes: allBikeroutes, totalLength, totalAscend, totalTime, @@ -156,27 +180,92 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric }; } +interface WayTagData { + surfaces: Map; + highways: Map; + maxspeeds: Map; + smoothnesses: Map; + tracktypes: Map; + cycleways: Map; + bikeroutes: 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 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 result; + 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 result; + if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }; 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"); + + // 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); + + // 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 result; + 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 bac87e1..61ad504 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -126,6 +126,24 @@ 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)); + } + 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 ec8f0d8..13a2cc1 100644 --- a/docker/brouter/Dockerfile +++ b/docker/brouter/Dockerfile @@ -20,6 +20,17 @@ 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 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"; 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 + USER app EXPOSE 17777 diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts index 27bb9c8..864712e 100644 --- a/e2e/fixtures/brouter-mock.ts +++ b/e2e/fixtures/brouter-mock.ts @@ -32,7 +32,34 @@ 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", + ], + maxspeeds: [ + "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", + ], }; /** @@ -63,7 +90,27 @@ 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", + ], + 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/e2e/planner.test.ts b/e2e/planner.test.ts index 8dbd270..eb9608c 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -138,8 +138,9 @@ test.describe("Planner", () => { 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 @@ -350,6 +351,72 @@ 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 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"); + 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("residential")).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 + 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 + 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 `