Merge pull request #208 from trails-cool/road-type
Add road type color mode
This commit is contained in:
commit
80404b2494
19 changed files with 1312 additions and 26 deletions
|
|
@ -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<string, string> = {
|
||||
|
|
@ -32,6 +38,81 @@ const SURFACE_COLORS: Record<string, string> = {
|
|||
|
||||
const DEFAULT_SURFACE_COLOR = "#9ca3af";
|
||||
|
||||
const HIGHWAY_COLORS: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
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<string, string> = {
|
||||
grade1: "#22c55e",
|
||||
grade2: "#84cc16",
|
||||
grade3: "#eab308",
|
||||
grade4: "#f97316",
|
||||
grade5: "#ef4444",
|
||||
};
|
||||
|
||||
const DEFAULT_TRACKTYPE_COLOR = "#9ca3af";
|
||||
|
||||
const CYCLEWAY_COLORS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
|
||||
const [tracktypes, setTracktypes] = useState<string[]>([]);
|
||||
const [cycleways, setCycleways] = useState<string[]>([]);
|
||||
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
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<string, string> = { 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 (
|
||||
<div className="border-t border-gray-200 px-2 py-2">
|
||||
<div className="mb-1 flex items-center gap-2 px-2">
|
||||
<p className="shrink-0 text-xs font-medium text-gray-500">
|
||||
{colorMode === "grade" ? t("elevation.grade") : t("elevation.profile")}
|
||||
</p>
|
||||
{(() => {
|
||||
const osmLinks: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 ? (
|
||||
<a href={link} target="_blank" rel="noopener" className="shrink-0 text-xs font-medium text-gray-500 hover:text-blue-600 hover:underline">
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<p className="shrink-0 text-xs font-medium text-gray-500">{title}</p>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-gray-400">
|
||||
{colorMode === "grade" && (<>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />{"<3%"}</span>
|
||||
|
|
@ -393,6 +634,54 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
))}
|
||||
{[...new Set(surfaces)].length > 6 && <span>+{[...new Set(surfaces)].length - 6}</span>}
|
||||
</>)}
|
||||
{colorMode === "highway" && highways.length > 0 && (<>
|
||||
{[...new Set(highways)].slice(0, 6).map((h) => (
|
||||
<span key={h} className="flex items-center gap-0.5">
|
||||
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: HIGHWAY_COLORS[h] ?? DEFAULT_HIGHWAY_COLOR }} />
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
{[...new Set(highways)].length > 6 && <span>+{[...new Set(highways)].length - 6}</span>}
|
||||
</>)}
|
||||
{colorMode === "maxspeed" && (<>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />{"≤30"}</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />{"≤50"}</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />{"≤70"}</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />{"≤100"}</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#991b1b" }} />{"100+"}</span>
|
||||
</>)}
|
||||
{colorMode === "smoothness" && smoothnesses.length > 0 && (<>
|
||||
{[...new Set(smoothnesses)].slice(0, 6).map((s) => (
|
||||
<span key={s} className="flex items-center gap-0.5">
|
||||
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: SMOOTHNESS_COLORS[s] ?? DEFAULT_SMOOTHNESS_COLOR }} />
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
{[...new Set(smoothnesses)].length > 6 && <span>+{[...new Set(smoothnesses)].length - 6}</span>}
|
||||
</>)}
|
||||
{colorMode === "tracktype" && (<>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />grade1</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#84cc16" }} />grade2</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />grade3</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />grade4</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />grade5</span>
|
||||
</>)}
|
||||
{colorMode === "cycleway" && cycleways.length > 0 && (<>
|
||||
{[...new Set(cycleways)].slice(0, 6).map((c) => (
|
||||
<span key={c} className="flex items-center gap-0.5">
|
||||
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: CYCLEWAY_COLORS[c] ?? DEFAULT_CYCLEWAY_COLOR }} />
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
{[...new Set(cycleways)].length > 6 && <span>+{[...new Set(cycleways)].length - 6}</span>}
|
||||
</>)}
|
||||
{colorMode === "bikeroute" && (<>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#7c3aed" }} />International</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#2563eb" }} />National</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#0891b2" }} />Regional</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#059669" }} />Local</span>
|
||||
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#d4d4d8" }} />None</span>
|
||||
</>)}
|
||||
</div>
|
||||
<select
|
||||
value={colorMode}
|
||||
|
|
@ -401,8 +690,14 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
|
|||
>
|
||||
<option value="plain">{t("colorMode.plain")}</option>
|
||||
<option value="elevation">{t("colorMode.elevation")}</option>
|
||||
<option value="surface">{t("colorMode.surface")}</option>
|
||||
<option value="grade">{t("colorMode.grade")}</option>
|
||||
<option value="surface">{t("colorMode.surface")}</option>
|
||||
<option value="highway">{t("colorMode.highway")}</option>
|
||||
<option value="maxspeed">{t("colorMode.maxspeed")}</option>
|
||||
<option value="smoothness">{t("colorMode.smoothness")}</option>
|
||||
<option value="tracktype">{t("colorMode.tracktype")}</option>
|
||||
<option value="cycleway">{t("colorMode.cycleway")}</option>
|
||||
<option value="bikeroute">{t("colorMode.bikeroute")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -375,6 +375,12 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
|
||||
const [tracktypes, setTracktypes] = useState<string[]>([]);
|
||||
const [cycleways, setCycleways] = useState<string[]>([]);
|
||||
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("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}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,83 @@ describe("mergeGeoJsonSegments", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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 = [
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[]): 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<string, unknown>[]): 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<string, unknown>[]): 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<string, unknown>[]): Enric
|
|||
};
|
||||
}
|
||||
|
||||
interface WayTagData {
|
||||
surfaces: Map<number, string>;
|
||||
highways: Map<number, string>;
|
||||
maxspeeds: Map<number, string>;
|
||||
smoothnesses: Map<number, string>;
|
||||
tracktypes: Map<number, string>;
|
||||
cycleways: Map<number, string>;
|
||||
bikeroutes: Map<number, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>): Map<number, string> {
|
||||
const result = new Map<number, string>();
|
||||
function extractWayTagData(properties: Record<string, unknown>): WayTagData {
|
||||
const surfaces = new Map<number, string>();
|
||||
const highways = new Map<number, string>();
|
||||
const maxspeeds = new Map<number, string>();
|
||||
const smoothnesses = new Map<number, string>();
|
||||
const tracktypes = new Map<number, string>();
|
||||
const cycleways = new Map<number, string>();
|
||||
const bikeroutes = new Map<number, string>();
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-11
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
41
openspec/changes/archive/2026-04-11-road-type-chart/tasks.md
Normal file
41
openspec/changes/archive/2026-04-11-road-type-chart/tasks.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
## 1. Data Extraction & Pipeline
|
||||
|
||||
- [x] 1.1 Extend `extractSurfacesFromMessages` in `brouter.ts` to also extract `highway=*` tags, returning a `Map<number, string>` 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 `<select>` dropdown in `ElevationChart.tsx`
|
||||
|
||||
## 6. Internationalization
|
||||
|
||||
- [x] 6.1 Add English i18n keys: `colorMode.highway` ("Road Type"), `elevation.highway` ("Road Type Profile")
|
||||
- [x] 6.2 Add German i18n keys: `colorMode.highway` ("Straßentyp"), `elevation.highway` ("Straßentypenprofil")
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- [x] 7.1 Add `highways` data to BRouter mock fixtures in `e2e/fixtures/brouter-mock.ts`
|
||||
- [x] 7.2 Add unit tests for highway tag extraction in `brouter.ts`
|
||||
- [x] 7.3 Add E2E test: switch to road type color mode and verify chart renders
|
||||
- [x] 7.4 Add E2E test: verify road type hover label shows highway type name
|
||||
82
openspec/specs/road-type-coloring/spec.md
Normal file
82
openspec/specs/road-type-coloring/spec.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
## Purpose
|
||||
|
||||
Road type visualization for route planning. Extracts OSM highway classification from BRouter tiledesc data and provides a color mode that shows road type on both the map polyline and elevation chart.
|
||||
|
||||
## 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"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
## Purpose
|
||||
|
||||
Multi-mode route visualization (plain, elevation gradient, surface type, grade) with session-synced color mode selection. Both the route polyline on the map and the elevation chart reflect the selected color mode.
|
||||
Multi-mode route visualization (plain, elevation gradient, surface type, grade, road type) with session-synced color mode selection. Both the route polyline on the map and the elevation chart reflect the selected color mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -27,6 +27,14 @@ The Planner SHALL support multiple route color modes that visualize per-point da
|
|||
- **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.
|
||||
|
||||
|
|
@ -34,6 +42,10 @@ The Planner SHALL provide a UI control to switch between route color modes.
|
|||
- **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
|
||||
|
|
@ -72,6 +84,10 @@ The elevation chart SHALL display a legend matching the active color mode.
|
|||
- **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.
|
||||
|
||||
|
|
@ -82,3 +98,7 @@ The elevation chart hover label SHALL show mode-specific information.
|
|||
#### 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")
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ export default {
|
|||
elevation: "Höhe",
|
||||
surface: "Untergrund",
|
||||
grade: "Steigung",
|
||||
highway: "Straßentyp",
|
||||
maxspeed: "Tempolimit",
|
||||
smoothness: "Fahrbahnqualität",
|
||||
tracktype: "Wegtyp",
|
||||
cycleway: "Radweg",
|
||||
bikeroute: "Radroute",
|
||||
surfaceLegend: "Farbe nach Straßenbelag",
|
||||
surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar",
|
||||
},
|
||||
|
|
@ -111,6 +117,12 @@ export default {
|
|||
loss: "Höhenmeter abwärts",
|
||||
profile: "Höhenprofil",
|
||||
grade: "Steigungsprofil",
|
||||
highway: "Straßentypenprofil",
|
||||
maxspeed: "Tempolimitprofil",
|
||||
smoothness: "Fahrbahnqualitätsprofil",
|
||||
tracktype: "Wegtypenprofil",
|
||||
cycleway: "Radwegprofil",
|
||||
bikeroute: "Radroutenprofil",
|
||||
low: "Tief",
|
||||
high: "Hoch",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ export default {
|
|||
elevation: "Elevation",
|
||||
surface: "Surface",
|
||||
grade: "Grade",
|
||||
highway: "Road Type",
|
||||
maxspeed: "Speed Limit",
|
||||
smoothness: "Smoothness",
|
||||
tracktype: "Track Type",
|
||||
cycleway: "Cycleway",
|
||||
bikeroute: "Bike Route",
|
||||
surfaceLegend: "Color by road surface type",
|
||||
surfaceUnavailable: "Surface data not available for this profile",
|
||||
},
|
||||
|
|
@ -111,6 +117,12 @@ export default {
|
|||
loss: "Elevation Loss",
|
||||
profile: "Elevation Profile",
|
||||
grade: "Grade Profile",
|
||||
highway: "Road Type Profile",
|
||||
maxspeed: "Speed Limit Profile",
|
||||
smoothness: "Smoothness Profile",
|
||||
tracktype: "Track Type Profile",
|
||||
cycleway: "Cycleway Profile",
|
||||
bikeroute: "Bike Route Profile",
|
||||
low: "Low",
|
||||
high: "High",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue