Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChartPathDetail | null>(null)
usePathDetailsLayer(map, pathDetails, activeDetail, showPaths)
usePathDetailsLayer(map, pathDetails, activeDetail, showPaths, route.selectedPath.points.coordinates)
usePOIsLayer(map, pois)
useCurrentLocationLayer(map, currentLocation)

Expand Down
48 changes: 48 additions & 0 deletions src/layers/DefaultMapPopup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
41 changes: 27 additions & 14 deletions src/layers/PathDetailPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
<MapPopup map={map} coordinate={pathDetails.pathDetailsPoint ? pathDetails.pathDetailsPoint.point : null}>
<div className={styles.popup}>
{pathDetails.pathDetailsPoint && (
<p>
{metersToText(
Math.round(pathDetails.pathDetailsPoint.elevation),
settings.showDistanceInMiles,
true,
<MapPopup map={map} coordinate={p ? p.point : null}>
<div className={styles.detailPopup}>
{p && (p.description ? (
<>
<div className={styles.detailPopupValue}>
{p.color && <span className={styles.colorDot} style={{ background: p.color }} />}
{p.description}
</div>
{p.distance != null && (
<div>{metersToText(Math.round(p.distance), miles)}</div>
)}
<br />
{pathDetails.pathDetailsPoint!.description}
</p>
)}
</>
) : (
<>
<div>{metersToText(Math.round(p.elevation), miles, true)}</div>
{p.incline != null && (
<div>
<span className={styles.colorDot} style={{ background: p.color }} />
{p.incline >= 0 ? '+' : ''}{Math.round(p.incline * 10) / 10} %
</div>
)}
{p.distance != null && (
<div>{metersToText(Math.round(p.distance), miles)}</div>
)}
</>
))}
</div>
</MapPopup>
)
Expand Down
141 changes: 139 additions & 2 deletions src/layers/UsePathDetailsLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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 { planeDist } from '@/pathDetails/elevationWidget/pathDetailData'
import Dispatcher from '@/stores/Dispatcher'
import { PathDetailsHover } from '@/actions/Actions'

const highlightedPathSegmentLayerKey = 'highlightedPathSegmentLayer'
const activeDetailLayerKey = 'activeDetailLayer'
Expand All @@ -22,6 +26,7 @@ export default function usePathDetailsLayer(
pathDetails: PathDetailsStoreState,
activeDetail: ChartPathDetail | null = null,
showPaths: boolean = true,
pathCoordinates: number[][] = [],
) {
// Highlighted segments (elevation threshold)
useEffect(() => {
Expand All @@ -43,9 +48,138 @@ export default function usePathDetailsLayer(
}
}, [map, activeDetail, showPaths])

// Pre-compute cumulative distances for the path coordinates
const cumDistRef = useRef<number[]>([])
useEffect(() => {
if (pathCoordinates.length === 0) {
cumDistRef.current = []
return
}
const distances = [0]
for (let i = 1; i < pathCoordinates.length; i++) {
distances.push(distances[i - 1] + planeDist(pathCoordinates[i - 1], pathCoordinates[i]))
}
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<any>) => {
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()
Expand Down Expand Up @@ -89,6 +223,9 @@ function addActiveDetailLayer(map: Map, detail: ChartPathDetail) {
},
properties: {
color: seg.color,
value: seg.value,
fromDistance: seg.fromDistance,
toDistance: seg.toDistance,
},
}))

Expand Down
18 changes: 18 additions & 0 deletions src/pathDetails/ElevationInfoBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 21 additions & 10 deletions src/pathDetails/elevationWidget/ChartRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down
Loading