mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
164 lines
8.2 KiB
TypeScript
164 lines
8.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { focusRouteProfileAtDistance, type DistanceIndexedProfileSample, type RouteProfileFocus } from '../../utils/routeGeometry'
|
|
import * as elevationProfileModel from './elevationProfileModel'
|
|
|
|
interface ElevationProfileProps {
|
|
samples: DistanceIndexedProfileSample[]
|
|
color: string
|
|
height?: number
|
|
gradientId: string
|
|
ariaLabel?: string
|
|
focus?: RouteProfileFocus | null
|
|
onFocusChange?: (focus: RouteProfileFocus | null) => void
|
|
formatFocus?: (focus: RouteProfileFocus) => string
|
|
/** Stretches to mark behind the line, in metres along the route (the via ferratas a tour climbs). */
|
|
bands?: ReadonlyArray<{ fromM: number; toM: number }>
|
|
}
|
|
|
|
/** A route profile whose horizontal axis represents travelled distance. */
|
|
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands }: ElevationProfileProps) {
|
|
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
|
|
const pointerFrameRef = useRef<number | null>(null)
|
|
const pendingPointerDistanceRef = useRef<number | null>(null)
|
|
const width = 440
|
|
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height), [samples, height])
|
|
const interactive = samples.length >= 2 && !!onFocusChange && chart.hasGeographicCoordinates
|
|
const focusAtDistance = (distanceMeters: number): RouteProfileFocus | null =>
|
|
focusRouteProfileAtDistance(samples, distanceMeters)
|
|
const cancelPendingPointerFocus = (): void => {
|
|
if (pointerFrameRef.current != null) window.cancelAnimationFrame(pointerFrameRef.current)
|
|
pointerFrameRef.current = null
|
|
pendingPointerDistanceRef.current = null
|
|
}
|
|
useEffect(() => () => cancelPendingPointerFocus(), [samples])
|
|
if (samples.length < 2) return null
|
|
const focusAtPointer = (clientX: number, element: SVGSVGElement): void => {
|
|
const bounds = element.getBoundingClientRect()
|
|
if (bounds.width <= 0) return
|
|
const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width))
|
|
pendingPointerDistanceRef.current = ratio * chart.maxDistance
|
|
if (pointerFrameRef.current != null) return
|
|
pointerFrameRef.current = window.requestAnimationFrame(() => {
|
|
pointerFrameRef.current = null
|
|
const distance = pendingPointerDistanceRef.current
|
|
pendingPointerDistanceRef.current = null
|
|
if (distance != null) onFocusChange?.(focusAtDistance(distance))
|
|
})
|
|
}
|
|
const focusText = focus
|
|
? formatFocus?.(focus) ?? (focus.elevationMeters == null
|
|
? `${Math.round(focus.distanceMeters)} m`
|
|
: `${Math.round(focus.distanceMeters)} m · ${Math.round(focus.elevationMeters)} m`)
|
|
: ''
|
|
const focusX = focus ? (focus.distanceMeters / chart.maxDistance) * width : null
|
|
const focusY = focus?.elevationMeters == null
|
|
? null
|
|
: height - ((focus.elevationMeters - chart.minElevation) / chart.elevationSpan) * (height - 8) - 4
|
|
const tooltipX = focusX == null ? 0 : Math.max(4, Math.min(width - 164, focusX + 8))
|
|
const tooltipAtRight = focusX != null && focusX > width * 0.62
|
|
const updateKeyboardFocus = (next: RouteProfileFocus | null): void => {
|
|
if (next == null) cancelPendingPointerFocus()
|
|
onFocusChange?.(next)
|
|
if (next) setKeyboardAnnouncement(formatFocus?.(next) ?? (next.elevationMeters == null
|
|
? `${Math.round(next.distanceMeters)} m`
|
|
: `${Math.round(next.distanceMeters)} m · ${Math.round(next.elevationMeters)} m`))
|
|
else setKeyboardAnnouncement('')
|
|
}
|
|
const clearPointerFocus = (): void => {
|
|
cancelPendingPointerFocus()
|
|
onFocusChange?.(null)
|
|
setKeyboardAnnouncement('')
|
|
}
|
|
const handleKeyDown = (event: React.KeyboardEvent<SVGSVGElement>): void => {
|
|
if (!interactive) return
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
updateKeyboardFocus(null)
|
|
return
|
|
}
|
|
const currentIndex = focus?.sampleIndex ?? 0
|
|
let nextIndex: number | null = null
|
|
if (event.key === 'Home') nextIndex = 0
|
|
else if (event.key === 'End') nextIndex = samples.length - 1
|
|
else if (event.key === 'ArrowLeft') nextIndex = Math.max(0, currentIndex - 1)
|
|
else if (event.key === 'ArrowRight') nextIndex = Math.min(samples.length - 1, currentIndex + 1)
|
|
if (nextIndex == null) return
|
|
event.preventDefault()
|
|
updateKeyboardFocus(focusAtDistance(samples[nextIndex].distanceMeters))
|
|
}
|
|
return (
|
|
<div className="relative w-full">
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
width="100%"
|
|
height={height}
|
|
preserveAspectRatio="none"
|
|
className="bg-surface-tertiary"
|
|
style={{ display: 'block', borderRadius: 6, touchAction: interactive ? 'pan-y' : undefined, fontFamily: 'var(--font-system)' }}
|
|
role={interactive ? 'group' : undefined}
|
|
aria-label={interactive ? ariaLabel : undefined}
|
|
aria-describedby={interactive ? `${gradientId}-keyboard-announcement` : undefined}
|
|
aria-keyshortcuts={interactive ? 'ArrowLeft ArrowRight Home End Escape' : undefined}
|
|
tabIndex={interactive ? 0 : undefined}
|
|
onFocus={interactive ? () => { if (!focus) updateKeyboardFocus(focusAtDistance(0)) } : undefined}
|
|
onBlur={interactive ? () => updateKeyboardFocus(null) : undefined}
|
|
onKeyDown={interactive ? handleKeyDown : undefined}
|
|
onPointerDown={interactive ? event => {
|
|
if (event.pointerType !== 'mouse') {
|
|
event.currentTarget.focus({ preventScroll: true })
|
|
focusAtPointer(event.clientX, event.currentTarget)
|
|
}
|
|
} : undefined}
|
|
onPointerMove={interactive ? event => focusAtPointer(event.clientX, event.currentTarget) : undefined}
|
|
onPointerLeave={interactive ? clearPointerFocus : undefined}
|
|
onPointerCancel={interactive ? clearPointerFocus : undefined}
|
|
>
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
|
|
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
|
</linearGradient>
|
|
</defs>
|
|
{bands?.map(band => (
|
|
<rect key={`${band.fromM}-${band.toM}`} data-profile-band="true" x={(band.fromM / chart.maxDistance) * width} y={0}
|
|
width={Math.max(1, ((band.toM - band.fromM) / chart.maxDistance) * width)} height={height}
|
|
fill="var(--map-ferrata)" fillOpacity={0.16} />
|
|
))}
|
|
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
|
|
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
|
{focusX != null && (
|
|
<g pointerEvents="none" aria-hidden="true">
|
|
<line data-profile-focus-line="true" x1={focusX} x2={focusX} y1={4} y2={height - 4} stroke="var(--text-muted)" strokeOpacity="0.85" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
|
|
</g>
|
|
)}
|
|
</svg>
|
|
{/* The point in an unscaled layer of its own: the chart above stretches to the width, which would squash a circle drawn in it into an ellipse. */}
|
|
{focusX != null && focusY != null && (
|
|
<svg width="100%" height={height} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 overflow-visible">
|
|
<circle data-profile-focus-point="true" cx={`${(focusX / width) * 100}%`} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} />
|
|
</svg>
|
|
)}
|
|
{interactive && focusX != null && (
|
|
<div
|
|
data-profile-focus-readout="true"
|
|
className="absolute top-1 z-10 overflow-hidden rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-content shadow-elevated"
|
|
style={{
|
|
left: tooltipAtRight ? undefined : `${tooltipX / width * 100}%`,
|
|
right: tooltipAtRight ? 4 : undefined,
|
|
maxWidth: 'min(164px, calc(100% - 8px))',
|
|
pointerEvents: 'none',
|
|
fontFamily: 'var(--font-system)',
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
|
fontWeight: 500,
|
|
lineHeight: 1.25,
|
|
overflowWrap: 'anywhere',
|
|
}}
|
|
>
|
|
{focusText}
|
|
</div>
|
|
)}
|
|
{interactive && <span id={`${gradientId}-keyboard-announcement`} className="sr-only" aria-live="polite" aria-atomic="true">{keyboardAnnouncement}</span>}
|
|
</div>
|
|
)
|
|
}
|