Move color mode selector to elevation chart, add grade profile

- Moved color mode dropdown from header bar to inline with the
  elevation chart title — closer to the visualization it controls
- Removed ColorModeToggle component from SessionView header
- Added "Grade" color mode: colors route and elevation chart by
  steepness (green <3%, yellow <6%, orange <10%, red <15%, dark red 15%+)
- Grade hover label shows percentage (e.g. "+4.2%")
- Fixed useTranslation namespace in ElevationChart ("planner")
- i18n: Added grade keys for en + de

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 03:13:54 +02:00
parent 425f1643b9
commit da1a5248d6
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 101 additions and 36 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

@ -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<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>("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 (
<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 justify-between px-2">
<p className="text-xs font-medium text-gray-500">
{colorMode === "grade" ? t("elevation.grade") : t("elevation.profile")}
</p>
<select
value={colorMode}
onChange={(e) => setMode(e.target.value)}
className="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

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

View file

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