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..bd62d66 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -4,6 +4,15 @@ import type { DayStage } from "@trails-cool/gpx"; import type { YjsState } from "~/lib/use-yjs"; import { elevationColor, 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; elevation: number; @@ -61,7 +70,7 @@ 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"); @@ -115,7 +124,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]!; @@ -226,7 +264,13 @@ 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)}%`; + } 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); @@ -281,11 +325,29 @@ 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")} +

+ +
("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/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 615af15..fb7727f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -101,6 +101,7 @@ export default { plain: "Standard", elevation: "Höhe", surface: "Untergrund", + grade: "Steigung", surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar", }, rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.", @@ -108,6 +109,7 @@ export default { gain: "Höhenmeter aufwärts", loss: "Höhenmeter abwärts", profile: "Höhenprofil", + grade: "Steigungsprofil", }, landing: { startPlanning: "Planung starten", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 5957029..9ac8127 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -101,6 +101,7 @@ export default { plain: "Plain", elevation: "Elevation", surface: "Surface", + grade: "Grade", surfaceUnavailable: "Surface data not available for this profile", }, rateLimitExceeded: "Too many requests. Please try again later.", @@ -108,6 +109,7 @@ export default { gain: "Elevation Gain", loss: "Elevation Loss", profile: "Elevation Profile", + grade: "Grade Profile", }, landing: { startPlanning: "Start Planning",