trails/apps/planner/app/components/ElevationChart.tsx
Ullrich Schäfer 8cbaceb356
feat(planner): collapsible elevation chart with summary bar
Docked-but-collapsible placement (the chosen direction):

- A collapse toggle (chevron) in the chart header; state persists to
  localStorage.
- Collapsed = a thin summary bar: a mini sage sparkline + distance and
  ascent/descent + an expand toggle. Reclaims map space on demand.
- Summary figures come from the same authoritative routeStats the
  sidebar uses (distance, elevationGain, elevationLoss), so the numbers
  match the sidebar exactly — not recomputed from the raw elevation
  points (which over-counted ascent ~2x from noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:39:01 +02:00

611 lines
26 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { IconButton } from "@trails-cool/ui";
import type { DayStage } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
import {
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS,
DEFAULT_SMOOTHNESS_COLOR,
CYCLEWAY_COLORS,
DEFAULT_CYCLEWAY_COLOR,
} from "@trails-cool/map-core";
import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw";
import { useElevationData } from "~/lib/use-elevation-data";
import { Select } from "@trails-cool/ui";
import { setColorMode, type ColorMode } from "~/lib/route-data";
interface ElevationChartProps {
yjs: YjsState;
onHover?: (position: [number, number] | null) => void;
highlightDistance?: number | null;
onClickPosition?: (position: [number, number]) => void;
onDragSelect?: (bounds: [[number, number], [number, number]]) => void;
days?: DayStage[];
/** Authoritative route stats (same source as the sidebar) for the summary. */
routeStats?: { distance?: number; elevationGain?: number; elevationLoss?: number };
}
const COLLAPSE_KEY = "planner:elevationCollapsed";
const ChevronDown = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
);
const ChevronUp = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15" />
</svg>
);
/** Tiny inline elevation sparkline for the collapsed summary bar. */
function Sparkline({ points }: { points: Array<{ distance: number; elevation: number }> }) {
if (points.length < 2) return null;
const W = 72;
const H = 18;
const eles = points.map((p) => p.elevation);
const min = Math.min(...eles);
const range = Math.max(...eles) - min || 1;
const maxD = points[points.length - 1]!.distance || 1;
const d = points
.map((p) => `${((p.distance / maxD) * W).toFixed(1)},${(H - ((p.elevation - min) / range) * H).toFixed(1)}`)
.join(" ");
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0 text-accent" aria-hidden>
<polyline points={d} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days, routeStats }: ElevationChartProps) {
const { t } = useTranslation("planner");
const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData);
const [collapsed, setCollapsed] = useState(
() => typeof window !== "undefined" && localStorage.getItem(COLLAPSE_KEY) === "1",
);
const toggleCollapsed = useCallback(() => {
setCollapsed((c) => {
const next = !c;
try {
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
} catch {
/* ignore */
}
return next;
});
}, []);
// Collapsed-bar summary. Uses the authoritative routeStats (same source as
// the sidebar) so the figures match; falls back to the chart's own distance
// only when stats aren't available yet.
const distanceMeters = routeStats?.distance ?? (points.length ? points[points.length - 1]!.distance : 0);
const distanceKm = (distanceMeters / 1000).toFixed(1);
const ascent = routeStats?.elevationGain;
const descent = routeStats?.elevationLoss;
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isExternalHover = useRef(false);
const dragStartX = useRef<number | null>(null);
const dragStartClientX = useRef<number | null>(null);
const isDragging = useRef(false);
const dragCurrentX = useRef<number | null>(null);
const dragCleanup = useRef<(() => void) | null>(null);
// Detach any dangling window drag listeners on unmount.
useEffect(() => () => dragCleanup.current?.(), []);
// External highlight from map hover: find closest point by distance
useEffect(() => {
if (highlightDistance == null) {
if (isExternalHover.current) {
isExternalHover.current = false;
setHoverIdx(null);
}
return;
}
if (points.length < 2) return;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - highlightDistance);
if (diff < minDiff) {
minDiff = diff;
closest = i;
}
}
isExternalHover.current = true;
setHoverIdx(closest);
}, [highlightDistance, points]);
const draw = useCallback(
(highlightIdx: number | null) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
drawElevationChart(ctx, rect.width, rect.height, {
points,
colorMode,
surfaces,
highways,
maxspeeds,
smoothnesses,
tracktypes,
cycleways,
bikeroutes,
hoverIdx: highlightIdx,
isDragging: isDragging.current,
dragStartX: dragStartX.current,
dragCurrentX: dragCurrentX.current,
days,
});
},
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days],
);
useEffect(() => {
draw(hoverIdx);
}, [points, hoverIdx, colorMode, draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) return;
// Drag is tracked via window listeners (see handleMouseDown); ignore
// canvas moves while a press/drag gesture is active.
if (dragStartClientX.current != null) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (mouseX - PADDING.left) / chartW;
if (ratio < 0 || ratio > 1) {
setHoverIdx(null);
onHover?.(null);
return;
}
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) {
minDiff = diff;
closest = i;
}
}
isExternalHover.current = false;
setHoverIdx(closest);
const p = points[closest]!;
onHover?.([p.lat, p.lon]);
},
[points, onHover],
);
const handleMouseLeave = useCallback(() => {
// Keep an in-progress drag alive when the pointer leaves the canvas —
// the window listeners keep tracking it.
if (dragStartClientX.current != null) return;
setHoverIdx(null);
onHover?.(null);
}, [onHover]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
setHoverIdx(null);
onHover?.(null);
const clampX = (clientX: number) =>
Math.max(PADDING.left, Math.min(clientX - rect.left, PADDING.left + chartW));
// Track the drag on the window so it survives the pointer leaving the
// canvas and still completes if the button is released outside it.
const onWindowMove = (ev: MouseEvent) => {
if (dragStartClientX.current == null) return;
if (Math.abs(ev.clientX - dragStartClientX.current) > 5) {
isDragging.current = true;
dragCurrentX.current = clampX(ev.clientX);
draw(null);
}
};
const detach = () => {
window.removeEventListener("mousemove", onWindowMove);
window.removeEventListener("mouseup", onWindowUp);
dragCleanup.current = null;
};
function onWindowUp(ev: MouseEvent) {
detach();
if (points.length >= 2) {
const maxDist = points[points.length - 1]!.distance;
if (isDragging.current && dragStartX.current != null) {
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (clampX(ev.clientX) - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
let found = false;
for (const p of points) {
if (p.distance >= startDist && p.distance <= endDist) {
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
found = true;
}
}
if (found && onDragSelect) onDragSelect([[minLat, minLon], [maxLat, maxLon]]);
} else if (onClickPosition) {
const ratio = (ev.clientX - rect.left - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1) {
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) { minDiff = diff; closest = i; }
}
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
}
}
}
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
draw(null);
}
dragCleanup.current = detach;
window.addEventListener("mousemove", onWindowMove);
window.addEventListener("mouseup", onWindowUp);
},
[points, onHover, onDragSelect, onClickPosition, draw],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
if (e.touches.length === 1) {
const x = e.touches[0]!.clientX - rect.left;
dragStartX.current = x;
dragStartClientX.current = e.touches[0]!.clientX;
isDragging.current = false;
dragCurrentX.current = null;
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (x - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1 && points.length >= 2) {
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) { minDiff = diff; closest = i; }
}
isExternalHover.current = false;
setHoverIdx(closest);
const p = points[closest]!;
onHover?.([p.lat, p.lon]);
}
} else if (e.touches.length === 2) {
const x1 = e.touches[0]!.clientX - rect.left;
const x2 = e.touches[1]!.clientX - rect.left;
dragStartX.current = Math.min(x1, x2);
dragCurrentX.current = Math.max(x1, x2);
isDragging.current = true;
draw(hoverIdx);
}
},
[points, onHover, hoverIdx, draw],
);
const handleTouchMove = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas || points.length < 2) return;
const rect = canvas.getBoundingClientRect();
if (e.touches.length === 1 && !isDragging.current) {
const x = e.touches[0]!.clientX - rect.left;
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (x - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1) {
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) { minDiff = diff; closest = i; }
}
isExternalHover.current = false;
setHoverIdx(closest);
const p = points[closest]!;
onHover?.([p.lat, p.lon]);
}
} else if (e.touches.length === 2) {
const x1 = e.touches[0]!.clientX - rect.left;
const x2 = e.touches[1]!.clientX - rect.left;
dragStartX.current = Math.min(x1, x2);
dragCurrentX.current = Math.max(x1, x2);
isDragging.current = true;
draw(hoverIdx);
}
},
[points, onHover, hoverIdx, draw],
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) {
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
const maxDist = points[points.length - 1]!.distance;
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (dragCurrentX.current - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
let found = false;
for (const p of points) {
if (p.distance >= startDist && p.distance <= endDist) {
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
found = true;
}
}
if (found && onDragSelect) {
onDragSelect([[minLat, minLon], [maxLat, maxLon]]);
}
}
} else if (e.changedTouches.length === 1 && onClickPosition && dragStartClientX.current != null) {
const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current);
if (dx <= 10) {
const canvas = canvasRef.current;
if (canvas && points.length >= 2) {
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
const mouseX = e.changedTouches[0]!.clientX - rect.left;
const ratio = (mouseX - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1) {
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
const diff = Math.abs(points[i]!.distance - targetDist);
if (diff < minDiff) { minDiff = diff; closest = i; }
}
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
}
}
}
}
setHoverIdx(null);
onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
draw(null);
},
[points, onHover, onClickPosition, onDragSelect, draw],
);
const setMode = useCallback((mode: string) => {
setColorMode(yjs.routeData, mode as ColorMode);
}, [yjs.routeData]);
if (points.length < 2) return null;
if (collapsed) {
return (
<div className="flex items-center gap-3 border-t border-border px-4 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<Sparkline points={points} />
<span className="font-mono text-xs text-text-md">
{distanceKm} km
{ascent !== undefined && (
<>
<span className="mx-1.5 text-text-lo">·</span> {ascent} m
</>
)}
{descent !== undefined && (
<>
<span className="mx-1.5 text-text-lo">·</span> {descent} m
</>
)}
</span>
<div className="ml-auto">
<IconButton size="sm" label={t("elevation.expand", "Expand elevation profile")} onClick={toggleCollapsed}>
<ChevronUp />
</IconButton>
</div>
</div>
);
}
return (
<div className="border-t border-border px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<div className="mb-1 flex items-center gap-2 px-2">
{(() => {
const osmLinks: Record<string, string> = {
highway: "https://wiki.openstreetmap.org/wiki/Key:highway",
maxspeed: "https://wiki.openstreetmap.org/wiki/Key:maxspeed",
surface: "https://wiki.openstreetmap.org/wiki/Key:surface",
smoothness: "https://wiki.openstreetmap.org/wiki/Key:smoothness",
tracktype: "https://wiki.openstreetmap.org/wiki/Key:tracktype",
cycleway: "https://wiki.openstreetmap.org/wiki/Key:cycleway",
bikeroute: "https://wiki.openstreetmap.org/wiki/Tag:route%3Dbicycle",
};
const titles: Record<string, string> = {
grade: t("elevation.grade"),
highway: t("elevation.highway"),
maxspeed: t("elevation.maxspeed"),
smoothness: t("elevation.smoothness"),
tracktype: t("elevation.tracktype"),
cycleway: t("elevation.cycleway"),
bikeroute: t("elevation.bikeroute"),
};
const title = titles[colorMode] ?? t("elevation.profile");
const link = osmLinks[colorMode];
return link ? (
<a href={link} target="_blank" rel="noopener" className="shrink-0 text-xs font-medium text-text-md hover:text-accent hover:underline">
{title}
</a>
) : (
<p className="shrink-0 text-xs font-medium text-text-md">{title}</p>
);
})()}
<div className="flex flex-1 items-center justify-center gap-1.5 text-[10px] text-text-lo">
{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>}
</>)}
{colorMode === "highway" && highways.length > 0 && (<>
{[...new Set(highways)].slice(0, 6).map((h) => (
<span key={h} className="flex items-center gap-0.5">
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: HIGHWAY_COLORS[h] ?? DEFAULT_HIGHWAY_COLOR }} />
{h}
</span>
))}
{[...new Set(highways)].length > 6 && <span>+{[...new Set(highways)].length - 6}</span>}
</>)}
{colorMode === "maxspeed" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />{"≤30"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />{"≤50"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />{"≤70"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />{"≤100"}</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#991b1b" }} />{"100+"}</span>
</>)}
{colorMode === "smoothness" && smoothnesses.length > 0 && (<>
{[...new Set(smoothnesses)].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: SMOOTHNESS_COLORS[s] ?? DEFAULT_SMOOTHNESS_COLOR }} />
{s}
</span>
))}
{[...new Set(smoothnesses)].length > 6 && <span>+{[...new Set(smoothnesses)].length - 6}</span>}
</>)}
{colorMode === "tracktype" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#22c55e" }} />grade1</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#84cc16" }} />grade2</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#eab308" }} />grade3</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#f97316" }} />grade4</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#ef4444" }} />grade5</span>
</>)}
{colorMode === "cycleway" && cycleways.length > 0 && (<>
{[...new Set(cycleways)].slice(0, 6).map((c) => (
<span key={c} className="flex items-center gap-0.5">
<span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: CYCLEWAY_COLORS[c] ?? DEFAULT_CYCLEWAY_COLOR }} />
{c}
</span>
))}
{[...new Set(cycleways)].length > 6 && <span>+{[...new Set(cycleways)].length - 6}</span>}
</>)}
{colorMode === "bikeroute" && (<>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#7c3aed" }} />International</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#2563eb" }} />National</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#0891b2" }} />Regional</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#059669" }} />Local</span>
<span className="flex items-center gap-0.5"><span className="inline-block h-1.5 w-2.5 rounded-sm" style={{ background: "#d4d4d8" }} />None</span>
</>)}
</div>
<Select
size="sm"
value={colorMode}
onChange={(e) => setMode(e.target.value)}
aria-label={t("elevation.profile")}
className="shrink-0"
>
<option value="plain">{t("colorMode.plain")}</option>
<option value="elevation">{t("colorMode.elevation")}</option>
<option value="grade">{t("colorMode.grade")}</option>
<option value="surface">{t("colorMode.surface")}</option>
<option value="highway">{t("colorMode.highway")}</option>
<option value="maxspeed">{t("colorMode.maxspeed")}</option>
<option value="smoothness">{t("colorMode.smoothness")}</option>
<option value="tracktype">{t("colorMode.tracktype")}</option>
<option value="cycleway">{t("colorMode.cycleway")}</option>
<option value="bikeroute">{t("colorMode.bikeroute")}</option>
</Select>
<IconButton
size="sm"
label={t("elevation.collapse", "Collapse elevation profile")}
onClick={toggleCollapsed}
className="shrink-0"
>
<ChevronDown />
</IconButton>
</div>
<canvas
ref={canvasRef}
className="h-24 w-full cursor-crosshair touch-none"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
</div>
);
}