From e76494e0837ca99d197d23cd56132a4f05d366a2 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 10 Mar 2026 08:45:22 +0100 Subject: [PATCH 1/2] hover route to show path details or elevation --- src/App.tsx | 2 +- src/layers/DefaultMapPopup.module.css | 48 ++++++ src/layers/PathDetailPopup.tsx | 41 +++-- src/layers/UsePathDetailsLayer.tsx | 146 +++++++++++++++++- src/pathDetails/ElevationInfoBar.tsx | 18 +++ .../elevationWidget/ChartRenderer.ts | 31 ++-- .../elevationWidget/pathDetailData.ts | 3 +- src/stores/PathDetailsStore.ts | 3 + 8 files changed, 264 insertions(+), 28 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8263d0ad..9004519f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -126,7 +126,7 @@ export default function App() { usePathsLayer(map, route.routingResult.paths, route.selectedPath, query.queryPoints, showPaths) useQueryPointsLayer(map, query.queryPoints) const [activeDetail, setActiveDetail] = useState(null) - usePathDetailsLayer(map, pathDetails, activeDetail, showPaths) + usePathDetailsLayer(map, pathDetails, activeDetail, showPaths, route.selectedPath.points.coordinates) usePOIsLayer(map, pois) useCurrentLocationLayer(map, currentLocation) diff --git a/src/layers/DefaultMapPopup.module.css b/src/layers/DefaultMapPopup.module.css index 511c6a04..3f1c504f 100644 --- a/src/layers/DefaultMapPopup.module.css +++ b/src/layers/DefaultMapPopup.module.css @@ -36,3 +36,51 @@ left: 48px; margin-left: -11px; } +.detailPopup { + position: absolute; + bottom: 12px; + left: 0; + transform: translateX(-50%); + background-color: white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #cccccc; + font-size: 13px; + line-height: 1.4; + white-space: nowrap; + color: #333; +} +.detailPopup:after, +.detailPopup:before { + top: 100%; + border: solid transparent; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} +.detailPopup:after { + border-top-color: white; + border-width: 6px; + left: 50%; + margin-left: -6px; +} +.detailPopup:before { + border-top-color: #cccccc; + border-width: 7px; + left: 50%; + margin-left: -7px; +} +.detailPopupValue { + font-weight: 600; +} +.colorDot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 1.5px; + margin-right: 4px; + vertical-align: middle; +} diff --git a/src/layers/PathDetailPopup.tsx b/src/layers/PathDetailPopup.tsx index f07203da..26295b83 100644 --- a/src/layers/PathDetailPopup.tsx +++ b/src/layers/PathDetailPopup.tsx @@ -16,22 +16,35 @@ interface PathDetailPopupProps { */ export default function PathDetailPopup({ map, pathDetails }: PathDetailPopupProps) { const settings = useContext(SettingsContext) + const p = pathDetails.pathDetailsPoint + const miles = settings.showDistanceInMiles return ( - // todo: use createMapMarker from heightgraph? - // {createMapMarker(point.elevation, point.description, showDistanceInMiles)} - -
- {pathDetails.pathDetailsPoint && ( -

- {metersToText( - Math.round(pathDetails.pathDetailsPoint.elevation), - settings.showDistanceInMiles, - true, + +

+ {p && (p.description ? ( + <> +
+ {p.color && } + {p.description} +
+ {p.distance != null && ( +
{metersToText(Math.round(p.distance), miles)}
)} -
- {pathDetails.pathDetailsPoint!.description} -

- )} + + ) : ( + <> +
{metersToText(Math.round(p.elevation), miles, true)}
+ {p.incline != null && ( +
+ + {p.incline >= 0 ? '+' : ''}{Math.round(p.incline * 10) / 10} % +
+ )} + {p.distance != null && ( +
{metersToText(Math.round(p.distance), miles)}
+ )} + + ))}
) diff --git a/src/layers/UsePathDetailsLayer.tsx b/src/layers/UsePathDetailsLayer.tsx index 790b7cb4..697eb87f 100644 --- a/src/layers/UsePathDetailsLayer.tsx +++ b/src/layers/UsePathDetailsLayer.tsx @@ -1,14 +1,17 @@ import { Map } from 'ol' -import { useEffect } from 'react' +import type { MapBrowserEvent } from 'ol' +import { useEffect, useRef } from 'react' import { PathDetailsStoreState } from '@/stores/PathDetailsStore' import { FeatureCollection } from 'geojson' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' import { Stroke, Style } from 'ol/style' import { GeoJSON } from 'ol/format' -import { fromLonLat } from 'ol/proj' +import { fromLonLat, toLonLat } from 'ol/proj' import { Coordinate } from '@/utils' import { ChartPathDetail } from '@/pathDetails/elevationWidget/types' +import Dispatcher from '@/stores/Dispatcher' +import { PathDetailsHover } from '@/actions/Actions' const highlightedPathSegmentLayerKey = 'highlightedPathSegmentLayer' const activeDetailLayerKey = 'activeDetailLayer' @@ -22,6 +25,7 @@ export default function usePathDetailsLayer( pathDetails: PathDetailsStoreState, activeDetail: ChartPathDetail | null = null, showPaths: boolean = true, + pathCoordinates: number[][] = [], ) { // Highlighted segments (elevation threshold) useEffect(() => { @@ -43,9 +47,144 @@ export default function usePathDetailsLayer( } }, [map, activeDetail, showPaths]) + // Pre-compute cumulative distances for the path coordinates + const cumDistRef = useRef([]) + useEffect(() => { + if (pathCoordinates.length === 0) { + cumDistRef.current = [] + return + } + const distances = [0] + for (let i = 1; i < pathCoordinates.length; i++) { + const [lng1, lat1] = pathCoordinates[i - 1] + const [lng2, lat2] = pathCoordinates[i] + const toRad = (deg: number) => deg * 0.017453292519943295 + const dLat = toRad(lat2 - lat1) + const dLon = toRad(lng2 - lng1) + const x = Math.cos(toRad((lat1 + lat2) / 2)) * dLon + distances.push(distances[i - 1] + 6371000 * Math.sqrt(dLat * dLat + x * x)) + } + cumDistRef.current = distances + }, [pathCoordinates]) + + // Hover interaction on active detail layer + const isHoveringRef = useRef(false) + const rafRef = useRef(0) + useEffect(() => { + if (!activeDetail || !showPaths || pathCoordinates.length === 0) return + + const handler = (e: MapBrowserEvent) => { + cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(() => { + const activeLayer = map + .getLayers() + .getArray() + .find(l => l.get(activeDetailLayerKey)) + if (!activeLayer) return + + const features = map.getFeaturesAtPixel(e.pixel, { + layerFilter: l => l === activeLayer, + hitTolerance: 5, + }) + + if (features.length > 0) { + const feature = features[0] + const value = feature.get('value') + const isIncline = activeDetail.key === '_incline' + const description = value != null && !isIncline ? String(value) : '' + const featureColor = feature.get('color') as string | undefined + + // Use segment distance bounds to narrow the coordinate search + const fromDist = feature.get('fromDistance') as number | undefined + const toDist = feature.get('toDistance') as number | undefined + + const [lng, lat] = toLonLat(e.coordinate) + const cumDist = cumDistRef.current + const searchFrom = fromDist != null ? Math.max(0, binarySearchDist(cumDist, fromDist) - 1) : 0 + const searchTo = toDist != null ? Math.min(pathCoordinates.length - 1, binarySearchDist(cumDist, toDist) + 1) : pathCoordinates.length - 1 + const nearest = findNearestPoint(lng, lat, pathCoordinates, cumDist, searchFrom, searchTo) + + Dispatcher.dispatch( + new PathDetailsHover({ + point: { lng, lat }, + elevation: nearest.elevation, + description, + distance: nearest.distance, + incline: nearest.incline, + color: featureColor, + }) + ) + isHoveringRef.current = true + } else if (isHoveringRef.current) { + Dispatcher.dispatch(new PathDetailsHover(null)) + isHoveringRef.current = false + } + }) + } + + map.on('pointermove', handler as any) + map.on('click', handler as any) + return () => { + map.un('pointermove', handler as any) + map.un('click', handler as any) + cancelAnimationFrame(rafRef.current) + if (isHoveringRef.current) { + Dispatcher.dispatch(new PathDetailsHover(null)) + isHoveringRef.current = false + } + } + }, [map, activeDetail, showPaths, pathCoordinates]) + return } +function binarySearchDist(cumDist: number[], target: number): number { + let lo = 0 + let hi = cumDist.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (cumDist[mid] < target) lo = mid + 1 + else hi = mid + } + return lo +} + +function findNearestPoint( + lng: number, + lat: number, + coordinates: number[][], + cumDist: number[], + from: number, + to: number, +): { elevation: number; distance: number; incline: number | undefined } { + let minDist = Infinity + let bestIdx = from + for (let i = from; i <= to; i++) { + const dlng = coordinates[i][0] - lng + const dlat = coordinates[i][1] - lat + const dist = dlng * dlng + dlat * dlat + if (dist < minDist) { + minDist = dist + bestIdx = i + } + } + + const elevation = coordinates[bestIdx][2] || 0 + const distance = cumDist[bestIdx] || 0 + + let incline: number | undefined + if (coordinates.length >= 2) { + const a = bestIdx > 0 ? bestIdx - 1 : bestIdx + const b = bestIdx > 0 ? bestIdx : Math.min(bestIdx + 1, coordinates.length - 1) + const segDist = (cumDist[b] || 0) - (cumDist[a] || 0) + if (segDist > 0) { + incline = (((coordinates[b][2] || 0) - (coordinates[a][2] || 0)) / segDist) * 100 + } + } + + return { elevation, distance, incline } +} + function removeLayer(map: Map, key: string) { map.getLayers() .getArray() @@ -89,6 +228,9 @@ function addActiveDetailLayer(map: Map, detail: ChartPathDetail) { }, properties: { color: seg.color, + value: seg.value, + fromDistance: seg.fromDistance, + toDistance: seg.toDistance, }, })) diff --git a/src/pathDetails/ElevationInfoBar.tsx b/src/pathDetails/ElevationInfoBar.tsx index eb2ba637..7f778783 100644 --- a/src/pathDetails/ElevationInfoBar.tsx +++ b/src/pathDetails/ElevationInfoBar.tsx @@ -4,6 +4,7 @@ import { SettingsContext } from '@/contexts/SettingsContext' import Dispatcher from '@/stores/Dispatcher' import { PathDetailsHover } from '@/actions/Actions' import { buildChartData, buildInclineDetail } from './elevationWidget/pathDetailData' +import { getSlopeColor } from './elevationWidget/colors' import { ChartHoverResult, ChartPathDetail } from './elevationWidget/types' import ElevationWidget from './elevationWidget/ElevationWidget' import { tr } from '@/translation/Translation' @@ -57,16 +58,33 @@ export default function ElevationInfoBar({ }, [selectedDropdownDetail, inclineOnMap, inclineDetail, onActiveDetailChanged]) const hoverRaf = useRef(0) + const chartDataRef = useRef(chartData) + chartDataRef.current = chartData const handleHover = useCallback((result: ChartHoverResult | null) => { cancelAnimationFrame(hoverRaf.current) hoverRaf.current = requestAnimationFrame(() => { if (result) { const description = result.segment ? String(result.segment.value) : '' + const color = result.segment?.color + const elev = chartDataRef.current?.elevation + let incline: number | undefined + if (elev && elev.length >= 2) { + const i = result.elevationIndex + const a = i > 0 ? i - 1 : i + const b = i > 0 ? i : i + 1 + const dist = elev[b].distance - elev[a].distance + if (dist > 0) { + incline = ((elev[b].elevation - elev[a].elevation) / dist) * 100 + } + } Dispatcher.dispatch( new PathDetailsHover({ point: result.point, elevation: result.elevation, description, + distance: result.distance, + incline, + color: color ?? (incline != null ? getSlopeColor(incline) : undefined), }), ) } else { diff --git a/src/pathDetails/elevationWidget/ChartRenderer.ts b/src/pathDetails/elevationWidget/ChartRenderer.ts index 85acb048..87667fcc 100644 --- a/src/pathDetails/elevationWidget/ChartRenderer.ts +++ b/src/pathDetails/elevationWidget/ChartRenderer.ts @@ -175,15 +175,15 @@ export default class ChartRenderer { // Build tooltip lines const miles = this.config.showDistanceInMiles const elev = this.data.elevation - const lines: string[] = [] + const lines: { text: string; dotColor?: string }[] = [] if (hit.segment) { // When a path detail is selected, show its value + distance - lines.push(String(hit.segment.value)) - lines.push(`distance: ${formatDistanceLabel(hit.distance, miles)}`) + lines.push({ text: String(hit.segment.value), dotColor: hit.segment.color }) + lines.push({ text: `distance: ${formatDistanceLabel(hit.distance, miles)}` }) } else { // Default: elevation, incline, distance - lines.push(`elevation: ${formatElevationLabel(hit.elevation, miles)}`) + lines.push({ text: `elevation: ${formatElevationLabel(hit.elevation, miles)}` }) // Compute incline between the two surrounding elevation points const i = hit.elevationIndex @@ -194,18 +194,20 @@ export default class ChartRenderer { if (dist > 0) { const slope = ((elev[b].elevation - elev[a].elevation) / dist) * 100 const sign = slope >= 0 ? '+' : '' - lines.push(`incline: ${sign}${Math.round(slope * 10) / 10} %`) + lines.push({ text: `incline: ${sign}${Math.round(slope * 10) / 10} %`, dotColor: getSlopeColor(slope) }) } } - lines.push(`distance: ${formatDistanceLabel(hit.distance, miles)}`) + lines.push({ text: `distance: ${formatDistanceLabel(hit.distance, miles)}` }) } ctx.font = '12px sans-serif' const lineHeight = 16 const padding = 6 - const textWidth = Math.max(...lines.map(l => ctx.measureText(l).width)) - const tooltipW = textWidth + padding * 2 + const dotSize = 4 + const dotSpace = lines.some(l => l.dotColor) ? dotSize * 2 + 4 : 0 + const textWidth = Math.max(...lines.map(l => ctx.measureText(l.text).width)) + const tooltipW = textWidth + dotSpace + padding * 2 const tooltipH = lines.length * lineHeight + padding * 2 - (lineHeight - 12) const rightEdge = this.cssWidth - margin.right let tooltipX = x + 4 @@ -222,11 +224,20 @@ export default class ChartRenderer { ctx.fill() ctx.stroke() - ctx.fillStyle = '#333' ctx.textAlign = 'left' ctx.textBaseline = 'top' for (let i = 0; i < lines.length; i++) { - ctx.fillText(lines[i], tooltipX + padding, tooltipY + padding + i * lineHeight) + const lineY = tooltipY + padding + i * lineHeight + const textX = tooltipX + padding + dotSpace + if (lines[i].dotColor) { + ctx.fillStyle = lines[i].dotColor! + const s = dotSize * 2 + ctx.beginPath() + ctx.roundRect(tooltipX + padding, lineY + 3, s, s, 1) + ctx.fill() + } + ctx.fillStyle = '#333' + ctx.fillText(lines[i].text, textX, lineY) } ctx.restore() diff --git a/src/pathDetails/elevationWidget/pathDetailData.ts b/src/pathDetails/elevationWidget/pathDetailData.ts index 2239b173..626fafad 100644 --- a/src/pathDetails/elevationWidget/pathDetailData.ts +++ b/src/pathDetails/elevationWidget/pathDetailData.ts @@ -281,10 +281,11 @@ export function buildInclineDetail(elevation: ElevationPoint[]): ChartPathDetail const dist = q.distance - p.distance const slopePercent = dist > 0 ? ((q.elevation - p.elevation) / dist) * 100 : 0 const color = getSlopeColor(slopePercent) + const sign = slopePercent >= 0 ? '+' : '' raw.push({ fromDistance: p.distance, toDistance: q.distance, - value: Math.round(Math.abs(slopePercent) * 10) / 10, + value: `${sign}${Math.round(slopePercent * 10) / 10} %`, color, coordinates: [[p.lng, p.lat], [q.lng, q.lat]], }) diff --git a/src/stores/PathDetailsStore.ts b/src/stores/PathDetailsStore.ts index 44fcd7fc..f32e64e6 100644 --- a/src/stores/PathDetailsStore.ts +++ b/src/stores/PathDetailsStore.ts @@ -8,6 +8,9 @@ export interface PathDetailsPoint { point: Coordinate elevation: number description: string + distance?: number + incline?: number + color?: string } export interface PathDetailsStoreState { From a09201f829f21be14489ae53130766392580e563 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 10 Mar 2026 08:51:47 +0100 Subject: [PATCH 2/2] remove dup --- src/layers/UsePathDetailsLayer.tsx | 9 ++------- src/pathDetails/elevationWidget/pathDetailData.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/layers/UsePathDetailsLayer.tsx b/src/layers/UsePathDetailsLayer.tsx index 697eb87f..78a9cc6b 100644 --- a/src/layers/UsePathDetailsLayer.tsx +++ b/src/layers/UsePathDetailsLayer.tsx @@ -10,6 +10,7 @@ import { GeoJSON } from 'ol/format' import { fromLonLat, toLonLat } from 'ol/proj' import { Coordinate } from '@/utils' import { ChartPathDetail } from '@/pathDetails/elevationWidget/types' +import { planeDist } from '@/pathDetails/elevationWidget/pathDetailData' import Dispatcher from '@/stores/Dispatcher' import { PathDetailsHover } from '@/actions/Actions' @@ -56,13 +57,7 @@ export default function usePathDetailsLayer( } const distances = [0] for (let i = 1; i < pathCoordinates.length; i++) { - const [lng1, lat1] = pathCoordinates[i - 1] - const [lng2, lat2] = pathCoordinates[i] - const toRad = (deg: number) => deg * 0.017453292519943295 - const dLat = toRad(lat2 - lat1) - const dLon = toRad(lng2 - lng1) - const x = Math.cos(toRad((lat1 + lat2) / 2)) * dLon - distances.push(distances[i - 1] + 6371000 * Math.sqrt(dLat * dLat + x * x)) + distances.push(distances[i - 1] + planeDist(pathCoordinates[i - 1], pathCoordinates[i])) } cumDistRef.current = distances }, [pathCoordinates]) diff --git a/src/pathDetails/elevationWidget/pathDetailData.ts b/src/pathDetails/elevationWidget/pathDetailData.ts index 626fafad..4e5892a4 100644 --- a/src/pathDetails/elevationWidget/pathDetailData.ts +++ b/src/pathDetails/elevationWidget/pathDetailData.ts @@ -14,7 +14,7 @@ export interface PathLike { * Consistent with GraphHopper's DistancePlaneProjection (see graphhopper#3296). * For now do not use calcDist from utils.ts to make it easy to separate this from GH Maps. */ -function planeDist(p: number[], q: number[]): number { +export function planeDist(p: number[], q: number[]): number { const toRad = (deg: number) => deg * 0.017453292519943295 const dLat = toRad(q[1] - p[1]) const dLon = toRad(q[0] - p[0])