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 { 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[]; } export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) { const { t } = useTranslation("planner"); const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData); const [hoverIdx, setHoverIdx] = useState(null); const canvasRef = useRef(null); const isExternalHover = useRef(false); const dragStartX = useRef(null); const dragStartClientX = useRef(null); const isDragging = useRef(false); const dragCurrentX = useRef(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); }, [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) => { 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 (dragStartClientX.current != null) { const dx = Math.abs(e.clientX - dragStartClientX.current); if (dx > 5) { isDragging.current = true; dragCurrentX.current = mouseX; draw(hoverIdx); return; } } 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, hoverIdx, draw], ); 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) => { 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) => { 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) { 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; 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) { 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; } } onClickPosition([points[closest]!.lat, points[closest]!.lon]); } } } dragStartX.current = null; dragStartClientX.current = null; isDragging.current = false; dragCurrentX.current = null; draw(hoverIdx); }, [points, onClickPosition, onDragSelect, hoverIdx, draw], ); const handleTouchStart = useCallback( (e: React.TouchEvent) => { 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) => { 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) => { 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; return (
{(() => { const osmLinks: Record = { 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 = { 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 ? ( {title} ) : (

{title}

); })()}
{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+"} )} {colorMode === "smoothness" && smoothnesses.length > 0 && (<> {[...new Set(smoothnesses)].slice(0, 6).map((s) => ( {s} ))} {[...new Set(smoothnesses)].length > 6 && +{[...new Set(smoothnesses)].length - 6}} )} {colorMode === "tracktype" && (<> grade1 grade2 grade3 grade4 grade5 )} {colorMode === "cycleway" && cycleways.length > 0 && (<> {[...new Set(cycleways)].slice(0, 6).map((c) => ( {c} ))} {[...new Set(cycleways)].length > 6 && +{[...new Set(cycleways)].length - 6}} )} {colorMode === "bikeroute" && (<> International National Regional Local None )}
); }