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, 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; days?: DayStage[]; } export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) { const { t } = useTranslation("planner"); const [points, setPoints] = useState([]); const [hoverIdx, setHoverIdx] = useState(null); const [colorMode, setColorMode] = useState("plain"); const [surfaces, setSurfaces] = useState([]); const [highways, setHighways] = useState([]); const [maxspeeds, setMaxspeeds] = useState([]); const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = 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([]); } }; 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 { // 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`}`; } 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, surfaces, highways, maxspeeds, days, t], ); useEffect(() => { drawChart(hoverIdx); }, [points, hoverIdx, colorMode, drawChart]); const handleMouseMove = useCallback( (e: React.MouseEvent) => { 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; 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; } } setHoverIdx(closest); const p = points[closest]!; onHover?.([p.lat, p.lon]); }, [points, onHover], ); const handleMouseLeave = useCallback(() => { setHoverIdx(null); onHover?.(null); }, [onHover]); const setMode = useCallback((mode: string) => { yjs.routeData.set("colorMode", mode); }, [yjs.routeData]); if (points.length < 2) return null; return (

{colorMode === "grade" ? t("elevation.grade") : colorMode === "highway" ? t("elevation.highway") : colorMode === "maxspeed" ? t("elevation.maxspeed") : t("elevation.profile")}

{colorMode === "grade" && (<> {"<3%"} {"<6%"} {"<10%"} {"<15%"} {"15%+"} )} {colorMode === "elevation" && (<> {Math.round(Math.min(...points.map(p => p.elevation)))}m {Math.round(Math.max(...points.map(p => p.elevation)))}m )} {colorMode === "surface" && surfaces.length > 0 && (<> {[...new Set(surfaces)].slice(0, 6).map((s) => ( {s} ))} {[...new Set(surfaces)].length > 6 && +{[...new Set(surfaces)].length - 6}} )} {colorMode === "highway" && highways.length > 0 && (<> {[...new Set(highways)].slice(0, 6).map((h) => ( {h} ))} {[...new Set(highways)].length > 6 && +{[...new Set(highways)].length - 6}} )} {colorMode === "maxspeed" && (<> {"≤30"} {"≤50"} {"≤70"} {"≤100"} {"100+"} )}
); }