diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index a4b06d7..469c27e 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -2,13 +2,14 @@ import { useMemo } from "react"; import { Polyline } from "react-leaflet"; import type L from "leaflet"; -export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway"; +export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] colorMode: ColorMode; surfaces?: string[]; highways?: string[]; + maxspeeds?: string[]; } const SURFACE_COLORS: Record = { @@ -63,6 +64,22 @@ const HIGHWAY_COLORS: Record = { const DEFAULT_HIGHWAY_COLOR = "#9ca3af"; +export function maxspeedColor(speed: string): string { + const num = parseInt(speed, 10); + if (isNaN(num)) { + // Handle text values: "walk", "none", "unknown" + if (speed === "walk") return "#22c55e"; + if (speed === "none") return "#991b1b"; + return "#9ca3af"; + } + if (num <= 20) return "#22c55e"; // green: walking pace + if (num <= 30) return "#16a34a"; // green: slow zone + if (num <= 50) return "#eab308"; // yellow: urban + if (num <= 70) return "#f97316"; // orange: suburban + if (num <= 100) return "#ef4444"; // red: rural + return "#991b1b"; // dark red: highway +} + export function routeGradeColor(grade: number): string { const absGrade = Math.abs(grade); if (absGrade < 3) return "#22c55e"; @@ -82,7 +99,7 @@ export function elevationColor(t: number): string { return `rgb(255, ${g}, 50)`; } -export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: ColoredRouteProps) { +export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds }: ColoredRouteProps) { const segments = useMemo(() => { if (colorMode === "plain" || coordinates.length < 2) { return null; @@ -147,6 +164,23 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: Col return result; } + // maxspeed mode + if (colorMode === "maxspeed") { + if (!maxspeeds || maxspeeds.length < coordinates.length) return null; + + const result: { positions: L.LatLngExpression[]; color: string }[] = []; + for (let i = 0; i < coordinates.length - 1; i++) { + result.push({ + positions: [ + [coordinates[i]![1], coordinates[i]![0]], + [coordinates[i + 1]![1], coordinates[i + 1]![0]], + ], + color: maxspeedColor(maxspeeds[i] ?? "unknown"), + }); + } + return result; + } + // surface mode if (!surfaces || surfaces.length < coordinates.length) return null; @@ -162,7 +196,7 @@ export function ColoredRoute({ coordinates, colorMode, surfaces, highways }: Col }); } return result; - }, [coordinates, colorMode, surfaces, highways]); + }, [coordinates, colorMode, surfaces, highways, maxspeeds]); const plainPositions = useMemo( () => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression), diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 0d8edc0..0cecc7f 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { DayStage } from "@trails-cool/gpx"; import type { YjsState } from "~/lib/use-yjs"; -import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, type ColorMode } from "~/components/ColoredRoute"; +import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR, maxspeedColor, type ColorMode } from "~/components/ColoredRoute"; function gradeColor(grade: number): string { const absGrade = Math.abs(grade); @@ -76,6 +76,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { const [colorMode, setColorMode] = useState("plain"); const [surfaces, setSurfaces] = useState([]); const [highways, setHighways] = useState([]); + const [maxspeeds, setMaxspeeds] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = points; @@ -102,6 +103,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { } else { setHighways([]); } + const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; + if (maxspeedsJson) { + try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } + } else { + setMaxspeeds([]); + } }; yjs.routeData.observe(update); update(); @@ -234,6 +241,29 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ctx.fillStyle = color + "40"; ctx.fill(); + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + } else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) { + // Maxspeed-colored segments + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]!; + const p1 = points[i + 1]!; + const color = maxspeedColor(maxspeeds[i] ?? "unknown"); + + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), PADDING.top + chartH); + ctx.lineTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.lineTo(toX(p1.distance), PADDING.top + chartH); + ctx.closePath(); + ctx.fillStyle = color + "40"; + ctx.fill(); + ctx.beginPath(); ctx.moveTo(toX(p0.distance), toY(p0.elevation)); ctx.lineTo(toX(p1.distance), toY(p1.elevation)); @@ -339,12 +369,15 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { if (colorMode === "highway" && highways[highlightIdx]) { label += ` · ${highways[highlightIdx]}`; } + if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) { + label += ` · ${maxspeeds[highlightIdx]} km/h`; + } const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8; ctx.textAlign = hx + 8 > w - 80 ? "right" : "left"; ctx.fillText(label, labelX, PADDING.top + 10); } }, - [points, colorMode, surfaces, highways, days, t], + [points, colorMode, surfaces, highways, maxspeeds, days, t], ); useEffect(() => { @@ -403,7 +436,7 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {

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

