mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"
This commit is contained in:
@@ -164,4 +164,51 @@ describe('ElevationProfile', () => {
|
||||
expect(band).toHaveAttribute('width', '220')
|
||||
expect(band).toHaveAttribute('fill', 'var(--map-ferrata)')
|
||||
})
|
||||
|
||||
it('FE-ELEV-001: a band that brings its own colour is drawn in it, a little stronger than the via ferrata tint', () => {
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="graded" bands={[{ fromM: 0, toM: 150, color: 'var(--hike-sac-t4)' }]} />,
|
||||
)
|
||||
const band = container.querySelector('[data-profile-band]')
|
||||
expect(band).toHaveAttribute('fill', 'var(--hike-sac-t4)')
|
||||
expect(band).toHaveAttribute('fill-opacity', '0.22')
|
||||
})
|
||||
|
||||
it('FE-ELEV-002: other ways are drawn thin on axes shared with the route, so a longer one reaches the right edge', () => {
|
||||
const longer = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 },
|
||||
{ distanceMeters: 600, elevationMeters: 300, lat: 48.06, lng: 11.06 },
|
||||
]
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="compared" compare={[{ key: 'b', samples: longer, color: 'var(--hike-variant-b)' }]} />,
|
||||
)
|
||||
const other = container.querySelector('[data-profile-compare="b"]')
|
||||
expect(other).toHaveAttribute('d', 'M0.0,96.0 L440.0,4.0')
|
||||
expect(other).toHaveAttribute('stroke', 'var(--hike-variant-b)')
|
||||
const line = container.querySelector('path[stroke="currentColor"]')?.getAttribute('d')
|
||||
expect(line).toBe('M0.0,96.0 L73.3,50.0 L220.0,73.0')
|
||||
expect(container.querySelector('path[fill^="url("]')?.getAttribute('d')).toContain('L220.0,100 L0.0,100 Z')
|
||||
})
|
||||
|
||||
it('FE-ELEV-003: with the readout left to the caller no box is drawn over the chart, the focus line stays', () => {
|
||||
const focus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }
|
||||
const { container } = render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="quiet" ariaLabel="Elevation profile" focus={focus} onFocusChange={vi.fn()} readout="none" />,
|
||||
)
|
||||
expect(container.querySelector('[data-profile-focus-readout]')).toBeNull()
|
||||
expect(container.querySelector('[data-profile-focus-line]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ELEV-004: keeps the spot a finger lifted from, while a mouse leaving clears it as before', () => {
|
||||
const onFocus = vi.fn()
|
||||
render(
|
||||
<ElevationProfile samples={samples} color="currentColor" gradientId="kept" ariaLabel="Elevation profile" onFocusChange={onFocus} keepFocusOnLift />,
|
||||
)
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile' })
|
||||
fireEvent.pointerLeave(profile, { pointerType: 'touch' })
|
||||
fireEvent.pointerCancel(profile, { pointerType: 'touch' })
|
||||
expect(onFocus).not.toHaveBeenCalledWith(null)
|
||||
fireEvent.pointerLeave(profile, { pointerType: 'mouse' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,19 +11,41 @@ interface ElevationProfileProps {
|
||||
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 }>
|
||||
/**
|
||||
* Stretches to mark behind the line, in metres along the route (the via
|
||||
* ferratas a tour climbs, the hard grades of a hike), in their own colour
|
||||
* where they bring one.
|
||||
*/
|
||||
bands?: ReadonlyArray<{ fromM: number; toM: number; color?: string }>
|
||||
/** Spots to mark across the profile, in metres along the route (where the stages of a multi-day tour end). */
|
||||
marks?: readonly number[]
|
||||
/** Other ways of the same route, drawn thin on the same axes so they compare at a glance. */
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>
|
||||
/** Where the spot in focus is said: in a box over the chart, or nowhere (the caller says it itself). */
|
||||
readout?: 'tooltip' | 'none'
|
||||
/** Keeps the spot a finger lifted from in focus, so it can still be read; a mouse leaving clears it as ever. */
|
||||
keepFocusOnLift?: boolean
|
||||
}
|
||||
|
||||
/** A route profile whose horizontal axis represents travelled distance. */
|
||||
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks }: ElevationProfileProps) {
|
||||
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks, compare, readout = 'tooltip', keepFocusOnLift = false }: 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 domain = useMemo(
|
||||
() => compare?.length ? elevationProfileModel.sharedProfileDomain([samples, ...compare.map(other => other.samples)]) : null,
|
||||
[samples, compare],
|
||||
)
|
||||
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height, domain), [samples, height, domain])
|
||||
const compared = useMemo(
|
||||
() => (compare ?? []).map(other => ({
|
||||
key: other.key,
|
||||
color: other.color,
|
||||
path: elevationProfileModel.prepareElevationProfileChart(other.samples, width, height, domain).linePath,
|
||||
})),
|
||||
[compare, height, domain],
|
||||
)
|
||||
const interactive = samples.length >= 2 && !!onFocusChange && chart.hasGeographicCoordinates
|
||||
const focusAtDistance = (distanceMeters: number): RouteProfileFocus | null =>
|
||||
focusRouteProfileAtDistance(samples, distanceMeters)
|
||||
@@ -66,8 +88,9 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
: `${Math.round(next.distanceMeters)} m · ${Math.round(next.elevationMeters)} m`))
|
||||
else setKeyboardAnnouncement('')
|
||||
}
|
||||
const clearPointerFocus = (): void => {
|
||||
const clearPointerFocus = (event: React.PointerEvent<SVGSVGElement>): void => {
|
||||
cancelPendingPointerFocus()
|
||||
if (keepFocusOnLift && event.pointerType !== 'mouse') return
|
||||
onFocusChange?.(null)
|
||||
setKeyboardAnnouncement('')
|
||||
}
|
||||
@@ -124,12 +147,16 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
{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} />
|
||||
fill={band.color ?? 'var(--map-ferrata)'} fillOpacity={band.color ? 0.22 : 0.16} />
|
||||
))}
|
||||
{marks?.map(atM => (
|
||||
<line key={atM} data-profile-mark="true" x1={(atM / chart.maxDistance) * width} x2={(atM / chart.maxDistance) * width}
|
||||
y1={0} y2={height} stroke="var(--accent)" strokeOpacity={0.55} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{compared.map(other => (
|
||||
<path key={other.key} data-profile-compare={other.key} d={other.path} fill="none" stroke={other.color} strokeOpacity={0.55}
|
||||
strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<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 && (
|
||||
@@ -144,7 +171,7 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
|
||||
<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 && (
|
||||
{interactive && readout === 'tooltip' && 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"
|
||||
|
||||
@@ -16,10 +16,37 @@ export interface ElevationProfileChartModel {
|
||||
areaPath: string
|
||||
}
|
||||
|
||||
/** The axes a chart is drawn on: its lowest and highest heights and its length. */
|
||||
interface ElevationProfileDomain {
|
||||
minElevation: number
|
||||
maxElevation: number
|
||||
maxDistance: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One set of axes for several profiles drawn over each other (the ways a
|
||||
* route can take), so a way that climbs higher or runs longer looks it.
|
||||
* Null when none of them has a sample.
|
||||
*/
|
||||
export function sharedProfileDomain(
|
||||
profiles: ReadonlyArray<readonly DistanceIndexedProfileSample[]>,
|
||||
): ElevationProfileDomain | null {
|
||||
const samples = profiles.flat()
|
||||
if (samples.length === 0) return null
|
||||
const elevations = samples.map(sample => sample.elevationMeters)
|
||||
return {
|
||||
minElevation: Math.min(...elevations),
|
||||
maxElevation: Math.max(...elevations),
|
||||
maxDistance: Math.max(...profiles.map(profile => profile[profile.length - 1]?.distanceMeters ?? 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/** The chart of a profile, on its own axes or on the `domain` it shares with others. */
|
||||
export function prepareElevationProfileChart(
|
||||
samples: readonly DistanceIndexedProfileSample[],
|
||||
width: number,
|
||||
height: number,
|
||||
domain: ElevationProfileDomain | null = null,
|
||||
): ElevationProfileChartModel {
|
||||
if (samples.length === 0) return {
|
||||
minElevation: 0,
|
||||
@@ -32,10 +59,10 @@ export function prepareElevationProfileChart(
|
||||
areaPath: '',
|
||||
}
|
||||
const elevations = samples.map(sample => sample.elevationMeters)
|
||||
const minElevation = Math.min(...elevations)
|
||||
const maxElevation = Math.max(...elevations)
|
||||
const minElevation = domain?.minElevation ?? Math.min(...elevations)
|
||||
const maxElevation = domain?.maxElevation ?? Math.max(...elevations)
|
||||
const elevationSpan = Math.max(maxElevation - minElevation, 1)
|
||||
const maxDistance = samples[samples.length - 1].distanceMeters || 1
|
||||
const maxDistance = (domain?.maxDistance ?? samples[samples.length - 1].distanceMeters) || 1
|
||||
const points = samples.map(sample => ({
|
||||
x: (sample.distanceMeters / maxDistance) * width,
|
||||
y: height - ((sample.elevationMeters - minElevation) / elevationSpan) * (height - 8) - 4,
|
||||
@@ -50,6 +77,9 @@ export function prepareElevationProfileChart(
|
||||
hasGeographicCoordinates: samples.every(sample => Number.isFinite(sample.lat) && Number.isFinite(sample.lng)),
|
||||
points,
|
||||
linePath,
|
||||
areaPath: `${linePath} L${width},${height} L0,${height} Z`,
|
||||
// On shared axes a shorter way ends before the right edge: its area closes under its own ends.
|
||||
areaPath: domain
|
||||
? `${linePath} L${points[points.length - 1].x.toFixed(1)},${height} L${points[0].x.toFixed(1)},${height} Z`
|
||||
: `${linePath} L${width},${height} L0,${height} Z`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user