trails/apps/planner/app/components/ElevationChart.tsx
Ullrich Schäfer ca48284252
Fix iOS safe area: header respects notch, chart respects home indicator
- viewport-fit=cover enables safe area insets in Safari
- Header: pt-[max(0.5rem,env(safe-area-inset-top))] clears the notch
- Elevation chart: pb-[max(0.5rem,env(safe-area-inset-bottom))] clears
  the home indicator bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:34:25 +02:00

1013 lines
42 KiB
TypeScript

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, maxspeedColor,
SURFACE_COLORS, DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS, DEFAULT_SMOOTHNESS_COLOR,
TRACKTYPE_COLORS, DEFAULT_TRACKTYPE_COLOR,
CYCLEWAY_COLORS, DEFAULT_CYCLEWAY_COLOR,
BIKEROUTE_COLORS, DEFAULT_BIKEROUTE_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;
elevation: number;
lat: number;
lon: number;
}
function extractElevation(geojsonStr: string): ElevationPoint[] {
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length === 0) return [];
const points: ElevationPoint[] = [];
let totalDist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
const prev = coords[i - 1]!;
const curr = coords[i]!;
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
}
if (coords[i]![2] !== undefined) {
points.push({
distance: totalDist,
elevation: coords[i]![2]!,
lat: coords[i]![1]!,
lon: coords[i]![0]!,
});
}
}
return points;
} catch {
return [];
}
}
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
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[];
}
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) {
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 [highways, setHighways] = useState<string[]>([]);
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
const [tracktypes, setTracktypes] = useState<string[]>([]);
const [cycleways, setCycleways] = useState<string[]>([]);
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
pointsRef.current = points;
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);
// 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);
// Do NOT call onHover here to avoid feedback loop
}, [highlightDistance, points]);
useEffect(() => {
const update = () => {
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
setPoints(extractElevation(geojson));
} else {
setPoints([]);
}
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([]);
}
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
if (highwaysJson) {
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
} else {
setHighways([]);
}
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
if (maxspeedsJson) {
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
} else {
setMaxspeeds([]);
}
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
if (smoothnessesJson) {
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
} else {
setSmoothnesses([]);
}
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
if (tracktypesJson) {
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
} else {
setTracktypes([]);
}
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
if (cyclewaysJson) {
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
} else {
setCycleways([]);
}
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
if (bikeroutesJson) {
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
} else {
setBikeroutes([]);
}
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const drawChart = 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);
const w = rect.width;
const h = rect.height;
const chartW = w - PADDING.left - PADDING.right;
const chartH = h - PADDING.top - PADDING.bottom;
const maxDist = points[points.length - 1]!.distance;
const elevations = points.map((p) => p.elevation);
const minEle = Math.min(...elevations);
const maxEle = Math.max(...elevations);
const eleRange = maxEle - minEle || 1;
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
ctx.clearRect(0, 0, w, h);
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]!;
const p1 = points[i + 1]!;
const t = (p0.elevation - minEle) / eleRange;
const color = elevationColor(t);
// 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("rgb", "rgba").replace(")", ", 0.2)");
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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "highway" && highways.length >= points.length) {
// Highway-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const highway = highways[i] ?? "unknown";
const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
// Maxspeed-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const speed = maxspeeds[i] ?? "unknown";
const color = maxspeedColor(speed);
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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
// Smoothness-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const smoothness = smoothnesses[i] ?? "unknown";
const color = SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
// Track type-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const tracktype = tracktypes[i] ?? "unknown";
const color = TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
// Cycleway-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const cycleway = cycleways[i] ?? "unknown";
const color = CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
// Bike route-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const bikeroute = bikeroutes[i] ?? "none";
const color = BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_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));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else {
// Plain fill and line
ctx.beginPath();
ctx.moveTo(PADDING.left, PADDING.top + chartH);
for (const p of points) {
ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
ctx.fill();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
else ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Axis labels
ctx.fillStyle = "#6b7280";
ctx.font = "10px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
ctx.textAlign = "center";
ctx.fillText("0 km", PADDING.left, h - 4);
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
// Day dividers
if (days && days.length > 1) {
for (let d = 0; d < days.length - 1; d++) {
// Find the point closest to the day boundary distance
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
const bx = toX(boundaryDist);
// Dashed vertical line
ctx.beginPath();
ctx.setLineDash([4, 3]);
ctx.moveTo(bx, PADDING.top);
ctx.lineTo(bx, PADDING.top + chartH);
ctx.strokeStyle = "#9A9484";
ctx.lineWidth = 1;
ctx.stroke();
ctx.setLineDash([]);
// Day label at top
ctx.fillStyle = "#9A9484";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
}
}
// Hover crosshair + info
if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
const p = points[highlightIdx]!;
const hx = toX(p.distance);
const hy = toY(p.elevation);
// Vertical line
ctx.beginPath();
ctx.moveTo(hx, PADDING.top);
ctx.lineTo(hx, PADDING.top + chartH);
ctx.strokeStyle = "rgba(239, 68, 68, 0.5)";
ctx.lineWidth = 1;
ctx.stroke();
// Dot
ctx.beginPath();
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.stroke();
// Info label
ctx.fillStyle = "#1f2937";
ctx.font = "bold 10px sans-serif";
ctx.textAlign = "left";
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]}`;
}
if (colorMode === "highway" && highways[highlightIdx]) {
label += ` · ${highways[highlightIdx]}`;
}
if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) {
const s = maxspeeds[highlightIdx]!;
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
}
if (colorMode === "smoothness" && smoothnesses[highlightIdx]) {
label += ` · ${smoothnesses[highlightIdx]}`;
}
if (colorMode === "tracktype" && tracktypes[highlightIdx]) {
label += ` · ${tracktypes[highlightIdx]}`;
}
if (colorMode === "cycleway" && cycleways[highlightIdx]) {
label += ` · ${cycleways[highlightIdx]}`;
}
if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) {
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
label += ` · ${names[bikeroutes[highlightIdx]!] ?? bikeroutes[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);
}
// Drag selection overlay
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null) {
const x1 = Math.max(PADDING.left, Math.min(dragStartX.current, PADDING.left + chartW));
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX.current, PADDING.left + chartW));
const selLeft = Math.min(x1, x2);
const selRight = Math.max(x1, x2);
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
}
},
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
);
useEffect(() => {
drawChart(hoverIdx);
}, [points, hoverIdx, colorMode, drawChart]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) 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;
// Handle drag selection
if (dragStartClientX.current != null) {
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx > 5) {
isDragging.current = true;
dragCurrentX.current = mouseX;
drawChart(hoverIdx);
return;
}
}
if (ratio < 0 || ratio > 1) {
setHoverIdx(null);
onHover?.(null);
return;
}
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
// Find closest point
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, hoverIdx, drawChart],
);
const handleMouseLeave = useCallback(() => {
setHoverIdx(null);
onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
}, [onHover]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
},
[],
);
const handleMouseUp = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) {
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
return;
}
const rect = canvas.getBoundingClientRect();
const chartW = rect.width - PADDING.left - PADDING.right;
const maxDist = points[points.length - 1]!.distance;
if (isDragging.current && dragStartX.current != null) {
// Drag-select: compute bounding box of selected range
const endX = e.clientX - rect.left;
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (endX - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
// Find all points in the selected distance range
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 (dragStartClientX.current != null) {
// Click (not drag): pan map to clicked point
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx <= 5 && onClickPosition) {
const mouseX = e.clientX - rect.left;
const ratio = (mouseX - 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;
}
}
const p = points[closest]!;
onClickPosition([p.lat, p.lon]);
}
}
}
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
drawChart(hoverIdx);
},
[points, onClickPosition, onDragSelect, hoverIdx, drawChart],
);
// Touch handlers for mobile
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) {
// Single touch: start scrub + potential drag
const x = e.touches[0]!.clientX - rect.left;
dragStartX.current = x;
dragStartClientX.current = e.touches[0]!.clientX;
isDragging.current = false;
dragCurrentX.current = null;
// Immediately show highlight at touch position
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) {
// Two fingers: range select
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;
drawChart(hoverIdx);
}
},
[points, onHover, hoverIdx, drawChart],
);
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) {
// Single touch scrub: update highlight
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) {
// Two finger range select: update selection
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;
drawChart(hoverIdx);
}
},
[points, onHover, hoverIdx, drawChart],
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) {
// Two finger range complete: zoom map
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) {
// Single tap (no significant movement): pan map
const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current);
if (dx <= 10) {
// Treat as tap → pan
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]);
}
}
}
}
// Clear highlight on touch end
setHoverIdx(null);
onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
drawChart(null);
},
[points, onHover, onClickPosition, onDragSelect, drawChart],
);
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 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-gray-500 hover:text-blue-600 hover:underline">
{title}
</a>
) : (
<p className="shrink-0 text-xs font-medium text-gray-500">{title}</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>}
</>)}
{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
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="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>
</div>
<canvas
ref={canvasRef}
className="h-24 w-full cursor-crosshair touch-none"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
</div>
);
}