{colorMode === "grade" && (<> @@ -436,6 +469,13 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ))} {[...new Set(highways)].length > 6 && +{[...new Set(highways)].length - 6}} )} + {colorMode === "maxspeed" && (<> + ≤30 + ≤50 + ≤70 + ≤100 + 100+ + )}
([]); const [surfaces, setSurfaces] = useState([]); const [highways, setHighways] = useState([]); + const [maxspeeds, setMaxspeeds] = useState([]); const [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); @@ -450,6 +451,13 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted setHighways([]); } + const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; + if (maxspeedsJson) { + try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } + } else { + setMaxspeeds([]); + } + if (modeVal) setColorMode(modeVal); }; @@ -731,6 +739,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted colorMode={colorMode} surfaces={surfaces} highways={highways} + maxspeeds={maxspeeds} /> []): Enric const allCoords: [number, number, number][] = []; const allSurfaces: string[] = []; const allHighways: string[] = []; + const allMaxspeeds: string[] = []; const segmentBoundaries: number[] = []; let totalLength = 0; let totalAscend = 0; @@ -120,6 +122,7 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric allCoords.push([c[0]!, c[1]!, c[2] ?? 0]); allSurfaces.push(wayTagData.surfaces.get(j) ?? wayTagData.surfaces.get(j - 1) ?? "unknown"); allHighways.push(wayTagData.highways.get(j) ?? wayTagData.highways.get(j - 1) ?? "unknown"); + allMaxspeeds.push(wayTagData.maxspeeds.get(j) ?? wayTagData.maxspeeds.get(j - 1) ?? "unknown"); } // Accumulate stats @@ -153,6 +156,7 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric segmentBoundaries, surfaces: allSurfaces, highways: allHighways, + maxspeeds: allMaxspeeds, totalLength, totalAscend, totalTime, @@ -163,6 +167,7 @@ export function mergeGeoJsonSegments(segments: Record[]): Enric interface WayTagData { surfaces: Map; highways: Map; + maxspeeds: Map; } /** @@ -173,12 +178,13 @@ interface WayTagData { function extractWayTagData(properties: Record): WayTagData { const surfaces = new Map(); const highways = new Map(); + const maxspeeds = new Map(); const messages = properties.messages as string[][] | undefined; - if (!messages || messages.length < 2) return { surfaces, highways }; + if (!messages || messages.length < 2) return { surfaces, highways, maxspeeds }; const headers = messages[0]!; const wayTagsIdx = headers.indexOf("WayTags"); - if (wayTagsIdx === -1) return { surfaces, highways }; + if (wayTagsIdx === -1) return { surfaces, highways, maxspeeds }; for (let i = 1; i < messages.length; i++) { const row = messages[i]!; @@ -187,8 +193,17 @@ function extractWayTagData(properties: Record): WayTagData { surfaces.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown"); const highwayMatch = tags.match(/highway=(\S+)/); highways.set(i - 1, highwayMatch ? highwayMatch[1]! : "unknown"); + // Handle maxspeed variants: maxspeed:forward, maxspeed:backward, maxspeed + const hasReverse = tags.includes("reversedirection=yes"); + const forwardMatch = tags.match(/maxspeed:forward=(\S+)/); + const backwardMatch = tags.match(/maxspeed:backward=(\S+)/); + const plainMatch = tags.match(/(? { await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Wait for elevation chart to render with the color mode selector - const select = page.locator("select").last(); - await expect(select).toBeVisible({ timeout: 10000 }); + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); await expect(select).toHaveValue("plain"); // Switch to elevation @@ -364,9 +365,10 @@ test.describe("Planner", () => { await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - // Wait for elevation chart color mode selector - const select = page.locator("select").last(); - await expect(select).toBeVisible({ timeout: 10000 }); + // Wait for the elevation chart to render, then find the color mode select (has a "highway" option) + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); // Switch to highway (road type) mode await select.selectOption("highway"); @@ -395,8 +397,9 @@ test.describe("Planner", () => { await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Switch to highway mode - const select = page.locator("select").last(); - await expect(select).toBeVisible({ timeout: 10000 }); + await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); + const select = page.locator("select", { has: page.locator("option[value='highway']") }); + await expect(select).toBeVisible({ timeout: 5000 }); await select.selectOption("highway"); // Hover the canvas to trigger the hover label diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f883e08..d823b69 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -103,6 +103,7 @@ export default { surface: "Untergrund", grade: "Steigung", highway: "Straßentyp", + maxspeed: "Tempolimit", surfaceLegend: "Farbe nach Straßenbelag", surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar", }, @@ -113,6 +114,7 @@ export default { profile: "Höhenprofil", grade: "Steigungsprofil", highway: "Straßentypenprofil", + maxspeed: "Tempolimitprofil", low: "Tief", high: "Hoch", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4c9b8da..dc3546d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -103,6 +103,7 @@ export default { surface: "Surface", grade: "Grade", highway: "Road Type", + maxspeed: "Speed Limit", surfaceLegend: "Color by road surface type", surfaceUnavailable: "Surface data not available for this profile", }, @@ -113,6 +114,7 @@ export default { profile: "Elevation Profile", grade: "Grade Profile", highway: "Road Type Profile", + maxspeed: "Speed Limit Profile", low: "Low", high: "High", },