mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(tours): add non-motorized day tour planning
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
import { TourPlannerRail, TourPlannerToursRail } from '../Tours/planner/TourPlannerPanels'
|
||||
import type { TourPlannerController } from '../Tours/planner/useTourPlanner'
|
||||
import ToursSidebar from '../Tours/ToursSidebar'
|
||||
import type { Day } from '../../types'
|
||||
import CustomSelect from './CustomSelect'
|
||||
|
||||
vi.mock('../../api/client', () => ({ toursApi: {} }))
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
useSettingsStore: (selector: (state: { settings: { language: string; distance_unit: string } }) => unknown) =>
|
||||
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
|
||||
}))
|
||||
vi.mock('../../store/tripStore', () => ({
|
||||
useTripStore: (selector: (state: { loadTrip: () => Promise<void> }) => unknown) =>
|
||||
selector({ loadTrip: vi.fn() }),
|
||||
}))
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 42, name: 'Ridge walk', tour_type: 'hike', distance: 4,
|
||||
elevation_gain: 100, elevation_loss: 80, duration: 60, difficulty: null,
|
||||
wanderer_ref: null, match_confidence: 1, tour_group_id: null,
|
||||
max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
const days = [{ id: 7, trip_id: 1, day_number: 1, title: 'Summit day', date: '2026-05-15' } as Day]
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
return {
|
||||
mode: { type: 'new-draft' }, maxHikingDifficulty: 2,
|
||||
setMaxHikingDifficulty: vi.fn(), name: '', setName: vi.fn(),
|
||||
status: 'empty', waypoints: [], route: [], routeAnalysis: null,
|
||||
distanceMeters: 0, durationSeconds: 0, hasUnsavedChanges: false,
|
||||
elevationProfileExpanded: false, readOnlyGpxTour: null,
|
||||
saveOutcome: null, returnToNeutral: vi.fn(), startNewTour: vi.fn(),
|
||||
...overrides,
|
||||
} as TourPlannerController
|
||||
}
|
||||
|
||||
describe('CustomSelect consumer contracts', () => {
|
||||
it('renders the real labelled T1-T6 selector and updates difficulty through the merged select', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setDifficulty = vi.fn()
|
||||
function DifficultyRail() {
|
||||
const [difficulty, setDifficultyState] = useState<number>(2)
|
||||
return <TourPlannerRail planner={planner({
|
||||
maxHikingDifficulty: difficulty,
|
||||
setMaxHikingDifficulty: next => { setDifficulty(next); setDifficultyState(next) },
|
||||
})} canEdit />
|
||||
}
|
||||
render(<TranslationProvider><DifficultyRail /></TranslationProvider>)
|
||||
const trigger = screen.getByRole('button', { name: 'Maximum trail difficulty' })
|
||||
expect(screen.getByLabelText('Maximum trail difficulty')).toBe(trigger)
|
||||
expect(trigger).toHaveAttribute('id', 'tour-planner-difficulty')
|
||||
expect(document.querySelectorAll('[id="tour-planner-difficulty"]')).toHaveLength(1)
|
||||
await user.click(trigger)
|
||||
const menu = within(document.getElementById(trigger.getAttribute('aria-controls')!)!)
|
||||
for (const difficulty of [1, 2, 3, 4, 5, 6]) {
|
||||
expect(menu.getByRole('button', { name: new RegExp(`^T${difficulty}`) })).toBeInTheDocument()
|
||||
}
|
||||
await user.click(menu.getByRole('button', { name: /^T3/ }))
|
||||
expect(setDifficulty).toHaveBeenCalledWith(3)
|
||||
expect(trigger).toHaveTextContent('T3')
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: /^T4/ }))
|
||||
expect(screen.getByRole('heading', { name: 'Enable alpine routing?' })).toBeInTheDocument()
|
||||
expect(setDifficulty).toHaveBeenCalledTimes(1)
|
||||
await user.click(screen.getByRole('button', { name: 'Enable alpine routing' }))
|
||||
expect(setDifficulty).toHaveBeenLastCalledWith(4)
|
||||
})
|
||||
|
||||
it('keeps the labelled native post-save day picker resolving numeric day ids', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
|
||||
render(<TranslationProvider><TourPlannerToursRail
|
||||
canAssign
|
||||
planner={planner({ saveOutcome: tour, editingPlaceId: 42 })}
|
||||
tours={[tour]} days={days} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()}
|
||||
/></TranslationProvider>)
|
||||
await user.click(screen.getByRole('button', { name: 'Assign to a day' }))
|
||||
const picker = screen.getByRole('combobox', { name: 'Pick a day' })
|
||||
expect(picker).toHaveAttribute('id', 'tour-planner-assign-day')
|
||||
await user.selectOptions(picker, '7')
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 7)
|
||||
})
|
||||
|
||||
it('keeps the real ADD Tours All/Unplanned/Planned filters working', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TranslationProvider><ToursSidebar tripId={1} days={days}
|
||||
tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]}
|
||||
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
|
||||
/></TranslationProvider>)
|
||||
expect(screen.getAllByRole('option')).toHaveLength(2)
|
||||
const filter = screen.getByRole('button', { name: 'Show' })
|
||||
await user.click(filter)
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
|
||||
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
|
||||
await user.click(filter)
|
||||
await user.click(screen.getByRole('button', { name: /Planned\s*1/ }))
|
||||
expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
|
||||
await user.click(filter)
|
||||
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
|
||||
expect(screen.getAllByRole('option')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves the Places filter select contract with labels, badges and compact sizing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setFilter = vi.fn()
|
||||
function PlacesFilter() {
|
||||
const [filter, updateFilter] = useState<string | number>('all')
|
||||
return <CustomSelect size="sm" value={filter}
|
||||
onChange={next => { setFilter(next); updateFilter(next) }}
|
||||
options={[
|
||||
{ value: 'all', label: 'All', badge: '3' },
|
||||
{ value: 'unplanned', label: 'Unplanned', badge: '2' },
|
||||
{ value: 'planned', label: 'Planned', badge: '1' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
render(<PlacesFilter />)
|
||||
await user.click(screen.getByRole('button', { name: /^All\s*3$/ }))
|
||||
await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ }))
|
||||
expect(setFilter).toHaveBeenCalledWith('unplanned')
|
||||
expect(screen.getByRole('button', { name: /^Unplanned\s*2$/ })).toHaveFocus()
|
||||
await user.click(screen.getByRole('button', { name: /^Unplanned\s*2$/ }))
|
||||
await user.click(screen.getByRole('button', { name: /^Planned\s*1$/ }))
|
||||
expect(setFilter).toHaveBeenLastCalledWith('planned')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CustomSelect from './CustomSelect';
|
||||
|
||||
@@ -193,4 +193,101 @@ describe('CustomSelect', () => {
|
||||
// Dropdown should not be in the DOM — options remain hidden
|
||||
expect(screen.queryByText('Apple')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies the external label id only to the trigger', () => {
|
||||
render(<><label htmlFor="fruit">Fruit</label><CustomSelect id="fruit" value="apple" onChange={onChange} options={OPTIONS} /></>);
|
||||
expect(screen.getByRole('button', { name: 'Fruit' })).toHaveAttribute('id', 'fruit');
|
||||
expect(document.querySelectorAll('[id="fruit"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 'fruit'])('keeps the explicit accessible name with id %s', id => {
|
||||
render(<CustomSelect id={id} ariaLabel="Choose fruit" value="apple" onChange={onChange} options={OPTIONS} />);
|
||||
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
|
||||
expect(trigger).toHaveAttribute('aria-label', 'Choose fruit');
|
||||
expect(Array.from(trigger.attributes).filter(attribute => attribute.name === 'aria-label')).toHaveLength(1);
|
||||
if (id) expect(trigger).toHaveAttribute('id', id);
|
||||
});
|
||||
|
||||
it('associates two independent portals without duplicate ids', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<>
|
||||
<CustomSelect id="first-fruit" ariaLabel="First fruit" value="apple" onChange={onChange} options={OPTIONS} />
|
||||
<CustomSelect id="second-fruit" ariaLabel="Second fruit" value="banana" onChange={onChange} options={OPTIONS} />
|
||||
</>);
|
||||
const first = screen.getByRole('button', { name: 'First fruit' });
|
||||
const second = screen.getByRole('button', { name: 'Second fruit' });
|
||||
await user.click(first);
|
||||
const firstMenuId = first.getAttribute('aria-controls')!;
|
||||
expect(container.contains(document.getElementById(firstMenuId))).toBe(false);
|
||||
await user.click(second);
|
||||
const secondMenuId = second.getAttribute('aria-controls')!;
|
||||
expect(firstMenuId).not.toBe(secondMenuId);
|
||||
expect(document.getElementById(secondMenuId)).toHaveAttribute('role', 'group');
|
||||
const ids = Array.from(document.querySelectorAll('[id]'), element => element.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await user.click(within(document.getElementById(secondMenuId)!).getByRole('button', { name: 'Cherry' }));
|
||||
expect(onChange).toHaveBeenCalledWith('cherry');
|
||||
expect(second).toHaveFocus();
|
||||
expect(second).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it.each(['{Enter}', ' '])('supports Tab, %s, arrows, selection and Escape', async openingKey => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} />);
|
||||
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
|
||||
await user.tab();
|
||||
expect(trigger).toHaveFocus();
|
||||
await user.keyboard(openingKey);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.keyboard('{ArrowDown}{ArrowDown}');
|
||||
expect(screen.getByRole('button', { name: 'Banana' })).toHaveFocus();
|
||||
await user.keyboard('{ArrowUp}{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('apple');
|
||||
expect(trigger).toHaveFocus();
|
||||
await user.keyboard('{ArrowUp}');
|
||||
expect(screen.getByRole('button', { name: 'Cherry' })).toHaveFocus();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps searchable focus and supports arrow navigation from the search field', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
|
||||
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByPlaceholderText('...')).toHaveFocus();
|
||||
await user.type(screen.getByPlaceholderText('...'), 'ban');
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('banana');
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps compact rendering, icon, badge and disabled keyboard behavior', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect ariaLabel="Disabled fruit" value="apple" onChange={onChange} size="sm" disabled options={[{ value: 'apple', label: 'Apple', badge: '3', icon: <span data-testid="fruit-icon" /> }]} />);
|
||||
const trigger = screen.getByRole('button', { name: 'Disabled fruit' });
|
||||
expect(trigger).toBeDisabled();
|
||||
expect(trigger.style.padding).toBe('8px 12px');
|
||||
expect(within(trigger).getByText('3')).toBeInTheDocument();
|
||||
expect(within(trigger).getByTestId('fruit-icon')).toBeInTheDocument();
|
||||
await user.tab();
|
||||
await user.keyboard('{Enter}{ArrowDown}');
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{ArrowDown}', 'Apple'],
|
||||
['{ArrowUp}', 'Cherry'],
|
||||
])('keyboard opening a searchable menu with %s retains option focus', async (key, option) => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
|
||||
await user.tab();
|
||||
await user.keyboard(key);
|
||||
expect(screen.getByRole('button', { name: option })).toHaveFocus();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.getByRole('button', { name: 'Choose fruit' })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import React, { useState, useRef, useEffect, useId } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
|
||||
@@ -16,6 +16,7 @@ interface SelectOption {
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
ariaLabel?: string
|
||||
value: string | number
|
||||
onChange: (value: string | number) => void
|
||||
options?: SelectOption[]
|
||||
@@ -70,6 +71,7 @@ function menuRoom(anchor: HTMLElement | null, left: number): number {
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
ariaLabel,
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
@@ -84,8 +86,11 @@ export default function CustomSelect({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const keyboardDirection = useRef<'first' | 'last' | null>(null)
|
||||
const menuId = useId()
|
||||
|
||||
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
|
||||
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
|
||||
@@ -93,12 +98,20 @@ export default function CustomSelect({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !searchable || !searchRef.current) return
|
||||
searchRef.current.focus()
|
||||
if (!keyboardDirection.current) searchRef.current.focus()
|
||||
// Focusing raises the keyboard on a phone; scroll the trigger up so the list
|
||||
// it just opened is not left underneath it (#2000).
|
||||
scrollAnchorIntoView(ref.current)
|
||||
}, [open, searchable])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !keyboardDirection.current) return
|
||||
const buttons = dropRef.current?.querySelectorAll<HTMLButtonElement>('button')
|
||||
const direction = keyboardDirection.current
|
||||
keyboardDirection.current = null
|
||||
if (buttons?.length) buttons[direction === 'last' ? buttons.length - 1 : 0].focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
@@ -137,13 +150,46 @@ export default function CustomSelect({
|
||||
|
||||
const sm = size === 'sm'
|
||||
|
||||
const closeMenu = () => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu()
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
const backwards = event.key === 'ArrowUp'
|
||||
if (!open) {
|
||||
keyboardDirection.current = backwards ? 'last' : 'first'
|
||||
setSearch('')
|
||||
setOpen(true)
|
||||
} else {
|
||||
const buttons = Array.from(dropRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])
|
||||
const current = buttons.findIndex(button => button === document.activeElement)
|
||||
const next = current < 0 ? (backwards ? buttons.length - 1 : 0) : (current + (backwards ? -1 : 1) + buttons.length) % buttons.length
|
||||
buttons[next]?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
{/* Trigger */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -174,7 +220,7 @@ export default function CustomSelect({
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
<div ref={dropRef} id={menuId} role="group" aria-label={ariaLabel || selected?.label || placeholder || undefined} onKeyDown={handleKeyDown} style={{
|
||||
position: 'fixed',
|
||||
...(anchored
|
||||
? {
|
||||
@@ -255,7 +301,7 @@ export default function CustomSelect({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
|
||||
onClick={() => { onChange(option.value); closeMenu() }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 10px', borderRadius: 6,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DetailShell from './DetailShell'
|
||||
|
||||
describe('DetailShell', () => {
|
||||
it('centers between planner panels and owns header, scrolling, footer and close behavior', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(
|
||||
<DetailShell
|
||||
header={<h2>Detail title</h2>}
|
||||
footer={<button type="button">Action</button>}
|
||||
onClose={onClose}
|
||||
closeLabel="Close"
|
||||
leftWidth={240}
|
||||
rightWidth={320}
|
||||
testId="detail-scroll"
|
||||
>
|
||||
<p>Detail content</p>
|
||||
</DetailShell>,
|
||||
)
|
||||
|
||||
const shell = container.firstElementChild as HTMLElement
|
||||
expect(shell.style.left).toBe('calc(240px + 0.5 * (100% - 240px - 320px))')
|
||||
expect(screen.getByTestId('detail-scroll')).toHaveTextContent('Detail content')
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface DetailShellProps {
|
||||
header: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
onClose: () => void
|
||||
closeLabel: string
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
headerStyle?: CSSProperties
|
||||
contentStyle?: CSSProperties
|
||||
closeButtonClassName?: string
|
||||
testId?: string
|
||||
}
|
||||
|
||||
/** Shared map-detail surface: panel-aware positioning, chrome and scrolling. */
|
||||
export default function DetailShell({
|
||||
header,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
closeLabel,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
headerStyle,
|
||||
contentStyle,
|
||||
closeButtonClassName = '',
|
||||
testId,
|
||||
}: DetailShellProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||
transform: 'translateX(-50%)',
|
||||
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||
zIndex: 50,
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-surface-elevated text-content"
|
||||
style={{
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-edge-faint" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '18px 16px 14px', flexShrink: 0, ...headerStyle }}>
|
||||
{header}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
className={`bg-surface-hover ${closeButtonClassName}`}
|
||||
style={{ width: 28, height: 28, borderRadius: '50%', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
|
||||
onMouseEnter={event => { event.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={event => { event.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
>
|
||||
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
data-testid={testId}
|
||||
style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10, ...contentStyle }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ElevationProfile from './ElevationProfile'
|
||||
import type { RouteProfileFocus } from '../../utils/routeGeometry'
|
||||
import * as elevationProfileModel from './elevationProfileModel'
|
||||
|
||||
const samples = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 },
|
||||
{ distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01 },
|
||||
{ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03 },
|
||||
]
|
||||
|
||||
function InteractiveProfile({ onFocus, samples: profileSamples = samples, externalFocus = null, height = 100 }: {
|
||||
onFocus?: (focus: RouteProfileFocus | null) => void
|
||||
samples?: typeof samples
|
||||
externalFocus?: RouteProfileFocus | null
|
||||
height?: number
|
||||
}) {
|
||||
const [focus, setFocus] = useState<RouteProfileFocus | null>(null)
|
||||
return <ElevationProfile samples={profileSamples} height={height} color="currentColor" gradientId="interactive" ariaLabel="Elevation profile" focus={externalFocus ?? focus} onFocusChange={next => { setFocus(next); onFocus?.(next) }} formatFocus={point => `${point.distanceMeters} m · ${point.elevationMeters} m`} />
|
||||
}
|
||||
|
||||
describe('ElevationProfile', () => {
|
||||
it('positions samples by travelled distance rather than array index', () => {
|
||||
const { container } = render(
|
||||
<ElevationProfile
|
||||
samples={[
|
||||
{ distanceMeters: 0, elevationMeters: 100 },
|
||||
{ distanceMeters: 100, elevationMeters: 150 },
|
||||
{ distanceMeters: 1000, elevationMeters: 120 },
|
||||
]}
|
||||
color="currentColor"
|
||||
gradientId="test-profile"
|
||||
/>,
|
||||
)
|
||||
|
||||
const path = container.querySelector('path[stroke]')?.getAttribute('d')
|
||||
expect(path).toBe('M0.0,96.0 L44.0,4.0 L440.0,59.2')
|
||||
})
|
||||
|
||||
it('renders nothing without at least two samples', () => {
|
||||
const { container } = render(<ElevationProfile samples={[]} color="currentColor" gradientId="empty-profile" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('maps pointer position to distance and shows the corresponding focus readout', () => {
|
||||
const onFocus = vi.fn()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length })
|
||||
render(<InteractiveProfile onFocus={onFocus} />)
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile' })
|
||||
vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 10, y: 0, left: 10, top: 0, right: 450, bottom: 100, width: 440, height: 100,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
|
||||
fireEvent.pointerMove(profile, { clientX: 230, pointerType: 'mouse' })
|
||||
act(() => frames.shift()?.(16))
|
||||
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 150, elevationMeters: 187.5, lat: 48.015, lng: 11.015, sampleIndex: 1 })
|
||||
expect(profile.querySelector('[data-profile-focus-line]')).toBeInTheDocument()
|
||||
expect(profile.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
|
||||
const readout = profile.parentElement?.querySelector('[data-profile-focus-readout]')
|
||||
expect(readout?.textContent).toBe('150 m · 187.5 m')
|
||||
expect(readout).toHaveClass('rounded-lg', 'border-edge-faint', 'bg-surface-card', 'text-content', 'shadow-elevated')
|
||||
expect(readout).toHaveStyle({ fontFamily: 'var(--font-system)', fontWeight: '500', pointerEvents: 'none' })
|
||||
expect(readout).toHaveStyle({ maxWidth: 'min(164px, calc(100% - 8px))' })
|
||||
expect(profile).toHaveStyle({ fontFamily: 'var(--font-system)' })
|
||||
expect(profile.querySelector('[data-profile-focus-line]')).toHaveAttribute('stroke', 'var(--text-muted)')
|
||||
expect(profile.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
|
||||
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
|
||||
fireEvent.pointerMove(profile, { clientX: -50, pointerType: 'mouse' })
|
||||
act(() => frames.shift()?.(32))
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
|
||||
fireEvent.pointerMove(profile, { clientX: 900, pointerType: 'mouse' })
|
||||
act(() => frames.shift()?.(48))
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 })
|
||||
fireEvent.pointerLeave(profile)
|
||||
expect(onFocus).toHaveBeenLastCalledWith(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('supports focus, arrow keys, Home, End and Escape without pointer input', () => {
|
||||
const onFocus = vi.fn()
|
||||
render(<InteractiveProfile onFocus={onFocus} />)
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile' })
|
||||
profile.focus()
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
|
||||
fireEvent.keyDown(profile, { key: 'ArrowRight' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 })
|
||||
fireEvent.keyDown(profile, { key: 'End' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 300, elevationMeters: 150, lat: 48.03, lng: 11.03, sampleIndex: 2 })
|
||||
fireEvent.keyDown(profile, { key: 'Home' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11, sampleIndex: 0 })
|
||||
fireEvent.keyDown(profile, { key: 'Escape' })
|
||||
expect(onFocus).toHaveBeenLastCalledWith(null)
|
||||
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('is not keyboard-focusable or interactive without geographic sample coordinates', () => {
|
||||
render(<ElevationProfile samples={[{ distanceMeters: 0, elevationMeters: 10 }, { distanceMeters: 100, elevationMeters: 20 }]} color="currentColor" gradientId="no-coordinates" ariaLabel="Elevation profile" onFocusChange={vi.fn()} />)
|
||||
expect(screen.queryByRole('group', { name: 'Elevation profile' })).toBeNull()
|
||||
expect(document.querySelector('svg[tabindex="0"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses static chart geometry for pointer, keyboard and external focus updates', () => {
|
||||
const prepared = vi.spyOn(elevationProfileModel, 'prepareElevationProfileChart')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let nextFrameId = 0
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
const id = ++nextFrameId
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
})
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { callbacks.delete(id) })
|
||||
const onFocus = vi.fn()
|
||||
const { rerender, unmount } = render(<InteractiveProfile onFocus={onFocus} />)
|
||||
const profile = screen.getByRole('group', { name: 'Elevation profile' })
|
||||
expect(prepared).toHaveBeenCalledTimes(1)
|
||||
vi.spyOn(profile, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 440, bottom: 100, width: 440, height: 100,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
|
||||
fireEvent.pointerMove(profile, { clientX: 90, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(profile, { clientX: 180, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' })
|
||||
expect(raf).toHaveBeenCalledTimes(1)
|
||||
expect(prepared).toHaveBeenCalledTimes(1)
|
||||
const pendingFrameId = [...callbacks.keys()][0]
|
||||
fireEvent.pointerLeave(profile)
|
||||
expect(cancel).toHaveBeenCalledWith(pendingFrameId)
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
fireEvent.pointerMove(profile, { clientX: 270, pointerType: 'mouse' })
|
||||
expect(raf).toHaveBeenCalledTimes(2)
|
||||
const [frameId, callback] = [...callbacks.entries()][0]
|
||||
act(() => callback(16))
|
||||
callbacks.delete(frameId)
|
||||
expect(onFocus).toHaveBeenLastCalledWith(expect.objectContaining({ sampleIndex: 1 }))
|
||||
expect(onFocus).toHaveBeenCalledTimes(2)
|
||||
expect(prepared).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(profile, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(profile, { key: 'ArrowLeft' })
|
||||
expect(prepared).toHaveBeenCalledTimes(1)
|
||||
|
||||
const externalFocus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }
|
||||
rerender(<InteractiveProfile samples={samples} externalFocus={externalFocus} onFocus={onFocus} />)
|
||||
expect(prepared).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<InteractiveProfile samples={samples} externalFocus={externalFocus} height={120} onFocus={onFocus} />)
|
||||
expect(prepared).toHaveBeenCalledTimes(2)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** A route profile whose horizontal axis represents travelled distance. */
|
||||
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus }: 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>
|
||||
<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" />
|
||||
{focusY != null && <circle data-profile-focus-point="true" cx={focusX} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />}
|
||||
</g>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { DistanceIndexedProfileSample } from '../../utils/routeGeometry'
|
||||
|
||||
export interface ElevationProfileChartPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ElevationProfileChartModel {
|
||||
minElevation: number
|
||||
maxElevation: number
|
||||
elevationSpan: number
|
||||
maxDistance: number
|
||||
hasGeographicCoordinates: boolean
|
||||
points: ElevationProfileChartPoint[]
|
||||
linePath: string
|
||||
areaPath: string
|
||||
}
|
||||
|
||||
export function prepareElevationProfileChart(
|
||||
samples: readonly DistanceIndexedProfileSample[],
|
||||
width: number,
|
||||
height: number,
|
||||
): ElevationProfileChartModel {
|
||||
if (samples.length === 0) return {
|
||||
minElevation: 0,
|
||||
maxElevation: 0,
|
||||
elevationSpan: 1,
|
||||
maxDistance: 1,
|
||||
hasGeographicCoordinates: false,
|
||||
points: [],
|
||||
linePath: '',
|
||||
areaPath: '',
|
||||
}
|
||||
const elevations = samples.map(sample => sample.elevationMeters)
|
||||
const minElevation = Math.min(...elevations)
|
||||
const maxElevation = Math.max(...elevations)
|
||||
const elevationSpan = Math.max(maxElevation - minElevation, 1)
|
||||
const 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,
|
||||
}))
|
||||
const linePath = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
|
||||
|
||||
return {
|
||||
minElevation,
|
||||
maxElevation,
|
||||
elevationSpan,
|
||||
maxDistance,
|
||||
hasGeographicCoordinates: samples.every(sample => Number.isFinite(sample.lat) && Number.isFinite(sample.lng)),
|
||||
points,
|
||||
linePath,
|
||||
areaPath: `${linePath} L${width},${height} L0,${height} Z`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user