diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index 5bf8bf7..ad000dd 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Polyline } from "react-leaflet"; import type L from "leaflet"; -export type ColorMode = "plain" | "elevation" | "surface"; +export type ColorMode = "plain" | "elevation" | "surface" | "grade"; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] @@ -32,6 +32,15 @@ const SURFACE_COLORS: Record = { const DEFAULT_SURFACE_COLOR = "#9ca3af"; +export function routeGradeColor(grade: number): string { + const absGrade = Math.abs(grade); + if (absGrade < 3) return "#22c55e"; + if (absGrade < 6) return "#eab308"; + if (absGrade < 10) return "#f97316"; + if (absGrade < 15) return "#ef4444"; + return "#991b1b"; +} + export function elevationColor(t: number): string { // green (0) → yellow (0.5) → red (1) if (t <= 0.5) { @@ -68,6 +77,27 @@ export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteP return result; } + if (colorMode === "grade") { + const result: { positions: L.LatLngExpression[]; color: string }[] = []; + for (let i = 0; i < coordinates.length - 1; i++) { + const c0 = coordinates[i]!; + const c1 = coordinates[i + 1]!; + // Approximate distance in meters using lat/lon diff + const dLat = (c1[1] - c0[1]) * 111320; + const dLon = (c1[0] - c0[0]) * 111320 * Math.cos((c0[1] * Math.PI) / 180); + const dist = Math.sqrt(dLat * dLat + dLon * dLon); + const grade = dist > 0 ? ((c1[2] - c0[2]) / dist) * 100 : 0; + result.push({ + positions: [ + [c0[1], c0[0]], + [c1[1], c1[0]], + ], + color: routeGradeColor(grade), + }); + } + return result; + } + // surface mode if (!surfaces || surfaces.length < coordinates.length) return null; diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 999d958..fe5c9e6 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, type ColorMode } from "~/components/ColoredRoute"; +import { elevationColor, SURFACE_COLORS, DEFAULT_SURFACE_COLOR, type ColorMode } from "~/components/ColoredRoute"; + +function gradeColor(grade: number): string { + const absGrade = Math.abs(grade); + if (absGrade < 3) return "#22c55e"; // green: flat/gentle + if (absGrade < 6) return "#eab308"; // yellow: moderate + if (absGrade < 10) return "#f97316"; // orange: steep + if (absGrade < 15) return "#ef4444"; // red: very steep + return "#991b1b"; // dark red: extreme +} interface ElevationPoint { distance: number; @@ -61,10 +70,11 @@ interface ElevationChartProps { } export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { - const { t } = useTranslation(); + const { t } = useTranslation("planner"); const [points, setPoints] = useState([]); const [hoverIdx, setHoverIdx] = useState(null); const [colorMode, setColorMode] = useState("plain"); + const [surfaces, setSurfaces] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = points; @@ -79,6 +89,12 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { } const mode = yjs.routeData.get("colorMode") as ColorMode | undefined; setColorMode(mode ?? "plain"); + const surfacesJson = yjs.routeData.get("surfaces") as string | undefined; + if (surfacesJson) { + try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); } + } else { + setSurfaces([]); + } }; yjs.routeData.observe(update); update(); @@ -115,7 +131,36 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ctx.clearRect(0, 0, w, h); - if (colorMode === "elevation") { + if (colorMode === "grade") { + // Grade-colored segments: color by steepness + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]!; + const p1 = points[i + 1]!; + const dDist = p1.distance - p0.distance; + const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0; + const color = gradeColor(grade); + + // Fill segment + 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.replace(")", ", 0.25)").replace("rgb", "rgba").replace("#", ""); + // hex to rgba fill + ctx.fillStyle = color + "40"; + ctx.fill(); + + // Line segment + 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 === "elevation") { // Elevation-colored fill and line segments for (let i = 0; i < points.length - 1; i++) { const p0 = points[i]!; @@ -134,6 +179,30 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ctx.fill(); // Line segment + 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 === "surface" && surfaces.length >= points.length) { + // Surface-colored segments + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]!; + const p1 = points[i + 1]!; + const surface = surfaces[i] ?? "unknown"; + const color = SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_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)); @@ -226,13 +295,22 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { ctx.fillStyle = "#1f2937"; ctx.font = "bold 10px sans-serif"; ctx.textAlign = "left"; - const label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`; + let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`; + if (colorMode === "grade" && highlightIdx > 0) { + const prev = points[highlightIdx - 1]!; + const dDist = p.distance - prev.distance; + const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0; + label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`; + } + if (colorMode === "surface" && surfaces[highlightIdx]) { + label += ` · ${surfaces[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, days, t], + [points, colorMode, surfaces, days, t], ); useEffect(() => { @@ -281,11 +359,52 @@ export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { onHover?.(null); }, [onHover]); + const setMode = useCallback((mode: string) => { + yjs.routeData.set("colorMode", mode); + }, [yjs.routeData]); + if (points.length < 2) return null; return (
-

Elevation Profile

+
+

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

+
+ {colorMode === "grade" && (<> + {"<3%"} + {"<6%"} + {"<10%"} + {"<15%"} + {"15%+"} + )} + {colorMode === "elevation" && (<> + {Math.round(Math.min(...points.map(p => p.elevation)))}m + + {Math.round(Math.max(...points.map(p => p.elevation)))}m + )} + {colorMode === "surface" && surfaces.length > 0 && (<> + {[...new Set(surfaces)].slice(0, 6).map((s) => ( + + + {s} + + ))} + {[...new Set(surfaces)].length > 6 && +{[...new Set(surfaces)].length - 6}} + )} +
+ +
("plain"); - - useEffect(() => { - const update = () => { - setCurrent((yjs.routeData.get("colorMode") as string) ?? "plain"); - }; - yjs.routeData.observe(update); - update(); - return () => yjs.routeData.unobserve(update); - }, [yjs.routeData]); - - const setMode = (mode: string) => { - yjs.routeData.set("colorMode", mode); - }; - - return ( - - ); -} function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void }) { const { t } = useTranslation("planner"); @@ -260,7 +230,6 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, /> )} - {computing && ( {t("computingRoute")} )} diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 847b095..8dbd270 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -123,14 +123,23 @@ test.describe("Planner", () => { // pointer events needed for the distance-based snap detection. test("session has color mode toggle", async ({ page, request }) => { - const response = await request.post("/api/sessions", { data: {} }); - const { url } = await response.json(); + const sessionResp = await request.post("/api/sessions", { data: {} }); + const { url } = await sessionResp.json(); + + await mockBRouter(page); + + // Need a route for the elevation chart (and color selector) to appear + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, + ]))}`); - await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - const select = page.getByTitle("Route Color"); - await expect(select).toBeVisible(); + // 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(select).toHaveValue("plain"); // Switch to elevation diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 615af15..531e717 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -101,6 +101,8 @@ export default { plain: "Standard", elevation: "Höhe", surface: "Untergrund", + grade: "Steigung", + surfaceLegend: "Farbe nach Straßenbelag", surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar", }, rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.", @@ -108,6 +110,9 @@ export default { gain: "Höhenmeter aufwärts", loss: "Höhenmeter abwärts", profile: "Höhenprofil", + grade: "Steigungsprofil", + low: "Tief", + high: "Hoch", }, landing: { startPlanning: "Planung starten", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 5957029..800ada7 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -101,6 +101,8 @@ export default { plain: "Plain", elevation: "Elevation", surface: "Surface", + grade: "Grade", + surfaceLegend: "Color by road surface type", surfaceUnavailable: "Surface data not available for this profile", }, rateLimitExceeded: "Too many requests. Please try again later.", @@ -108,6 +110,9 @@ export default { gain: "Elevation Gain", loss: "Elevation Loss", profile: "Elevation Profile", + grade: "Grade Profile", + low: "Low", + high: "High", }, landing: { startPlanning: "Start Planning",