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:
commit
4c6351f161
6 changed files with 180 additions and 43 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue