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>
611 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|