Merge pull request #205 from trails-cool/feat/elevation-improvements

Move color selector to chart, add grade profile and surface coloring
This commit is contained in:
Ullrich Schäfer 2026-04-11 03:24:32 +02:00 committed by GitHub
commit 4c6351f161
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 180 additions and 43 deletions

View file

@ -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<string, string> = {
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;

View file

@ -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<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const [surfaces, setSurfaces] = useState<string[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
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 (
<div className="border-t border-gray-200 px-2 py-2">
<p className="mb-1 px-2 text-xs font-medium text-gray-500">Elevation Profile</p>
<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>
<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>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />{"<6%"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />{"<10%"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />{"<15%"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#991b1b" }} />{"15%+"}</span>
</>)}
{colorMode === "elevation" && (<>
<span>{Math.round(Math.min(...points.map(p => p.elevation)))}m</span>
<span className="inline-block h-1.5 w-16 rounded-sm" style={{ background: "linear-gradient(to right, rgb(0, 200, 50), rgb(255, 200, 50), rgb(255, 0, 50))" }} />
<span>{Math.round(Math.max(...points.map(p => p.elevation)))}m</span>
</>)}
{colorMode === "surface" && surfaces.length > 0 && (<>
{[...new Set(surfaces)].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: SURFACE_COLORS[s] ?? DEFAULT_SURFACE_COLOR }} />
{s}
</span>
))}
{[...new Set(surfaces)].length > 6 && <span>+{[...new Set(surfaces)].length - 6}</span>}
</>)}
</div>
<select
value={colorMode}
onChange={(e) => setMode(e.target.value)}
className="shrink-0 rounded border border-gray-200 px-1.5 py-0.5 text-[11px] text-gray-500"
>
<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>
</select>
</div>
<canvas
ref={canvasRef}
className="h-24 w-full cursor-crosshair"

View file

@ -111,36 +111,6 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (messa
}, [yjs, t, addToast]);
}
function ColorModeToggle({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const [current, setCurrent] = useState<string>("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 (
<select
value={current}
onChange={(e) => setMode(e.target.value)}
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700"
title={t("colorMode.label")}
>
<option value="plain">{t("colorMode.plain")}</option>
<option value="elevation">{t("colorMode.elevation")}</option>
<option value="surface">{t("colorMode.surface")}</option>
</select>
);
}
function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"]; days: ReturnType<typeof useDays>; onWaypointHover: (index: number | null) => void }) {
const { t } = useTranslation("planner");
@ -260,7 +230,6 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
/>
)}
<ExportButton yjs={yjs} />
<ColorModeToggle yjs={yjs} />
{computing && (
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
)}

View file

@ -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

View file

@ -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",

View file

@ -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",