mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview
# Conflicts: # client/src/components/Map/MapView.tsx # client/src/components/Map/MapViewGL.tsx # client/src/pages/TripPlannerPage.tsx # client/src/pages/tripPlanner/useTripPlanner.ts
This commit is contained in:
@@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
@@ -1143,6 +1143,50 @@ async function withCachedPlaces<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins implementing `searchProvider` found for the same query (#2221).
|
||||
*
|
||||
* Its own request beside the core one rather than a branch inside it: the core search
|
||||
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
|
||||
* answers with an empty list for every failure there is — no provider installed, one
|
||||
* that timed out, a 404 on an older server, the network gone — because a search that
|
||||
* breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
/**
|
||||
* How long a plugin index may keep the search list waiting, in milliseconds.
|
||||
*
|
||||
* The host gives a provider two seconds to answer and this gives the round trip a
|
||||
* little more. It is a ceiling on the WAIT, not on the provider: with no search
|
||||
* plugin installed the route answers immediately without reaching any of them, so
|
||||
* the normal case costs one local round trip and nothing else.
|
||||
*
|
||||
* A deadline rather than patience, because the alternative is a search that feels
|
||||
* broken. A list that arrives without an optional index is a smaller answer; a list
|
||||
* that arrives four seconds late is no answer at all.
|
||||
*/
|
||||
const PLUGIN_SEARCH_DEADLINE_MS = 2500
|
||||
|
||||
async function pluginSearchPlaces(
|
||||
query: string,
|
||||
lang?: string,
|
||||
near?: { lat: number; lng: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
|
||||
try {
|
||||
const r = await apiClient.get('/plugin-search', {
|
||||
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
|
||||
signal: stop.signal,
|
||||
})
|
||||
const places = (r.data as { places?: unknown })?.places
|
||||
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
@@ -1152,10 +1196,25 @@ export const mapsApi = {
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*
|
||||
* Plugin indexes are asked at the same time and their hits are appended, so every
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
|
||||
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
|
||||
}),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
withCachedPlaces(
|
||||
input,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
|
||||
@@ -44,6 +44,7 @@ type Defaults = {
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setValhallaBase(data.valhalla_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -409,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
|
||||
// A flat projection, 1000 px per degree: far enough apart that every
|
||||
// booking line clears its declutter floor, which is measured along the
|
||||
// projected line (#2275) rather than read off a canned distanceTo.
|
||||
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
|
||||
x: lng * 1000, y: lat * 1000,
|
||||
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
|
||||
})),
|
||||
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
|
||||
panes: new Map<string, HTMLElement>(),
|
||||
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
|
||||
@@ -221,15 +227,22 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006b: the whole-trip overview colours each line, on a white casing', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={['#ea580c']} />)
|
||||
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
// White under the colour, the same trick the coloured GPX tracks use to stay
|
||||
// readable on satellite and dark basemaps.
|
||||
expect(casing.color).toBe('#ffffff')
|
||||
expect(core.color).toBe('#ea580c')
|
||||
expect(casing.color).toBe('#c2740a')
|
||||
expect(core.color).toBe('#ff9f0a')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#0a5cc2')
|
||||
expect(core.color).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
|
||||
@@ -606,8 +606,10 @@ export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
// One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
// been. Only the road trip passes these, and only while colouring by day is on.
|
||||
routeColors = null,
|
||||
routeSegments = [],
|
||||
routeColors,
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
onMarkerClick,
|
||||
@@ -1028,15 +1030,12 @@ export const MapView = memo(function MapView({
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on.
|
||||
In the whole-trip overview each day is drawn in its own colour (#1736), and the
|
||||
casing goes white — the same trick the coloured GPX tracks use to stay readable
|
||||
on satellite and dark basemaps, where a per-colour dark casing would not. */}
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i] ? '#ffffff' : '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
@@ -1050,7 +1049,7 @@ export const MapView = memo(function MapView({
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i] ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
@@ -1540,17 +1540,19 @@ describe('MapViewGL', () => {
|
||||
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-048b: the whole-trip overview colours each line on the feature', async () => {
|
||||
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
|
||||
const routeSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]} routeColors={['#1d4ed8', null]} />)
|
||||
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
|
||||
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
|
||||
await act(async () => {})
|
||||
|
||||
const { features } = lastData(routeSource)
|
||||
expect(features[0].properties.color).toBe('#1d4ed8')
|
||||
expect(features[0].properties.color).toBe('#ff9f0a')
|
||||
expect(features[0].properties.casing).toBe('#c2740a')
|
||||
// No colour means the day route's own blue, which the layer paint falls back to.
|
||||
expect(features[1].properties.color).toBeUndefined()
|
||||
expect(features[1].properties.color).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
|
||||
@@ -1968,4 +1970,68 @@ describe('MapViewGL', () => {
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
// ── Satellite ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
|
||||
// A GL map cannot do that, because its basemap is a style with dozens of layers,
|
||||
// so the imagery goes on as a raster layer under everything TREK draws.
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({
|
||||
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
|
||||
})
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
|
||||
type: 'raster',
|
||||
tiles: [expect.stringContaining('arcgisonline.com')],
|
||||
}))
|
||||
// Anchored before the route, or the imagery would be painted over it.
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
|
||||
'trip-route',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
|
||||
// Present from the first pass on, the way the real map reports it afterwards.
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
|
||||
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
|
||||
|
||||
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
})
|
||||
rerender(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
@@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
@@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite'
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -107,9 +123,12 @@ interface Props {
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
/**
|
||||
* One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
* been. Only the road trip passes these, and only while colouring by day is on.
|
||||
*/
|
||||
routeColors?: ({ line: string; casing: string } | undefined)[] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
/** Line colour per entry of `route`, same index — the whole-trip overview (#1736). */
|
||||
routeColors?: (string | null)[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
hoverDisabled?: boolean
|
||||
@@ -235,6 +254,44 @@ function addPlaceClusterLayers(map: any): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
|
||||
* a no-op and only the visibility flips.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applySatellite(map: any, on: boolean): void {
|
||||
try {
|
||||
if (!map.isStyleLoaded?.()) return
|
||||
if (!map.getSource(SATELLITE_SOURCE_ID)) {
|
||||
if (!on) return // Nothing to build while it is switched off.
|
||||
map.addSource(SATELLITE_SOURCE_ID, {
|
||||
type: 'raster',
|
||||
tiles: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: SATELLITE_TILE_MAXZOOM,
|
||||
attribution: SATELLITE_TILE_ATTRIBUTION,
|
||||
})
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? []
|
||||
const firstOwn = layers.find((l: { id: string }) =>
|
||||
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
|
||||
map.addLayer({
|
||||
id: SATELLITE_LAYER_ID,
|
||||
type: 'raster',
|
||||
source: SATELLITE_SOURCE_ID,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
}, firstOwn?.id)
|
||||
}
|
||||
if (map.getLayer(SATELLITE_LAYER_ID)) {
|
||||
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
|
||||
}
|
||||
} catch { /* a style that refuses the layer keeps the plain basemap */ }
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
@@ -554,8 +611,8 @@ export function MapViewGL({
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeColors = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
routeColors,
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
onMarkerClick,
|
||||
@@ -597,6 +654,15 @@ export function MapViewGL({
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
// The same stored choice the Leaflet map reads, so the two renderers agree.
|
||||
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
const isSatellite = baseLayer === 'satellite'
|
||||
const toggleBaseLayer = useCallback(() => {
|
||||
// The store flips synchronously, so the map switches even offline; a failed save
|
||||
// is logged there rather than blocking the switch.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
@@ -702,6 +768,23 @@ export function MapViewGL({
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
/**
|
||||
* Zoomed out, the handles go away.
|
||||
*
|
||||
* A via is a handle for a few hundred metres of road, and at a continental zoom a
|
||||
* whole day's worth of them collapses into a cluster of dots over one town — not
|
||||
* something anybody can aim at, and dragging one there moves the route by kilometres
|
||||
* per pixel. Below this the drive is read, not shaped.
|
||||
*/
|
||||
const VIA_MIN_ZOOM = 9
|
||||
const applyViaZoom = () => {
|
||||
const on = map.getZoom() >= VIA_MIN_ZOOM
|
||||
for (const pin of viaPinsRef.current) {
|
||||
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
|
||||
if (node) node.style.display = on ? '' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
for (const via of vias) {
|
||||
const el = document.createElement('span')
|
||||
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
|
||||
@@ -790,8 +873,26 @@ export function MapViewGL({
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
|
||||
// Applied now and on every zoom that settles, so a handle that should not be there is
|
||||
// gone before the first frame rather than after the first gesture.
|
||||
applyViaZoom()
|
||||
map.on('zoomend', applyViaZoom)
|
||||
viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom))
|
||||
}, [vias, viasDraggable, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The other people's pointers, and this person's own going the other way.
|
||||
*
|
||||
* Built as plain elements on the same pin layer the vias use, so they ride the map
|
||||
* during a pan instead of being re-placed a frame later — the same reason the via
|
||||
* handles are not library markers.
|
||||
*
|
||||
* No interpolation between frames, unlike the studio's book: a map pans and zooms under
|
||||
* the arrow, so a position eased towards over several frames is a position that was
|
||||
* never true at any of them. Ten frames a second placed exactly reads as a hand; the
|
||||
* same frames chasing a moving target read as a drift.
|
||||
*/
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
@@ -1118,7 +1219,9 @@ export function MapViewGL({
|
||||
id: 'trip-route-casing',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': ['case', ['has', 'color'], '#ffffff', '#0a5cc2'], 'line-width': 8 },
|
||||
// Per feature where the caller gave one, else the blue the route has always
|
||||
// been. `coalesce` rather than a second layer: one source, one stroke.
|
||||
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
||||
@@ -1825,13 +1928,16 @@ export function MapViewGL({
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
// The colour rides on the feature rather than the layer so one source can hold a
|
||||
// whole trip of differently-coloured days (#1736), the way trip-gpx already does.
|
||||
const features = (route || []).flatMap((seg, i) => seg && seg.length > 1 ? [{
|
||||
type: 'Feature' as const,
|
||||
properties: routeColors?.[i] ? { color: routeColors[i] } : {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}] : [])
|
||||
const features = (route || [])
|
||||
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
|
||||
.filter(({ seg }) => seg && seg.length > 1)
|
||||
.map(({ seg, colors }) => ({
|
||||
type: 'Feature' as const,
|
||||
// Null rather than absent: `coalesce` in the paint expression falls through on
|
||||
// null, and an absent property would make every line the default colour.
|
||||
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route, routeColors, mapReady])
|
||||
|
||||
@@ -2063,6 +2169,18 @@ export function MapViewGL({
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode, glProvider])
|
||||
|
||||
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
|
||||
// and reloading into the other keeps the choice. `styledata` is subscribed because a
|
||||
// basemap change rebuilds the style from scratch and takes the imagery with it.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const apply = () => applySatellite(map, isSatellite)
|
||||
apply()
|
||||
map.on('styledata', apply)
|
||||
return () => { map.off('styledata', apply) }
|
||||
}, [isSatellite, mapReady, glProvider])
|
||||
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
@@ -2098,6 +2216,18 @@ export function MapViewGL({
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
)}
|
||||
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
|
||||
move when an instance changes renderer. The day panel is a centred card on the
|
||||
desktop map and never reaches the pill, but on a phone it is full width and
|
||||
does, which is why the lift is only there. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
|
||||
bottom: isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
}}>
|
||||
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
the Leaflet map's overlay (no anchored popup, no photo). */}
|
||||
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
|
||||
// hide. The road between them loops out 0.3° (300 px) and back, which is what
|
||||
// is actually drawn — and what the person was missing on their map.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
unmount()
|
||||
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(1)
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
|
||||
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
|
||||
// together draw 800 px of line.
|
||||
renderOverlay({
|
||||
reservations: [booking({
|
||||
id: 8, type: 'car',
|
||||
endpoints: [
|
||||
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
|
||||
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
|
||||
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
|
||||
],
|
||||
})],
|
||||
})
|
||||
expect(lines()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
// Every point of the road projects within 30 px: nothing worth drawing yet.
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
|
||||
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
}, [reservations])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
|
||||
return items.filter(item => {
|
||||
// A transit journey draws its real rail/bus alignment, not a straight from->to
|
||||
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
|
||||
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
|
||||
// with no other places to tighten the map onto — hides the whole route (#1570).
|
||||
if (item.transitSegs.length > 0) return true
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return fromPx.distanceTo(toPx) >= minPx
|
||||
// Measured along what is drawn, not between the ends: a routed drive can
|
||||
// cover half the screen while its endpoints sit close together (#2275).
|
||||
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
|
||||
})
|
||||
}, [items, zoom, map])
|
||||
}, [items, zoom, map, roadRoutes])
|
||||
|
||||
const labelVisibleIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
}, [visibleItems, zoom, map])
|
||||
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
return linesFor(item, roadRoutes).map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
@@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps {
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
|
||||
/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */
|
||||
const VIA_MIN_ZOOM = 9
|
||||
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
@@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({
|
||||
const handlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
handlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const draggable = !!onMoveVia
|
||||
/**
|
||||
* Zoomed out, the handles go away.
|
||||
*
|
||||
* A via is a handle for a few hundred metres of road, and at a continental zoom a whole
|
||||
* day's worth collapses into a cluster of dots over one town — not something anybody can
|
||||
* aim at, and a drag there moves the route by kilometres per pixel. Below this the drive
|
||||
* is read, not shaped.
|
||||
*/
|
||||
const map = useMap()
|
||||
const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM)
|
||||
useEffect(() => {
|
||||
const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM)
|
||||
update()
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
@@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length) return null
|
||||
if (!markers.length || !zoomedIn) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -797,6 +797,119 @@ describe('calculateAlternatives', () => {
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The second engine, which is what made this whole branch produce anything on a
|
||||
// default install. Every case here overrides the 503 the shared handlers answer with,
|
||||
// so the OSRM path above stays the one the cases before this measure.
|
||||
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
const valhallaAnswer = (summary: Record<string, unknown>) => ({
|
||||
trip: {
|
||||
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
|
||||
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
|
||||
},
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
expect(routes[1].distance).toBeCloseTo(130000, 0)
|
||||
expect(routes[1].duration).toBe(4500)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
|
||||
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
|
||||
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
|
||||
// map, so it is dropped and the leg keeps the single way OSRM found.
|
||||
//
|
||||
// Asserted on the result rather than on which excludes OSRM was asked: by this point
|
||||
// `excludeUnsupported` has already recorded the public host from the case above, and
|
||||
// that Set is module state for the lifetime of the file.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].avoids).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
|
||||
// Only the ferry question gets a usable answer, so the first two fall through.
|
||||
if (!('use_ferry' in body.costing_options.auto)) {
|
||||
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
|
||||
}
|
||||
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
|
||||
})
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('ferry')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
|
||||
// The reason the geometry decides rather than the numbers. Valhalla prices roads
|
||||
// differently, so on a long leg the SAME road comes back kilometres apart in both
|
||||
// length and time — here 13 km and 5 minutes, which is the measured spread between
|
||||
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
|
||||
// on those it reads as a discovery and puts a second blue line on top of the first.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json({
|
||||
trip: {
|
||||
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
|
||||
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
|
||||
let valhallaCalls = 0
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
|
||||
server.use(
|
||||
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}),
|
||||
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
|
||||
|
||||
expect(valhallaCalls).toBe(0)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
|
||||
@@ -3,6 +3,9 @@ import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
@@ -50,6 +53,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch`, with the request counted.
|
||||
*
|
||||
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
|
||||
* place that can answer how much routing an instance really does — the number behind
|
||||
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
|
||||
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
|
||||
* is never sent: where any of it was.
|
||||
*
|
||||
* Distance is the straight line along the waypoint chain rather than the routed length,
|
||||
* which is only in the answer and differs per response shape. It is a floor on the real
|
||||
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
|
||||
*
|
||||
* An aborted request is not a failure: the map cancels constantly while someone drags.
|
||||
*/
|
||||
async function routedFetch(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
kind: RouteUsageSurface,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
waypoints: readonly Waypoint[],
|
||||
): Promise<Response> {
|
||||
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
let km = 0
|
||||
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
|
||||
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
countRoute({ ...sample, failed: !response.ok })
|
||||
return response
|
||||
} catch (err) {
|
||||
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
||||
countRoute({ ...sample, failed: true })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
@@ -114,7 +155,7 @@ export async function calculateRoute(
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
||||
if (!response.ok) {
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
@@ -321,7 +362,7 @@ export async function calculateSegments(
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -352,23 +393,55 @@ export async function calculateSegments(
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
|
||||
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
|
||||
signal?: AbortSignal
|
||||
profile?: RouteProfileKey
|
||||
tripId?: number | string | null
|
||||
dayId?: number | null
|
||||
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
|
||||
avoid?: readonly AvoidClass[]
|
||||
} = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort()
|
||||
: []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
// and normalizes its answer; null means the provider failed or refused, and the
|
||||
// throw makes callers fall back to straight lines exactly like an OSRM outage.
|
||||
@@ -416,7 +489,7 @@ export async function calculateRouteWithLegs(
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -446,12 +519,73 @@ export async function calculateRouteWithLegs(
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
routeCache.set(cacheKey, result)
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
|
||||
* spoke — same leg list, same formatted text, same snap positions. What it adds is
|
||||
* `avoidance`: what was asked for against what the road actually turned out to be, which
|
||||
* is the only honest way to label a weighting that can fail.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
|
||||
* and the day stays drawn.
|
||||
*/
|
||||
async function routeAvoidingWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: waypoints.length,
|
||||
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
|
||||
}
|
||||
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
|
||||
// Counted by hand because the adapter speaks to a different engine with a different
|
||||
// verb, and the counter exists to answer "how much routing does this instance do" —
|
||||
// an engine it cannot see is exactly the traffic that would go missing from it.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run) return null
|
||||
|
||||
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,7 +648,7 @@ export interface RouteAlternative {
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
avoids?: AvoidClass
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -528,11 +662,39 @@ export interface RouteAlternative {
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*
|
||||
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
|
||||
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
|
||||
* NoRoute rather than the long way round, which is not an alternative anybody can use.
|
||||
* Valhalla weights it instead and drives around, which is the whole point.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
/**
|
||||
* How far two lines may stray from each other and still be the same road, in kilometres.
|
||||
*
|
||||
* Parallel carriageways, a different slip road onto the same motorway and the noise
|
||||
* between two engines' idea of where a road is all sit well under this. A way somebody
|
||||
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
|
||||
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
|
||||
*/
|
||||
const SAME_ROAD_KM = 0.5
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently — measured over ten European legs
|
||||
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
|
||||
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
|
||||
* Two identical blue lines on the map is exactly what that looks like.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
@@ -563,16 +725,76 @@ const excludeUnsupported = new Set<string>()
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
// Valhalla first, because on a default install it is the only one of the two that
|
||||
// can answer at all. It weights the class away instead of banning it, so it also
|
||||
// answers on a leg where the class cannot be avoided — and says so, which is why the
|
||||
// result is thrown away below unless the road really came back without it.
|
||||
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
|
||||
if (weighted) return weighted
|
||||
|
||||
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
|
||||
// NoRoute rather than driving round, so there is nothing to offer and no point paying
|
||||
// a request to be told so.
|
||||
if (exclude === 'ferry') return null
|
||||
return osrmExcluding(from, to, profile, exclude, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
if (!valhallaAvailable()) return null
|
||||
const sample = {
|
||||
profile,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
|
||||
// Counted here rather than in the adapter, so the adapter stays a format translator
|
||||
// and every routing request an instance makes still lands in the one counter.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
|
||||
if (!leg || !legAvoids(leg, exclude)) return null
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The same leg from OSRM with the class excluded outright, or null. */
|
||||
async function osrmExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
@@ -614,7 +836,7 @@ export async function calculateAlternatives(
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -629,19 +851,18 @@ export async function calculateAlternatives(
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
// the same drive without the motorway, then without the tolls, then without a ferry.
|
||||
// Tried one at a time and stopped as soon as one lands, so a leg that does have a
|
||||
// second road costs one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
|
||||
// 400 on both public hosts, whose car profile is built without excludable classes. It
|
||||
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
|
||||
// to OSRM's `exclude` for an instance running its own MLD-built router.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
const detour = await routeExcluding(from, to, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
@@ -659,22 +880,43 @@ export async function calculateAlternatives(
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
const furthest = furthestFrom(line, reference)
|
||||
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same point, plus how far off it actually is.
|
||||
*
|
||||
* The search itself stays in squared degrees with a cosine on the longitude: it only has
|
||||
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
|
||||
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
|
||||
* real distance rather than a rank, and half a degree is a different number of metres in
|
||||
* Andalusia than in Lapland.
|
||||
*/
|
||||
function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestNeighbour: [number, number] | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
let nearestPoint: [number, number] | null = null
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
}
|
||||
return best
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { TripRouteOverview } from './useTripRouteOverview'
|
||||
|
||||
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
|
||||
days: [
|
||||
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: '#1d4ed8', lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
|
||||
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: '#ea580c', lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
|
||||
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
|
||||
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
|
||||
],
|
||||
lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 222500, totalDuration: 10800, loading: false,
|
||||
|
||||
@@ -94,7 +94,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color, flexShrink: 0 }} aria-hidden />
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
|
||||
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
|
||||
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
|
||||
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const bare = freshMap()
|
||||
attach(bare).update([hop(7, 'car', 0.05)], opts)
|
||||
expect(features(bare)).toHaveLength(0)
|
||||
|
||||
const routed = freshMap()
|
||||
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
|
||||
expect(features(routed)).toHaveLength(1)
|
||||
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
const map = freshMap()
|
||||
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
|
||||
expect(features(map)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReservationMapboxOverlay waypoint edge cases', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
|
||||
})
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
private linesFor(item: TransportItem): [number, number][][] {
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
private render() {
|
||||
const map = this.map
|
||||
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
|
||||
const show = this.opts.showConnections
|
||||
|
||||
// Visible filter: require the on-screen pixel distance between
|
||||
// endpoints to exceed a type-specific minimum, same as the Leaflet
|
||||
// overlay, so tiny no-op transport lines don't clutter the map.
|
||||
// Visible filter: a hop draws once what it will draw is long enough on
|
||||
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
|
||||
// transport lines don't clutter the map. Measured along the drawn line,
|
||||
// not between the ends: a routed drive can cover half the screen while
|
||||
// its endpoints sit close together (#2275).
|
||||
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
|
||||
const visibleItems = show ? this.items.filter(item => {
|
||||
try {
|
||||
// A transit journey draws its real alignment, not a straight from->to line, so
|
||||
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
|
||||
// (#1570). Mirrors the Leaflet overlay.
|
||||
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
|
||||
const fromPx = map.project([item.from.lng, item.from.lat])
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return dist >= minPx
|
||||
return hopIsVisible(item.type, this.linesFor(item), project)
|
||||
} catch { return true }
|
||||
}) : []
|
||||
|
||||
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
||||
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
|
||||
}))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
return this.linesFor(item).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { countRoute, flushRouteUsage } from './routeUsageCounter'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
|
||||
*
|
||||
* Two things are worth pinning: that it batches rather than posting per route, because a
|
||||
* counter that costs a request of its own measures the load by adding to it, and that it
|
||||
* never throws — it sits on the hot path of every route the map draws.
|
||||
*/
|
||||
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: false,
|
||||
waypoints: 3,
|
||||
km: 100,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
describe('routeUsageCounter', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
flushRouteUsage()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
|
||||
countRoute(sample)
|
||||
expect(routeUsageApi.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
|
||||
countRoute(sample)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, waypoints: 5, km: 200 })
|
||||
countRoute({ ...sample, failed: true })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, surface: 'alternatives' })
|
||||
countRoute({ ...sample, selfHosted: true })
|
||||
countRoute({ ...sample, profile: 'walking' })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
|
||||
for (let i = 0; i < 25; i++) countRoute(sample)
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries[0].requests).toBe(25)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
|
||||
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
|
||||
countRoute(sample)
|
||||
expect(() => flushRouteUsage()).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
countRoute(sample)
|
||||
flushRouteUsage()
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
|
||||
expect(entries[0].requests).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* Counts the routing this browser asks for, and posts the totals in batches.
|
||||
*
|
||||
* All routing happens client-side against an external engine, so the server never
|
||||
* sees a route request and cannot count one. Without a count nobody knows what a
|
||||
* self-hosted router would have to carry — how many requests a real trip costs, how
|
||||
* big they are, how much already goes to an operator's own engine.
|
||||
*
|
||||
* Counters only. What goes over the wire is four integers per bucket: how many
|
||||
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
|
||||
* no route, no trip, nothing that says where anybody went.
|
||||
*
|
||||
* Batched on purpose. A counter that cost a request of its own would measure the load
|
||||
* by adding to it, so the tally is held in memory and flushed on a timer, when it
|
||||
* grows past a threshold, and when the page goes away.
|
||||
*/
|
||||
|
||||
/** Flush when the tally has this many requests in it, whichever comes first. */
|
||||
const FLUSH_AFTER_REQUESTS = 25
|
||||
/** …or after this long, so a quiet session still reports what it did. */
|
||||
const FLUSH_AFTER_MS = 60_000
|
||||
/** Guard against a pathological session: drop the tally rather than grow forever. */
|
||||
const MAX_BUCKETS = 40
|
||||
|
||||
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let pending = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let unloadWired = false
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null }
|
||||
if (!buckets.size) return
|
||||
const entries = [...buckets.values()]
|
||||
buckets.clear()
|
||||
pending = 0
|
||||
// Fire and forget: a failed report is a lost count, not a problem worth telling
|
||||
// anybody about. The next flush carries on from an empty tally either way.
|
||||
void routeUsageApi.report(entries).catch(() => {})
|
||||
}
|
||||
|
||||
function wireUnload(): void {
|
||||
if (unloadWired || typeof document === 'undefined') return
|
||||
unloadWired = true
|
||||
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
|
||||
// where a session ends by switching apps rather than by closing a tab.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one routing request. Never throws and never awaits: it sits on the hot path
|
||||
* of every route the map draws.
|
||||
*/
|
||||
export function countRoute(sample: {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
waypoints: number
|
||||
km: number
|
||||
failed: boolean
|
||||
}): void {
|
||||
try {
|
||||
wireUnload()
|
||||
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
|
||||
const bucket = buckets.get(key)
|
||||
if (bucket) {
|
||||
bucket.requests += 1
|
||||
bucket.waypoints += sample.waypoints
|
||||
bucket.km += sample.km
|
||||
if (sample.failed) bucket.failed += 1
|
||||
} else {
|
||||
if (buckets.size >= MAX_BUCKETS) return
|
||||
buckets.set(key, {
|
||||
profile: sample.profile,
|
||||
surface: sample.surface,
|
||||
selfHosted: sample.selfHosted,
|
||||
requests: 1,
|
||||
waypoints: sample.waypoints,
|
||||
km: sample.km,
|
||||
failed: sample.failed ? 1 : 0,
|
||||
})
|
||||
}
|
||||
pending += 1
|
||||
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
|
||||
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
|
||||
} catch { /* counting must never break routing */ }
|
||||
}
|
||||
|
||||
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
|
||||
export function flushRouteUsage(): void {
|
||||
flush()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useMergedMapPois } from './useMergedMapPois'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
|
||||
*
|
||||
* The corridor answers "what is along the drive", the category pill answers "what is in
|
||||
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
|
||||
* only new when the content really is — the map rebuilds every pin off it.
|
||||
*/
|
||||
|
||||
const poi = (osm_id: string, name = osm_id): Poi => ({
|
||||
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
|
||||
} as Poi)
|
||||
|
||||
describe('useMergedMapPois', () => {
|
||||
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
|
||||
const explore = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(null, explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, []))
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
|
||||
const explore = [poi('n2')]
|
||||
const { result } = renderHook(() => useMergedMapPois([], explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
|
||||
// The corridor copy carries how far along the drive it sits, which the pill's does not.
|
||||
const corridor = [poi('n1', 'Hotel am Weg')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
|
||||
expect(result.current).toBe(corridor)
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].name).toBe('Hotel am Weg')
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const explore = [poi('n2')]
|
||||
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
|
||||
const first = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMergedMapPois — the refuel offers', () => {
|
||||
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
|
||||
|
||||
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
|
||||
// In road trip mode this is the only channel a POI reaches the map through, so
|
||||
// without it somebody is asked to accept a stop they cannot see.
|
||||
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('a')]
|
||||
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
|
||||
initialProps: { o: [] as Poi[] },
|
||||
})
|
||||
const first = result.current
|
||||
rerender({ o: [] })
|
||||
expect(result.current).toBe(first)
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* The findings the map should draw, when two searches can be running at once.
|
||||
*
|
||||
* In road trip mode the corridor search answers "what is along this drive". The category
|
||||
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
|
||||
* stop is exactly the case where someone wants the second one without giving up the first,
|
||||
* so the two lists are drawn together rather than one replacing the other.
|
||||
*
|
||||
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
|
||||
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
|
||||
* copy wins because it carries how far along the drive it sits.
|
||||
*
|
||||
* `offered` is the third case: the handful of filling stations proposed where a tank runs
|
||||
* out. They belong on the map for a plain reason — somebody is being asked to accept a
|
||||
* stop, and a stop you cannot see is not one you can judge — but they are not a search
|
||||
* result and must not survive the panel being closed, which is why they arrive separately
|
||||
* rather than being folded into the corridor's own list.
|
||||
*
|
||||
* Returns the input array itself whenever there is nothing to merge. The map redraws off
|
||||
* this reference, so handing it a fresh array on every render would rebuild every pin.
|
||||
*/
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
|
||||
return useMemo(() => {
|
||||
const base = corridor === null ? explore : merge(corridor, explore)
|
||||
return merge(base, offered)
|
||||
}, [corridor, explore, offered])
|
||||
}
|
||||
|
||||
/** Nothing offered, as one stable array, so the default never changes identity. */
|
||||
const NONE: Poi[] = []
|
||||
|
||||
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
|
||||
function merge(a: Poi[], b: Poi[]): Poi[] {
|
||||
if (!b.length) return a
|
||||
if (!a.length) return b
|
||||
const seen = new Set(a.map(p => p.osm_id))
|
||||
const extra = b.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...a, ...extra] : a
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { TRACK_COLORS } from '@trek/shared'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
@@ -64,16 +64,18 @@ describe('useTripRouteOverview', () => {
|
||||
expect(result.current.totalDuration).toBe(1800)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-003: gives each day its own colour from the map palette', async () => {
|
||||
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const colors = result.current.days.map(d => d.color)
|
||||
expect(colors[0]).toBe(TRACK_COLORS[1])
|
||||
expect(colors[1]).toBe(TRACK_COLORS[2])
|
||||
// The same palette either way, so a day does not change colour when the trip is
|
||||
// read as a road trip instead of an overview.
|
||||
expect(colors[0]).toEqual(dayColor(1))
|
||||
expect(colors[1]).toEqual(dayColor(2))
|
||||
// Every drawn line is labelled with the colour of the day it belongs to.
|
||||
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
|
||||
expect(new Set(result.current.lineColors).size).toBe(2)
|
||||
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TRACK_COLORS } from '@trek/shared'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
@@ -12,7 +12,9 @@ export interface TripOverviewDay {
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
color: string
|
||||
/** The core and casing this day is drawn in — the road trip's palette, so a day is
|
||||
* the same colour whichever way the trip is being read. */
|
||||
color: { line: string; casing: string }
|
||||
/** One polyline per run of the day, `[lat, lng]`. */
|
||||
lines: [number, number][][]
|
||||
segments: RouteSegment[]
|
||||
@@ -28,7 +30,7 @@ export interface TripRouteOverview {
|
||||
/** Every day's polylines flattened in trip order — what the map draws. */
|
||||
lines: [number, number][][]
|
||||
/** The colour of each entry of `lines`, same index. */
|
||||
lineColors: string[]
|
||||
lineColors: { line: string; casing: string }[]
|
||||
segments: RouteSegment[]
|
||||
/** Every drawn coordinate, so the map can frame the whole trip at once. */
|
||||
focusPoints: [number, number][]
|
||||
@@ -116,7 +118,7 @@ export function useTripRouteOverview(
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const colorFor = (day: Day) => TRACK_COLORS[Math.abs(day.day_number ?? 0) % TRACK_COLORS.length]
|
||||
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
|
||||
|
||||
// Chunk every day up front, so the routed answers can be filed by position and
|
||||
// reassembled in order however they come back.
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
* Precision matters here more than anywhere else in the file: read as polyline5 the
|
||||
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
|
||||
* line into the sea rather than failing.
|
||||
*/
|
||||
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
|
||||
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
|
||||
|
||||
const answer = (summary: Record<string, unknown> = {}) => ({
|
||||
trip: {
|
||||
legs: [{ shape: SHAPE }],
|
||||
summary: {
|
||||
has_time_restrictions: false,
|
||||
has_toll: false,
|
||||
has_highway: true,
|
||||
has_ferry: false,
|
||||
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
|
||||
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
|
||||
length: 295.7,
|
||||
time: 11460,
|
||||
...summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
})
|
||||
|
||||
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
|
||||
// An operator who filled in their own router chose against the public hosts, usually
|
||||
// because the instance is not supposed to reach third parties at all. Adding one they
|
||||
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBeNull()
|
||||
expect(valhallaAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runFrom', () => {
|
||||
const total = (data: unknown) => runFrom(data)?.total ?? null
|
||||
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
|
||||
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
|
||||
// The public instance omits a flag it has nothing to say about, and the caller turns
|
||||
// this into a label. Undefined has to become false, not a truthy object.
|
||||
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
|
||||
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
|
||||
// What a request with a via really answers: one summary per pair of stops. The
|
||||
// schedule is built from those, so flattening them into a total would lose the
|
||||
// arrival time at every stop but the last.
|
||||
const chain = {
|
||||
trip: {
|
||||
legs: [
|
||||
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
|
||||
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
|
||||
],
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
// There is no honest way to divide one total between them, and a guess would reach
|
||||
// the user as a wrong arrival time rather than as a missing one.
|
||||
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
|
||||
expect(runFrom(two)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
|
||||
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
|
||||
// NaN kilometres rather than the null every caller reads as "no second way".
|
||||
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
|
||||
expect(total(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legAvoids', () => {
|
||||
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
|
||||
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
|
||||
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
|
||||
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
|
||||
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
|
||||
// would put a lie on the map.
|
||||
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
|
||||
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRouteAvoiding', () => {
|
||||
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
// The radius is what makes a place in a pedestrian zone routable at all: without it
|
||||
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
|
||||
expect(body!.locations).toEqual([
|
||||
{ lat: 53.5511, lon: 9.9937, radius: 50 },
|
||||
{ lat: 52.52, lon: 13.405, radius: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
seen.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
expect(seen.map(b => b.costing_options)).toEqual([
|
||||
{ auto: { use_highways: 0 } },
|
||||
{ auto: { use_ferry: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing: string }
|
||||
seen.push(body.costing)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
|
||||
expect(seen).toEqual(['pedestrian', 'bicycle'])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
|
||||
// Every failure means the same thing to the caller — there is no second way to
|
||||
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
|
||||
// here rather than as an error over a map that is drawing fine.
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, () =>
|
||||
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
|
||||
)
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
|
||||
let calls = 0
|
||||
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
|
||||
server.use(
|
||||
http.post('https://not-valhalla.example.org/route', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
// A 404 is about the URL rather than about this leg, so remembering it saves every
|
||||
// later leg a request against a host that will never answer.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
|
||||
let calls = 0
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRun over a whole day', () => {
|
||||
/** A chain answer with one summary per leg, which is what a real request returns. */
|
||||
const chain = (legCount: number) => ({
|
||||
trip: {
|
||||
legs: Array.from({ length: legCount }, () => ({
|
||||
shape: SHAPE,
|
||||
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
|
||||
})),
|
||||
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
calls++
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(28), 'driving', [])
|
||||
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
|
||||
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
|
||||
// Half a day of legs is worse than none. The schedule chains leg times blindly and
|
||||
// cannot tell that some are missing, so it would print a complete, plausible,
|
||||
// silently wrong timetable instead of leaving a visible gap.
|
||||
//
|
||||
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
|
||||
// by call number would just be answered on the retry.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
if (body.locations.length < 10) {
|
||||
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
|
||||
}
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
|
||||
// The caller retries the whole task, so without this a rate limit on the last piece
|
||||
// of a split day throws away the pieces that already answered and asks for all of
|
||||
// them again — three times over, against a host that allows one request a second.
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
calls++
|
||||
if (calls === 2) return new HttpResponse(null, { status: 429 })
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
|
||||
// A day whose legs do not match its stops cannot be assigned to the rail, and
|
||||
// guessing which stop is missing would put the wrong time against the right place.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
|
||||
*
|
||||
* The shipped OSRM hosts are built without excludable road classes: their car profile
|
||||
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
|
||||
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
|
||||
* for — drive this without the tolls — has never worked on a default install.
|
||||
*
|
||||
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
|
||||
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
|
||||
* until a search radius is allowed;
|
||||
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
|
||||
* above OSRM's on average and 36 % above on one, and those times feed the arrival
|
||||
* clocks of a road trip day. Mixing engines inside one chain would move times
|
||||
* nobody asked to move.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
* Zero is its default and it is the wrong default for TREK's data, which is full of
|
||||
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
|
||||
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
|
||||
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
|
||||
* this engine is worth having. Asked to avoid tolls between New York and Boston it
|
||||
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
|
||||
* would refuse to answer at all. The caller has to read the answer to find out what it
|
||||
* got, which is what `hasToll` and friends are for.
|
||||
*/
|
||||
const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
motorway: 'use_highways',
|
||||
toll: 'use_tolls',
|
||||
ferry: 'use_ferry',
|
||||
}
|
||||
|
||||
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
/**
|
||||
* Extra attempts for one piece of a split day.
|
||||
*
|
||||
* One, not a ladder: the common failure here is the public instance's rate limit, which
|
||||
* a single pause clears, and anything a second attempt cannot fix is not going to be
|
||||
* fixed by a fourth while somebody waits for their map.
|
||||
*/
|
||||
const CHUNK_RETRIES = 1
|
||||
|
||||
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
|
||||
export interface ValhallaLeg {
|
||||
coordinates: [number, number][]
|
||||
/** Metres. Valhalla is asked in kilometres and converted here. */
|
||||
distance: number
|
||||
/** Seconds. */
|
||||
duration: number
|
||||
/** What the route ACTUALLY has, as opposed to what was asked for. */
|
||||
hasToll: boolean
|
||||
hasHighway: boolean
|
||||
hasFerry: boolean
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
total: ValhallaLeg
|
||||
/**
|
||||
* Where each waypoint actually met the road.
|
||||
*
|
||||
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
|
||||
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
|
||||
* The ends of a leg's line are those points: measured against Füssen, its first vertex
|
||||
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
|
||||
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
|
||||
* quietly disappear from every day routed by this engine.
|
||||
*/
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
export function valhallaAvailable(): boolean {
|
||||
return valhallaBase() !== null
|
||||
}
|
||||
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
* Null for every failure, deliberately: this is an offer next to a route that already
|
||||
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
|
||||
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
|
||||
* second way to show. The one failure worth remembering is a host that is not a
|
||||
* Valhalla at all, because that is true of every leg from now on rather than this one.
|
||||
*/
|
||||
export async function valhallaRouteAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg | null> {
|
||||
const run = await valhallaRun([from, to], profile, [avoid], signal)
|
||||
return run ? run.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
|
||||
*
|
||||
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
|
||||
* into one request precisely so it appears at once rather than trickling in — so a day
|
||||
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
|
||||
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// Not when the host just proved it is not a Valhalla at all: that answer will not
|
||||
// change on a second ask, and asking anyway is the request the memo exists to save.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
if (!run) return null
|
||||
legs.push(...run.legs)
|
||||
}
|
||||
if (legs.length !== waypoints.length - 1) return null
|
||||
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each waypoint met the road, taken off the ends of the legs that touch it.
|
||||
*
|
||||
* A leg's line starts at the bound position of its first stop and ends at the bound
|
||||
* position of its second, so stop i is the head of leg i, and the last stop is the tail
|
||||
* of the last leg. The distance is measured here rather than reported, the same recovery
|
||||
* `readSnapped` already applies when OSRM leaves the field out.
|
||||
*/
|
||||
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
|
||||
return waypoints.map((waypoint, i) => {
|
||||
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
|
||||
const at = i < legs.length ? line[0] : line[line.length - 1]
|
||||
const asked: [number, number] = [waypoint.lat, waypoint.lng]
|
||||
return {
|
||||
asked,
|
||||
at,
|
||||
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
|
||||
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
|
||||
* the route is projected onto it to work out which leg it belongs after, and that index
|
||||
* is written to the database. A repeated point can put the via on the wrong leg, which
|
||||
* is a stored wrong answer rather than an invisible one.
|
||||
*/
|
||||
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
|
||||
const coordinates: [number, number][] = []
|
||||
for (const leg of legs) {
|
||||
const last = coordinates[coordinates.length - 1]
|
||||
const first = leg.coordinates[0]
|
||||
const continues = last && first && last[0] === first[0] && last[1] === first[1]
|
||||
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
|
||||
}
|
||||
return {
|
||||
coordinates,
|
||||
distance: legs.reduce((sum, l) => sum + l.distance, 0),
|
||||
duration: legs.reduce((sum, l) => sum + l.duration, 0),
|
||||
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
|
||||
// with a single toll booth in it would be labelled toll-free.
|
||||
hasToll: legs.some(l => l.hasToll),
|
||||
hasHighway: legs.some(l => l.hasHighway),
|
||||
hasFerry: legs.some(l => l.hasFerry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Valhalla answer as a leg, or null when it is not one.
|
||||
*
|
||||
* Split out because it is the whole of the format knowledge and the only part worth
|
||||
* testing without a network: the native shape, not `format=osrm`. That flag exists and
|
||||
* would have kept the response side unchanged, but it drops `has_toll` — the one field
|
||||
* this is all for — and carries every intersection instead, which measured 5.7 times
|
||||
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
|
||||
*/
|
||||
/**
|
||||
* The answer read leg by leg, which is what a whole day needs.
|
||||
*
|
||||
* Valhalla gives every leg its own summary, so a chain comes back already divided the
|
||||
* way a road trip day wants it: this stop to the next, with its own distance, its own
|
||||
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
|
||||
* a day into one number and lose the per-leg times the schedule is built from.
|
||||
*
|
||||
* The fallback to the trip summary is for a single leg only. Where several legs carry no
|
||||
* summary of their own there is no honest way to divide one total between them, and a
|
||||
* guess here would arrive as a wrong arrival time rather than as a missing one.
|
||||
*/
|
||||
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
const trip = tripOf(data)
|
||||
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
for (const leg of trip.legs) {
|
||||
if (typeof leg.shape !== 'string') return null
|
||||
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
|
||||
if (!summary) return null
|
||||
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
|
||||
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
|
||||
const measure = measured(summary, decodePolyline(leg.shape, 6))
|
||||
if (!measure) return null
|
||||
legs.push(measure)
|
||||
}
|
||||
return { legs, total: joinLegs(legs) }
|
||||
}
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
const length = Number(summary.length)
|
||||
const time = Number(summary.time)
|
||||
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
// Asked in kilometres, answered in kilometres, handed on in metres because that is
|
||||
// what every caller already reads off an OSRM route.
|
||||
distance: length * 1000,
|
||||
duration: time,
|
||||
hasToll: summary.has_toll === true,
|
||||
hasHighway: summary.has_highway === true,
|
||||
hasFerry: summary.has_ferry === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route actually avoids what it was asked to avoid.
|
||||
*
|
||||
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
|
||||
* is a weighting, so between two points with no untolled connection Valhalla answers a
|
||||
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
|
||||
* screen, and the flags are in the answer precisely so nobody has to guess.
|
||||
*/
|
||||
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
|
||||
if (avoid === 'toll') return !leg.hasToll
|
||||
if (avoid === 'motorway') return !leg.hasHighway
|
||||
return !leg.hasFerry
|
||||
}
|
||||
@@ -299,6 +299,43 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
|
||||
// The layer's second line is the name written on the building, not an
|
||||
// address. Joining the two and searching for it asks a question nobody
|
||||
// typed, and whatever came back first was taken as the place the user had
|
||||
// already picked. The row carries coordinates, so the fallback uses those.
|
||||
const user = userEvent.setup();
|
||||
let searched = 0;
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{
|
||||
placeId: 'node:9712313',
|
||||
mainText: 'Tokio Hauptbahnhof',
|
||||
secondaryText: '東京駅丸の内駅舎',
|
||||
source: 'openstreetmap',
|
||||
lat: 35.6811816,
|
||||
lng: 139.76598265,
|
||||
}],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => {
|
||||
searched += 1;
|
||||
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
|
||||
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
|
||||
|
||||
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
|
||||
expect(searched).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
@@ -375,6 +412,31 @@ describe('PlaceFormModal', () => {
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
|
||||
// The keystroke path asks both indexes at once, so the name the response
|
||||
// carries for the whole list ('trek-places') is true of the call and wrong
|
||||
// for half the rows. Marking every suggestion TREK is how a list that did
|
||||
// contain OpenStreetMap places read as if it never had.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [
|
||||
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
|
||||
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
|
||||
],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
|
||||
// The whole point of putting it on the row: with both indexes answering,
|
||||
// a label above the list could only name one of them. The index marks its
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { sourceLabelFor } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -56,36 +57,19 @@ interface PlaceFormModalProps {
|
||||
|
||||
|
||||
/**
|
||||
* Which index a search result came from, as a short mark beside it.
|
||||
* One row of the typed-ahead list, as the server sends it.
|
||||
*
|
||||
* A place list can be two indexes interleaved, so the source belongs on the row
|
||||
* rather than above the list: with the TREK index and OpenStreetMap answering
|
||||
* together, "one of these came from somewhere" is not an answer anyone can use.
|
||||
*
|
||||
* The three names are proper nouns, so they are not translated, and that is also
|
||||
* why there is no fourth: a source without a name people already know would need
|
||||
* a string in 23 languages to say less than nothing.
|
||||
* `source`, `lat` and `lng` are optional because not every index fills them:
|
||||
* Google answers with neither, and the mark falls back to the name the whole
|
||||
* list carries.
|
||||
*/
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'trek-places': 'TREK',
|
||||
openstreetmap: 'OpenStreetMap',
|
||||
nominatim: 'OpenStreetMap',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
/**
|
||||
* The label for one row.
|
||||
*
|
||||
* A place carries its own source when the index that produced it says so, which
|
||||
* is what makes an interleaved list readable. Everything else falls back to what
|
||||
* answered the call: Google never marks its places, and a merged list marks only
|
||||
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
|
||||
*/
|
||||
function sourceLabelFor(place: unknown, listSource: string): string | null {
|
||||
const own = (place as { source?: unknown } | null)?.source
|
||||
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
|
||||
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
|
||||
return SOURCE_LABELS[listSource] ?? null
|
||||
type Suggestion = {
|
||||
placeId: string
|
||||
mainText: string
|
||||
secondaryText: string
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
|
||||
@@ -161,10 +145,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
// Which index answered the last keystroke. One call is served by one source,
|
||||
// so the whole list carries it; the search below is per place, because that
|
||||
// list can be two indexes interleaved.
|
||||
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
|
||||
// Which index answered the last keystroke, for the rows that do not say so
|
||||
// themselves. Google and the OpenStreetMap fallback each answer from one
|
||||
// place; the index path answers from two at once and marks every row.
|
||||
const [acSource, setAcSource] = useState<string>('')
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -420,7 +404,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
@@ -448,6 +432,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
|
||||
// The layer's rows carry no address; their second line is the name
|
||||
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
|
||||
// is not a question anybody asked, and its first answer would be
|
||||
// whatever the index made of it — a different place, chosen silently.
|
||||
// The suggestion already knows where it is, so use that.
|
||||
place = {
|
||||
name: suggestion.mainText,
|
||||
address: '',
|
||||
lat: suggestion.lat,
|
||||
lng: suggestion.lng,
|
||||
osm_id: suggestion.placeId,
|
||||
source: 'openstreetmap',
|
||||
}
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
|
||||
@@ -288,6 +288,113 @@ describe('Filter tabs', () => {
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── The open day narrows the pool, and says so ──────────────────────────────
|
||||
//
|
||||
// The map has followed the selected day on this filter since #2024 while the list
|
||||
// did not, so a trip with everything planned read "55" in the pool beside five pins
|
||||
// on the map. Reported twice in the same Discord thread as the map being broken.
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: today, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: otherDay, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today, otherDay]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => {
|
||||
const dayOne = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const dayTwo = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: dayOne, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: dayTwo, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[dayOne, dayTwo]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.getByText('On Another Day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
|
||||
const onClearSelectedDay = vi.fn();
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
const { rerender } = render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={1}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
|
||||
|
||||
// Dismissing it asks for the day to be closed rather than changing the filter.
|
||||
fireEvent.click(screen.getByLabelText('Show the whole trip'));
|
||||
expect(onClearSelectedDay).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No day open, nothing narrowed, no note.
|
||||
rerender(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={null}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => {
|
||||
// A place assigned to some other day is planned, whichever day happens to be open,
|
||||
// so it must not reappear in the unplanned pool.
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const loose = buildPlace({ id: 93, name: 'Not Planned At All' });
|
||||
const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] };
|
||||
seedStore(useTripStore, { placesFilter: 'unplanned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[otherDay, loose]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Not Planned At All')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus } from 'lucide-react'
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
@@ -32,7 +32,7 @@ export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
export function PlacesHeader(S: SidebarState) {
|
||||
const {
|
||||
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
|
||||
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
|
||||
ratingFilter, setRatingFilter,
|
||||
@@ -163,7 +163,11 @@ export function PlacesHeader(S: SidebarState) {
|
||||
const counts = {
|
||||
all: baseFiltered.length,
|
||||
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
|
||||
// While a day is open this counts that day's plan, the same set the list and
|
||||
// the map show. Counting the whole trip here is what made the tab read 55
|
||||
// beside five pins, with nothing to say the two were answering different
|
||||
// questions.
|
||||
planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length,
|
||||
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||
}
|
||||
const tabs = ([
|
||||
@@ -211,6 +215,42 @@ export function PlacesHeader(S: SidebarState) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Says out loud what the count above already narrowed to.
|
||||
The map has followed the open day on this filter since #2024, and until now
|
||||
nothing anywhere said so: the pool read 55, the map drew five, and the honest
|
||||
conclusion was that the map was broken. It sits under the tabs rather than on
|
||||
the map because a chip over the canvas is unreachable on a phone, which is
|
||||
where this was reported from. */}
|
||||
{dayScoped && (
|
||||
<div
|
||||
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 6px 4px 9px', borderRadius: 8, marginBottom: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={11} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t('places.dayScoped')}
|
||||
</span>
|
||||
{onClearSelectedDay && (
|
||||
<button type="button"
|
||||
onClick={onClearSelectedDay}
|
||||
aria-label={t('places.dayScopedClear')}
|
||||
title={t('places.dayScopedClear')}
|
||||
className="text-content-faint hover:text-content"
|
||||
style={{
|
||||
marginInlineStart: 'auto', display: 'flex', alignItems: 'center',
|
||||
background: 'none', border: 'none', padding: 3, cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||
|
||||
@@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
|
||||
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
|
||||
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
|
||||
@@ -40,6 +40,12 @@ export interface PlacesSidebarProps {
|
||||
* through the day picker.
|
||||
*/
|
||||
onAddPlaceToSelectedDay?: () => void
|
||||
/**
|
||||
* Close the open day, from the note that says the pool is showing only that day.
|
||||
* Absent leaves the note without its dismiss, which is what the mobile day picker
|
||||
* wants: there the day is closed by the picker itself.
|
||||
*/
|
||||
onClearSelectedDay?: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
@@ -60,7 +66,7 @@ export interface PlacesSidebarProps {
|
||||
*/
|
||||
export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const {
|
||||
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
|
||||
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
|
||||
pushUndo, initialScrollTop, onScrollTopChange,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
@@ -238,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
/**
|
||||
* What "planned" means while a day is open: that day's plan, not the whole trip's.
|
||||
*
|
||||
* The map has narrowed to the selected day since #2024, and the list did not, which is
|
||||
* how a trip with 55 planned places showed 55 in the pool and five pins on the map with
|
||||
* nothing to explain the gap — read, reasonably, as the map being broken. The list now
|
||||
* follows the map, and `dayScoped` tells the header to say so.
|
||||
*
|
||||
* Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to
|
||||
* some other day is planned, whichever day happens to be open.
|
||||
*/
|
||||
const plannedInDayIds = useMemo(
|
||||
() => (selectedDayId
|
||||
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations })
|
||||
: null),
|
||||
[selectedDayId, days, assignments, accommodations, reservations],
|
||||
)
|
||||
const plannedFilterIds = plannedInDayIds ?? plannedIds
|
||||
const dayScoped = filter === 'planned' && plannedInDayIds !== null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
@@ -254,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
return true
|
||||
})
|
||||
return list
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
|
||||
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
|
||||
|
||||
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@@ -322,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
|
||||
/**
|
||||
* FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away.
|
||||
*
|
||||
* Everything here is geometry read off the page and arithmetic nobody sees until it is
|
||||
* wrong: a panel a gap below the badge, above it on a short window, pulled back inside
|
||||
* the viewport, centred on the rail rather than on the badge. The numbers are worked out
|
||||
* in the comments so a changed rule fails with a readable diff instead of a shrug.
|
||||
*
|
||||
* jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge,
|
||||
* rail and panel all measure the same and every branch of the placement collapses onto
|
||||
* 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are
|
||||
* stated rather than left to the jsdom default, since the flip and the clamp are the
|
||||
* whole point of half these cases.
|
||||
*/
|
||||
|
||||
const rect = (top: number, left: number, width: number, height: number): DOMRect => ({
|
||||
top, left, width, height,
|
||||
bottom: top + height,
|
||||
right: left + width,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
|
||||
let section: HTMLElement
|
||||
let anchor: HTMLElement
|
||||
let panel: DOMRect
|
||||
|
||||
function viewport(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
section = document.createElement('section')
|
||||
anchor = document.createElement('button')
|
||||
section.appendChild(anchor)
|
||||
document.body.appendChild(section)
|
||||
// The rail as it stands on a desktop: a tall column at the left edge with the badge
|
||||
// hard against its far side. That offset is what the centring rule exists for.
|
||||
section.getBoundingClientRect = () => rect(100, 0, 400, 600)
|
||||
anchor.getBoundingClientRect = () => rect(120, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 150)
|
||||
viewport(1200, 800)
|
||||
// The panel is the one element that cannot be primed by hand: React makes it inside the
|
||||
// portal and the layout effect measures it in the tick it is created. So it answers off
|
||||
// the prototype, which the two own assignments above shadow. Only its size is read —
|
||||
// handing back a top and a left it does not have yet would make the test circular.
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Both, because case 006 moves the anchor out of the section to test the fallback, so
|
||||
// it would outlive section.remove() and sit in the document for the rest of the file.
|
||||
anchor.remove()
|
||||
section.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function open() {
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<AnchoredPopover anchor={anchor} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
return { onClose, unmount: view.unmount, rerender: view.rerender }
|
||||
}
|
||||
|
||||
const dialog = () => screen.getByRole('dialog')
|
||||
|
||||
describe('AnchoredPopover', () => {
|
||||
it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<AnchoredPopover anchor={null} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
// A closed panel that keeps its key handler takes Escape away from whatever is
|
||||
// genuinely open behind it.
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => {
|
||||
open()
|
||||
|
||||
// Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a
|
||||
// computed style, because jsdom resolves no stylesheet: it is what makes every
|
||||
// number in this file a viewport coordinate instead of an offset inside a scroller.
|
||||
expect(dialog()).toHaveStyle({ top: '148px' })
|
||||
expect(dialog().className).toContain('fixed')
|
||||
expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => {
|
||||
open()
|
||||
|
||||
// The reason this component exists at all. The rail scrolls and carries transforms,
|
||||
// and both of those trap a fixed child, so a panel rendered where it is declared
|
||||
// would be clipped by the sidebar it hangs in. Every other case here passes without
|
||||
// the portal, which is why this one asserts the parent rather than the geometry.
|
||||
expect(dialog().parentElement).toBe(document.body)
|
||||
expect(section.contains(dialog())).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => {
|
||||
const { onClose, rerender } = open()
|
||||
|
||||
const second = document.createElement('button')
|
||||
section.appendChild(second)
|
||||
second.getBoundingClientRect = () => rect(500, 344, 48, 20)
|
||||
rerender(
|
||||
<AnchoredPopover anchor={second} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
// 500 plus 20 plus the gap. The rail has more than one badge worth opening and the
|
||||
// same panel serves all of them, so the anchor really does change under a mounted
|
||||
// popover. Measuring in a layout effect is what keeps that from ever being painted
|
||||
// at the previous badge: the position is committed before the browser draws.
|
||||
expect(dialog()).toHaveStyle({ top: '528px' })
|
||||
second.remove()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => {
|
||||
anchor.getBoundingClientRect = () => rect(200, 344, 48, 20)
|
||||
viewport(1200, 360)
|
||||
|
||||
open()
|
||||
|
||||
// Below would start at 228 and run to 378, past the 352 the window leaves. Above is
|
||||
// badge top 200 less the 150 it needs less the gap.
|
||||
expect(dialog()).toHaveStyle({ top: '42px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => {
|
||||
anchor.getBoundingClientRect = () => rect(40, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 250)
|
||||
viewport(1200, 320)
|
||||
|
||||
open()
|
||||
|
||||
// Flipping honestly would put it at -218, scrolling the half that holds the controls
|
||||
// out of reach with nothing to scroll back. Covering the badge is the lesser evil.
|
||||
expect(dialog()).toHaveStyle({ top: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => {
|
||||
open()
|
||||
|
||||
// Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging
|
||||
// over the map and pointing at nothing.
|
||||
expect(dialog()).toHaveStyle({ left: '100px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => {
|
||||
// Same badge, no rail: the panel is not owned by the sidebar and has to place itself
|
||||
// sanely wherever it is hung.
|
||||
document.body.appendChild(anchor)
|
||||
|
||||
open()
|
||||
|
||||
// Badge centre 368 less half the panel.
|
||||
expect(dialog()).toHaveStyle({ left: '268px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => {
|
||||
section.getBoundingClientRect = () => rect(100, 380, 200, 600)
|
||||
viewport(500, 800)
|
||||
|
||||
open()
|
||||
|
||||
// Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it
|
||||
// ends at 492, the same margin the rest of the placement keeps.
|
||||
expect(dialog()).toHaveStyle({ left: '292px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => {
|
||||
section.getBoundingClientRect = () => rect(100, -60, 100, 600)
|
||||
|
||||
open()
|
||||
|
||||
// Centred on that sliver the panel would start at -110 and lose its first half.
|
||||
expect(dialog()).toHaveStyle({ left: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => {
|
||||
// The handler sits on the document, so it sees every key pressed anywhere on the
|
||||
// page, typing in a field included.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'e' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => {
|
||||
// It closes on mousedown, not on click. Without the guard a button in the panel would
|
||||
// be torn out from under the pointer before its own click ever landed.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('Inside'))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => {
|
||||
// The badge toggles. Closing here as well means one press closes the panel twice, and
|
||||
// the next press reads as the badge refusing to open.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(anchor)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => {
|
||||
// Scroll events do not bubble and the rail is its own scroller. A listener without
|
||||
// capture would hear nothing while the badge slides out from under a panel that stays
|
||||
// where it was measured.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => {
|
||||
// Three handlers on the document and the window, on a component that is mounted and
|
||||
// thrown away again for every badge in the rail.
|
||||
const { onClose, unmount } = open()
|
||||
|
||||
unmount()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.mouseDown(document.body)
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* A small panel hung under something in the rail.
|
||||
*
|
||||
* The rail has more than one badge worth opening — what kind of stop this is, how full it
|
||||
* fills — and every one of them needs the same four things: measure before showing,
|
||||
* flip above when the window is short, close on an outside click or Escape, and close on
|
||||
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
|
||||
* is about what the panel contains, which is why they live here once instead of beside
|
||||
* every panel that needs them.
|
||||
*
|
||||
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
|
||||
* search and a keyboard model. This is a surface.
|
||||
*/
|
||||
export default function AnchoredPopover({ anchor, label, children, onClose }: {
|
||||
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
|
||||
anchor: HTMLElement | null
|
||||
/** What the dialog is called, for anyone who cannot see where it is hanging. */
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
|
||||
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
|
||||
// Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import RangeStrip, { blockMask } from './RangeStrip'
|
||||
|
||||
/**
|
||||
* FE-RANGESTRIP-001..015 — the picture under the range field.
|
||||
*
|
||||
* Nothing here is interactive, so what is worth pinning is the arithmetic the drawing
|
||||
* rests on: the empty state has to stay empty rather than settle on a plausible zero,
|
||||
* the block mask has a band it lives in and looks broken outside it, and the three
|
||||
* segments have to cover the bar exactly or the tail runs off the end. The chips are the
|
||||
* only readable proof that any of that happened, and the aria-label is the only route a
|
||||
* screen reader has to the figure at all.
|
||||
*/
|
||||
|
||||
const strip = (over: Partial<React.ComponentProps<typeof RangeStrip>> = {}) => (
|
||||
<RangeStrip
|
||||
rangeKm={500}
|
||||
fillPercent={null}
|
||||
wearPercent={null}
|
||||
unit={'metric' as DistanceUnit}
|
||||
electric={false}
|
||||
{...over}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* The three widths of the bar, in the order they are laid: what a stop puts in, what is
|
||||
* left of the tank, what age has taken. Read off the track rather than off the wrapper,
|
||||
* because the wrapper is the thing carrying the label.
|
||||
*/
|
||||
function segments(): number[] {
|
||||
const track = screen.getByRole('img').firstElementChild as HTMLElement
|
||||
return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width))
|
||||
}
|
||||
|
||||
describe('RangeStrip', () => {
|
||||
it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => {
|
||||
// A lone "km" beside a dash reads as a unit waiting for a number, which invites the
|
||||
// guess that the number is zero. There is no number, so there is nothing to label.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No range set')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Type a range/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => {
|
||||
// The bar is drawn empty here, so its label is the only thing that can say why.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName(
|
||||
'Type a range, or fill in the figures below and TREK works it out.',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => {
|
||||
// Split so the unit can sit smaller beside the number; the split is only safe while
|
||||
// the two halves still read as one reading of one distance.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
expect(screen.getByText('km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => {
|
||||
const { rerender } = render(strip({ electric: true }))
|
||||
expect(screen.getByText('per charge')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ electric: false }))
|
||||
expect(screen.getByText('per tank')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => {
|
||||
// Ten blocks. The chip is the only thing that turns the gaps from texture into a
|
||||
// measure, so it has to appear with the mask rather than merely near it.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => {
|
||||
// Two blocks read as damage; forty eat the bar. Either way there is nothing to
|
||||
// count, so claiming a block is worth fifty kilometres would be claiming a mask
|
||||
// that was never cut.
|
||||
const { rerender } = render(strip({ rangeKm: 100 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 2000 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => {
|
||||
// Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs
|
||||
// either side of each edge are what a change to that rounding would move.
|
||||
const { rerender } = render(strip({ rangeKm: 120 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 130 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1520 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1560 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => {
|
||||
// Four fifths of a 500 km tank. The whole point of the panel is that nobody charges
|
||||
// to the top, so the note has to move off the full-tank sentence.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => {
|
||||
// Anything outside nought to a hundred is not a fraction of a tank, and a note
|
||||
// saying a 130 % stop gives 650 km would be the panel inventing range.
|
||||
const { rerender } = render(strip({ fillPercent: 100 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: null }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: 130 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
// The other end of the same guard, and the one a cleared or mistyped field really
|
||||
// produces: a stop that puts nothing in would leave the bar empty and the note
|
||||
// claiming a range of nought.
|
||||
rerender(strip({ fillPercent: 0 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: -20 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => {
|
||||
// A battery reported as 150 % gone would leave the middle segment at a negative
|
||||
// width, which the browser drops: the tail would then be the whole bar and the chip
|
||||
// would be telling the traveller the car cannot move.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 }))
|
||||
|
||||
expect(screen.getByText('90 % lost to age')).toBeInTheDocument()
|
||||
expect(segments()).toEqual([8, 2, 90])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => {
|
||||
// Zero is the ordinary case for a new car and for every petrol one. A chip reading
|
||||
// "0 % lost to age" is an answer to a question nobody asked.
|
||||
const { rerender } = render(strip({ wearPercent: 0 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ wearPercent: -20 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([100, 0, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => {
|
||||
// Wear is a share of a range. With no range the share is of nothing, and a tail on
|
||||
// an empty bar would be the one filled thing on it.
|
||||
render(strip({ rangeKm: null, wearPercent: 30 }))
|
||||
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([0, 100, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => {
|
||||
// A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not
|
||||
// of the factory bar, or the filled part would reach into the hatched tail.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 }))
|
||||
|
||||
expect(segments()).toEqual([64, 16, 20])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => {
|
||||
// Asserted on the function, because jsdom implements no mask property: React writes
|
||||
// neither maskImage nor WebkitMaskImage and the element arrives with no style at
|
||||
// all, so through the DOM every one of these cases would pass on an empty bar.
|
||||
expect(blockMask(500)).toContain('repeating-linear-gradient')
|
||||
// Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar.
|
||||
expect(blockMask(500)).toContain('10%')
|
||||
// Five blocks, a fifth each: the step really follows the distance rather than being
|
||||
// a fixed number of divisions dressed up as one.
|
||||
expect(blockMask(250)).toContain('20%')
|
||||
|
||||
// Below three the cuts read as damage and above thirty the gaps eat the bar, so
|
||||
// outside that the bar is honestly solid.
|
||||
expect(blockMask(100)).toBeUndefined()
|
||||
expect(blockMask(2000)).toBeUndefined()
|
||||
expect(blockMask(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => {
|
||||
// The swatch is what ties the sentence to the stripe. Without it the hatching is an
|
||||
// unexplained texture at the end of the bar and the chip an unexplained subtraction.
|
||||
const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true }))
|
||||
|
||||
const hatched = Array.from(container.querySelectorAll<HTMLElement>('span[style*="repeating-linear-gradient"]'))
|
||||
expect(hatched).toHaveLength(1)
|
||||
expect(hatched[0].style.backgroundImage).toBe(
|
||||
(screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage,
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => {
|
||||
// Everything else in here is colour and width. A screen reader gets the range from
|
||||
// this label or not at all, so it has to carry both halves of the answer.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km')
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => {
|
||||
// The blocks are cut in kilometres whatever the setting, so the chip is the one
|
||||
// place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi.
|
||||
render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit }))
|
||||
|
||||
expect(screen.getByText('310.7')).toBeInTheDocument()
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* How far the car goes on one fill, drawn as the tank it leaves the factory with.
|
||||
*
|
||||
* The figure above it is the answer; this is the argument that the figure is right. It
|
||||
* carries the two things a bare number cannot say on its own:
|
||||
*
|
||||
* - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the
|
||||
* road, and a traveller who set 80 % is looking at four fifths of a bar, which is the
|
||||
* honest picture of what the next leg has.
|
||||
* - The hatched tail is what age has taken off an electric car's battery. It is already
|
||||
* out of the number above, so without the tail that figure looks like an unexplained
|
||||
* disagreement with the car's own data sheet.
|
||||
*
|
||||
* Widths animate because this sits directly under the fields that feed it: typing a
|
||||
* consumption and watching the bar move is what makes the two ways of stating a range
|
||||
* visibly the same thing.
|
||||
*
|
||||
* No box, no border, no tinted panel. It used to sit in a card of its own, which made it
|
||||
* a second surface inside a dialog whose whole point is one plain column — and the box
|
||||
* was doing nothing the bar was not already doing better.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One reading of the figure above it.
|
||||
*
|
||||
* `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the
|
||||
* chip has to be a step away from it in whichever scheme is on, and a white literal would
|
||||
* be a step the wrong way in the dark one.
|
||||
*/
|
||||
const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary'
|
||||
|
||||
/** How many kilometres one block of the bar stands for. */
|
||||
const BLOCK_KM = 50
|
||||
|
||||
/**
|
||||
* The bar, cut into blocks of a fixed distance rather than into quarters.
|
||||
*
|
||||
* Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the
|
||||
* bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the
|
||||
* comparison somebody typing a consumption is actually making — and it turns the bar into
|
||||
* something countable rather than something to eyeball.
|
||||
*
|
||||
* Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's
|
||||
* own colour to look like gaps, which tied them to that panel and left them visible as
|
||||
* stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows
|
||||
* through and nothing has to know the background.
|
||||
*
|
||||
* Exported for its own test rather than checked through the bar: jsdom supports no mask
|
||||
* property at all, so React writes neither of the two and the element reaches a test
|
||||
* carrying no style whatsoever. Through the DOM the rule is unobservable.
|
||||
*/
|
||||
export function blockMask(rangeKm: number | null): string | undefined {
|
||||
if (!rangeKm) return undefined
|
||||
const blocks = Math.round(rangeKm / BLOCK_KM)
|
||||
// Below three the cuts read as damage rather than as measure, and above about thirty
|
||||
// the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that
|
||||
// range the blocks were never going to be counted.
|
||||
if (blocks < 3 || blocks > 30) return undefined
|
||||
const step = 100 / blocks
|
||||
// Straight, not slanted. The slant was tried and read as a decorative texture rather
|
||||
// than as a measure: on a bar this thin the lean is legible only as fuzz on the seams.
|
||||
//
|
||||
// The colour is opacity here, not paint: black is "keep", transparent is "cut".
|
||||
return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour
|
||||
}
|
||||
|
||||
export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: {
|
||||
rangeKm: number | null
|
||||
fillPercent: number | null
|
||||
wearPercent: number | null
|
||||
unit: DistanceUnit
|
||||
electric: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// Capped where the arithmetic caps it, so the picture cannot claim a battery is gone.
|
||||
const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0
|
||||
const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
|
||||
const usable = 100 - wear
|
||||
const filled = rangeKm ? (usable * fill) / 100 : 0
|
||||
const rest = 100 - wear - filled
|
||||
// Split rather than formatted whole, so the unit can sit smaller beside the figure. The
|
||||
// rounding is formatDistance's own, because the badge on the trigger uses that and the
|
||||
// two must not disagree by a tenth.
|
||||
const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
|
||||
const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)'
|
||||
const mask = blockMask(rangeKm)
|
||||
|
||||
const note = shown === null
|
||||
? t('roadtrip.limit.rangeEmptyHint')
|
||||
: fill < 100
|
||||
? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
|
||||
: t('roadtrip.limit.fullNote')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
{/* The figure and what it is a figure OF, on one baseline. Wrapping rather than
|
||||
truncating: "per charge" is three words in English and six in Hungarian. */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
|
||||
{shown ?? '—'}
|
||||
</span>
|
||||
{shown !== null ? (
|
||||
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
|
||||
{getDistanceUnitLabel(unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-caption text-content-faint">
|
||||
{shown === null
|
||||
? t('roadtrip.limit.rangeEmpty')
|
||||
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2.5 w-full overflow-hidden rounded-full bg-surface"
|
||||
role="img"
|
||||
aria-label={shown === null ? note : `${shown} ${getDistanceUnitLabel(unit)} — ${note}`}
|
||||
>
|
||||
<div className="flex h-full w-full" style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined} aria-hidden>
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
|
||||
/>
|
||||
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${wear}%`, backgroundImage: stripes }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three readings of this one figure — what a stop actually puts in, what one block
|
||||
on the bar stands for, what age has taken off — each on its own chip inside the
|
||||
card the number and the bar already share. As prose on one line they ran into
|
||||
each other; given the card to themselves they looked like four separate answers.
|
||||
Chips inside the card are both: three facts, one answer. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className={NOTE_CHIP}>{note}</span>
|
||||
{mask ? (
|
||||
<span className={NOTE_CHIP}>{t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })}</span>
|
||||
) : null}
|
||||
{/* The swatch is what ties the chip to the tail of the bar. Without it the
|
||||
hatching is an unexplained stripe and the sentence an unexplained loss. */}
|
||||
{wear > 0 ? (
|
||||
<span className={`${NOTE_CHIP} gap-1.5`}>
|
||||
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
|
||||
{t('roadtrip.limit.wearNote', { percent: wear })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
|
||||
|
||||
const stop = (id: number, name: string): RoadtripStop => ({
|
||||
assignmentId: id,
|
||||
ownerDayId: 1,
|
||||
ownerIndex: id - 1,
|
||||
placeId: id * 10,
|
||||
name,
|
||||
lat: 53.5,
|
||||
@@ -41,7 +43,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days,
|
||||
lines: [],
|
||||
lines: [], lineDays: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
segments: [],
|
||||
@@ -202,9 +204,9 @@ describe('RoadtripCorridorPanel', () => {
|
||||
})
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one
|
||||
// inside a <header>.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one that
|
||||
// folds its list, so it is the button.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)!
|
||||
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
|
||||
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
|
||||
})
|
||||
@@ -291,11 +293,17 @@ describe('RoadtripCorridorPanel', () => {
|
||||
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is not reported as a fault', () => {
|
||||
// It used to say so, and it said so on nearly every search of a long day: the ceiling
|
||||
// is per box and a busy corridor reaches it as a matter of course. That made a
|
||||
// permanent complaint about a search that had worked, and its advice — narrow the
|
||||
// corridor — makes the answer smaller rather than better. The warnings that remain
|
||||
// are the ones about a search that did not happen.
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -85,6 +85,14 @@ function ResultBadge({ category }: { category: string }): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact about a hit, on its own surface.
|
||||
*
|
||||
* `bg-surface-secondary` rather than a literal: the row lifts to `surface-hover` under the
|
||||
* pointer, and a chip painted white would stop lifting with it in the dark scheme.
|
||||
*/
|
||||
const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted'
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -95,16 +103,16 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{/* Two facts, two elements. Both are translated phrases rather than short fixed
|
||||
values, so they stay text and the gap does the separating a middot used to —
|
||||
which also lets them wrap onto their own lines instead of breaking around a
|
||||
dot left stranded at the end of a line. */}
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
|
||||
<span>
|
||||
{/* Two facts, two chips. Where it is off the road and how far into the drive it
|
||||
comes are separate answers, and as two phrases sharing a line they read as one
|
||||
run-on sentence about the same thing. A chip each gives them an edge, and the
|
||||
row still wraps cleanly at a narrow width because nothing has to break around
|
||||
a separator. */}
|
||||
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
|
||||
<span className={POI_CHIP}>
|
||||
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
</span>
|
||||
<span className={POI_CHIP}>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
@@ -167,9 +175,30 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
/**
|
||||
* Folded away, per category.
|
||||
*
|
||||
* A search for fuel AND food comes back as two lists that push each other off the
|
||||
* screen, and the one being read is never the one at the top. Local state on purpose:
|
||||
* which group somebody has open right now is a way of looking, not a setting — it
|
||||
* should not outlive the search that produced these groups.
|
||||
*/
|
||||
const [open, setOpen] = useState(true)
|
||||
const label = meta ? t(meta.labelKey) : category
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
|
||||
// The gap between two groups belongs to the section, not to the strip: as padding on
|
||||
// the strip it became part of the hover surface, which then hung well above the row
|
||||
// it highlights.
|
||||
<section className="pb-1 pt-1.5 first:pt-0">
|
||||
{/* The whole strip is the control, not a chevron at its end: it is what the eye is
|
||||
on when it decides this is not the list it wants, and at this width a 12px
|
||||
target in the corner is a miss waiting to happen. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
className="group/cat flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see ResultBadge.
|
||||
@@ -178,13 +207,21 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
|
||||
{meta ? t(meta.labelKey) : category}
|
||||
{label}
|
||||
</span>
|
||||
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
|
||||
{t('roadtrip.poi.found', { count: pois.length })}
|
||||
</span>
|
||||
</header>
|
||||
<ul>
|
||||
{/* Quiet until the strip is under the pointer, then it says which way this goes.
|
||||
A chevron that is always at full contrast reads as a fourth piece of data in a
|
||||
header that already carries three. */}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`shrink-0 text-content-faint transition-all group-hover/cat:text-content ${open ? '' : '-rotate-90'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<ul hidden={!open}>
|
||||
{pois.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
@@ -267,9 +304,11 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
// no fuel on this stretch" and "nobody looked at this stretch".
|
||||
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
|
||||
// The stretch was searched and the answer came back short. Without this line a filter
|
||||
// finding nothing looks like proof there is nothing.
|
||||
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
|
||||
// A stretch that answered short is deliberately NOT reported. It fired on almost every
|
||||
// search of a long day — the ceiling is per box and a busy corridor reaches it easily —
|
||||
// so it read as a permanent complaint about a search that had in fact worked, and the
|
||||
// advice it gave ("narrow the corridor") makes the answer smaller rather than better.
|
||||
// The two warnings that remain are about a search that did not happen.
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
@@ -328,12 +367,16 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
|
||||
{/* One word, so it can be read at the size a primary action deserves. The panel
|
||||
it sits in is headed "along the route" and every control above it narrows the
|
||||
same search; repeating that on the button only made it small. */}
|
||||
same search; repeating that on the button only made it small.
|
||||
|
||||
Its height is the controls' height, not a size of its own. At 38px it stood a
|
||||
head above the segmented rows it follows and read as a second panel rather
|
||||
than as the end of this one. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={!canSearch}
|
||||
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
className="flex h-[32px] w-full items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading
|
||||
? <RotateCw size={15} className="animate-spin" aria-hidden />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
|
||||
* FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning.
|
||||
*
|
||||
* The one that matters is when a number is written. These fields had no local
|
||||
* state, so every keystroke was a settings PUT: typing "180" sent three, and
|
||||
@@ -13,14 +13,26 @@ import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
* tenth of what was typed and only say so after the next reload.
|
||||
*/
|
||||
|
||||
function open(onSave?: (key: string, value: number) => void) {
|
||||
function open(onSave?: (key: string, value: number | string | boolean) => void) {
|
||||
render(<RoadtripLimitsCard onSave={onSave} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
/** The three inputs, in the order the dialog lists them. */
|
||||
/** Every number field, in the order the dialog lists them. */
|
||||
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
|
||||
/**
|
||||
* Unfolds the car's own figures.
|
||||
*
|
||||
* They sit behind a disclosure now, open from the start only when one of them is already
|
||||
* stored — so a test that arrives with an empty vehicle has to ask for them, and one that
|
||||
* arrives with values does not.
|
||||
*/
|
||||
function openSpec() {
|
||||
const toggle = screen.queryByRole('button', { name: /Work it out from the car/i })
|
||||
if (toggle && toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
@@ -127,4 +139,125 @@ describe('RoadtripLimitsCard', () => {
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
// Four are always out: leg, day, range, fill. The car's own three are folded away
|
||||
// until asked for, because nothing is stored on this vehicle yet.
|
||||
expect(inputs()).toHaveLength(4)
|
||||
openSpec()
|
||||
|
||||
// Leg, day, range, fill, battery, consumption, wear. The three at the end do not
|
||||
// exist until there is a kind of car to have them.
|
||||
expect(inputs()).toHaveLength(7)
|
||||
expect(screen.getByText('kWh')).toBeInTheDocument()
|
||||
expect(screen.getByText('kWh/100 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 58,
|
||||
roadtrip_kwh_per_100: 16,
|
||||
// Deliberately different from what the figures work out to: the point of
|
||||
// the row going read-only is that there is no longer a second answer.
|
||||
roadtrip_range_km: 500,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
expect(screen.getByTestId('limit-derived')).toHaveTextContent('363')
|
||||
// Leg, day, battery, consumption, wear, fill — the range input is gone.
|
||||
expect(inputs()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
// Leg, day, tank, consumption, range, fill.
|
||||
openSpec()
|
||||
const tank = screen.getByTestId('limit-tankLitres') as HTMLInputElement
|
||||
fireEvent.change(tank, { target: { value: '15' } })
|
||||
fireEvent.blur(tank)
|
||||
|
||||
// 15 US gallons, not 15 litres. Storing the number as typed would have made
|
||||
// every fuel warning fire at a quarter of the right distance.
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78)
|
||||
})
|
||||
it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 58,
|
||||
roadtrip_kwh_per_100: 16,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
openSpec()
|
||||
// By name rather than by position: the rows are no longer at fixed indices now that
|
||||
// the car's figures live behind a disclosure.
|
||||
const consumption = screen.getByTestId('limit-kwhPer100') as HTMLInputElement
|
||||
fireEvent.change(consumption, { target: { value: '20' } })
|
||||
|
||||
// 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge
|
||||
// is the argument that these figures ARE the range, and it only makes it if it
|
||||
// answers now rather than after the field is left.
|
||||
expect(screen.getAllByText('290').length).toBeGreaterThan(0)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The two switches over the route line itself.
|
||||
*
|
||||
* Both are off by default and both change what the map draws, so what is asserted is
|
||||
* that each reports its own key and that neither is written until it is pressed.
|
||||
*/
|
||||
describe('the route line', () => {
|
||||
it('FE-ROADTRIP-LIMITS-013: joining the days up is off until it is asked for', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Connect the days' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(toggle)
|
||||
// Costs a routing request per join and changes every day's kilometres, so it is a
|
||||
// decision rather than a default.
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-014: a colour per day is its own switch', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'A colour per day' }))
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_day_colors', true)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-015: a switch that is on reports the way back off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Connect the days' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
|
||||
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
|
||||
} from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel'
|
||||
import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange'
|
||||
import RangeStrip from './RangeStrip'
|
||||
import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* The three numbers that decide when the rail speaks up about the driving.
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
*
|
||||
* Behind a button rather than laid out in the column, because this is set once and then
|
||||
* read never: a permanent card costs three rows of a narrow sidebar for something most
|
||||
* read never: a permanent card costs rows of a narrow sidebar for something most
|
||||
* travellers touch on the first day of planning and leave alone after. The button carries
|
||||
* the current answer, so what is set stays visible without the form being.
|
||||
* the current answers, so what is set stays visible without the form being.
|
||||
*
|
||||
* All three are personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
|
||||
* They are plain per-user settings, which is why this needs no migration.
|
||||
* All of it is personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and what their car is, belong to the traveller. They are plain
|
||||
* per-user settings, which is why this needs no migration.
|
||||
*
|
||||
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
|
||||
* idea what car this is, and neither has TREK, so the range is a number the traveller
|
||||
* types. Furkot does exactly the same, for the same reason.
|
||||
* The dialog has two halves because the settings are two kinds. The left is the trip: how
|
||||
* long a day may be and what the road may not be. The right is the car, and it is the
|
||||
* half that earns the width — a range can be typed as one number or worked out from what
|
||||
* the vehicle is made of, and the gauge shows the second turning into the first as it is
|
||||
* typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is,
|
||||
* and neither has TREK.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
|
||||
@@ -31,12 +41,38 @@ function parseLimit(raw: string): number {
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
/**
|
||||
* One number in the form, or one number the form worked out.
|
||||
*
|
||||
* `derived` turns the row read-only and states where the figure came from. The row keeps
|
||||
* its shape either way, because a range that switched between an input and a line of text
|
||||
* would move every row under it the moment a consumption was typed.
|
||||
*/
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
|
||||
icon: typeof Clock
|
||||
label: string
|
||||
suffix: string
|
||||
value: number | undefined
|
||||
placeholder: string
|
||||
step?: number
|
||||
derived?: string
|
||||
/**
|
||||
* A stable handle for the tests, because the rows are no longer at fixed indices.
|
||||
*
|
||||
* The vehicle figures now live behind a disclosure, so "the fourth number field" means
|
||||
* different things depending on whether it is open.
|
||||
*/
|
||||
testId?: string
|
||||
/**
|
||||
* What is in the field right now, on every keystroke, saved or not.
|
||||
*
|
||||
* Separate from `onChange` because the two answer different questions. `onChange` is
|
||||
* "this is the new setting", which must stay rare — it is an HTTP write. This one is
|
||||
* "this is what the traveller is looking at", which the gauge needs on every keystroke
|
||||
* or it would sit still until the field is left and the promise it makes, that these
|
||||
* figures are the range, would only pay out after the fact.
|
||||
*/
|
||||
onDraft?: (next: number) => void
|
||||
onChange: (next: number) => void
|
||||
}): React.ReactElement {
|
||||
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
|
||||
@@ -54,59 +90,279 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3">
|
||||
<label className="flex min-h-9 items-center gap-4">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="w-7 text-caption text-content-faint">{suffix}</span>
|
||||
{/* Label and unit in one wrapping flow, not in two columns. The unit used to have a
|
||||
column of its own, sized per section, because "kWh/100 km" does not fit where
|
||||
"min" does — and that column was the reason every section had a right edge of
|
||||
its own. In the flow it simply wraps under a long label and costs nothing. */}
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-body text-content-secondary">{label}</span>
|
||||
{/* The unit as a chip rather than as more grey words after the label. "kWh/100 km"
|
||||
set as prose reads as part of the question; on a chip it reads as what the
|
||||
number beside it will be measured in, which is what it is. */}
|
||||
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-caption leading-none text-content-muted">
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
{/* The one right edge of the whole dialog. Sized from the type rather than in fixed
|
||||
pixels, so it grows with the reader's text-size setting instead of clipping: six
|
||||
digits of tabular figures plus the field's own padding, and never narrower than
|
||||
a switch. Geometry, which the styleguide allows inline. */}
|
||||
<span className="flex shrink-0 justify-end" style={{ width: 'max(5.5rem, calc(6ch + 1.75rem))' }}>
|
||||
{derived ? (
|
||||
// A mark rather than a word: "computed" is eight characters in English and
|
||||
// fourteen in half the locales. The sentence lives in the title and in the
|
||||
// screen-reader text.
|
||||
<span className="flex items-center gap-1.5" title={derived} data-testid="limit-derived">
|
||||
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
|
||||
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
|
||||
<span className="sr-only">{derived}</span>
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step={step ?? 1}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
aria-label={`${label}, ${suffix}`}
|
||||
data-testid={testId}
|
||||
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
// The spinners are the other half of the width problem: they eat a third of a
|
||||
// narrow field and nobody sets a driving limit by clicking an arrow.
|
||||
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One class the drive should leave out, with the switch that asks for it.
|
||||
*
|
||||
* Disabled rather than hidden when there is no engine that can answer: an operator who
|
||||
* pointed the instance at their own OSRM has no second engine, and a switch that flips
|
||||
* and changes nothing is worse than one that says why it cannot.
|
||||
*/
|
||||
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
icon: typeof Coins
|
||||
label: string
|
||||
on: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One section of the dialog, as a panel of its own.
|
||||
*
|
||||
* The complaint the redesign started from was "untidy", and the cause was that four
|
||||
* sections sat in two columns with nothing between them but a heading: no edges, so the
|
||||
* eye had to work out where one ended, and two right edges, so nothing lined up across
|
||||
* the middle. A panel draws that boundary once and the rows inside inherit one rhythm.
|
||||
*
|
||||
* The closing note is part of the panel rather than a paragraph somebody remembers to add:
|
||||
* every one of these sections has a sentence that has to be said (driving time is driving
|
||||
* time only; avoidance is a weighting, not a ban), and left to each caller they drifted
|
||||
* apart in spacing and in colour.
|
||||
*/
|
||||
function Panel({ icon: Icon, title, note, children }: {
|
||||
icon: typeof Clock
|
||||
title: string
|
||||
note?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<SectionTitle icon={Icon}>{title}</SectionTitle>
|
||||
{children}
|
||||
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The name of one section, inside its panel. */
|
||||
function SectionTitle({ icon: Icon, children }: {
|
||||
icon: typeof Clock
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
<Icon size={13} aria-hidden />
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
|
||||
const VEHICLES: { key: VehicleKind | null; labelKey: string; Icon: typeof Car }[] = [
|
||||
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
|
||||
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
|
||||
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
|
||||
]
|
||||
|
||||
/**
|
||||
* What each kind of car is made of, and where each figure is kept.
|
||||
*
|
||||
* Two rows for petrol and three for electric, because that is the difference that
|
||||
* matters: a tank does not lose capacity to the years and a battery does. Everything else
|
||||
* ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a
|
||||
* consumption PREDICTION. This dialog does not predict one; it divides a capacity by a
|
||||
* consumption the traveller states, so those fields would add typing without moving a
|
||||
* single kilometre of the answer.
|
||||
*/
|
||||
const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: typeof Fuel; labelKey: string; step: number }[]> = {
|
||||
combustion: [
|
||||
{ key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 },
|
||||
{ key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
|
||||
],
|
||||
electric: [
|
||||
{ key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 },
|
||||
{ key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
|
||||
{ key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings every one of the car's own figures is stored under.
|
||||
*
|
||||
* Read once, to decide whether the disclosure below the range starts open: a stored
|
||||
* figure means the range row is derived, and its cause has to be in sight.
|
||||
*/
|
||||
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
* A string as well as a number since the avoidance is stored as a comma list: one
|
||||
* decision with three parts, which goes to the router as one request either way.
|
||||
*/
|
||||
onSave?: (key: string, value: number | string | boolean) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const imperial = distanceUnit === 'imperial'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
// an unknown word here would become a costing option the router does not have.
|
||||
const vehicle = settings.roadtrip_vehicle ?? ''
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
const saved: VehicleSpec = {
|
||||
tankLitres: settings.roadtrip_tank_litres,
|
||||
litresPer100: settings.roadtrip_litres_per_100,
|
||||
batteryKwh: settings.roadtrip_battery_kwh,
|
||||
kwhPer100: settings.roadtrip_kwh_per_100,
|
||||
degradationPercent: settings.roadtrip_battery_degradation,
|
||||
}
|
||||
/**
|
||||
* The one field being typed in, ahead of the write that will save it.
|
||||
*
|
||||
* One slot rather than a copy of the form, because only one field can be typed in at a
|
||||
* time, and a second copy of the settings is exactly the hand-mirrored state this
|
||||
* codebase refuses everywhere else. It exists so the gauge answers while the traveller
|
||||
* is still typing: the figures below it are only convincingly "the range" if changing
|
||||
* one moves the bar, and a settings PUT per keystroke is not on offer.
|
||||
*
|
||||
* The value arrives in whatever unit the field is labelled with, so it goes back
|
||||
* through the same conversion the save would have used.
|
||||
*/
|
||||
const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null)
|
||||
/**
|
||||
* Whether the car's own figures are unfolded.
|
||||
*
|
||||
* Open from the start the moment any of them is stored, because then the range row is
|
||||
* read-only and folding its cause away would leave a field nobody can edit with no
|
||||
* visible reason. Read once at mount: re-deriving it would slam the panel shut under
|
||||
* the hand of somebody clearing the last field.
|
||||
*/
|
||||
const [specOpen, setSpecOpen] = useState(() =>
|
||||
SPEC_SETTINGS.some(k => typeof (settings as unknown as Record<string, unknown>)[k] === 'number'))
|
||||
const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill'
|
||||
? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) }
|
||||
: saved
|
||||
const typedKm = preview?.key === 'range'
|
||||
? (imperial ? preview.value / 0.621371 : preview.value)
|
||||
: rangeKm
|
||||
const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null
|
||||
|
||||
// What the parts say, and what the trip will actually plan with. The second is the
|
||||
// first when there is one, which is the rule effectiveRangeKm states once for
|
||||
// everybody who needs it.
|
||||
const computedKm = rangeFromSpec(vehicleKind, spec)
|
||||
const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm)
|
||||
|
||||
const avoiding = parseAvoid(settings.roadtrip_avoid)
|
||||
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
|
||||
// car profile is built without excludable classes on every public host.
|
||||
const canAvoid = valhallaAvailable()
|
||||
const toggleAvoid = (cls: RouteAvoidClass) => {
|
||||
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
|
||||
onSave?.('roadtrip_avoid', serializeAvoid(next))
|
||||
}
|
||||
|
||||
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
|
||||
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
|
||||
]
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
|
||||
// third too early for ever after. The same rule runs through the vehicle's own figures,
|
||||
// where showSpec and storeSpec do it per field.
|
||||
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
|
||||
const km = imperial ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
}
|
||||
|
||||
// What is set, on the button itself, so the form does not have to be open to read it.
|
||||
// Each one keeps its own icon, because three numbers in a row say nothing about which
|
||||
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
|
||||
// an imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// Each one keeps its own icon, because numbers in a row say nothing about which is
|
||||
// which: a clock, a calendar and a pump do. Distances go through formatDistance, so an
|
||||
// imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
// The planning range, not the typed one: when the vehicle's figures win, the badge
|
||||
// has to say what the warnings will actually use.
|
||||
planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null,
|
||||
// Only worth a badge when it is NOT a full tank, which is the case that changes the
|
||||
// arithmetic. "100 %" on the trigger would be a badge for the default.
|
||||
settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100
|
||||
? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` }
|
||||
: null,
|
||||
// The avoidance rides here too, and as ONE badge rather than three: it is a single
|
||||
// decision, the three icons already say which parts of it are on, and a fourth,
|
||||
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
|
||||
avoiding.length && canAvoid
|
||||
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
@@ -120,7 +376,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the three badges below already carry a clock, a
|
||||
{/* No icon tile on the trigger: the badges below already carry a clock, a
|
||||
calendar and a pump, and a fourth mark in front of the title only ate the width
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
@@ -156,43 +412,213 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
|
||||
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={Fuel}
|
||||
label={t('roadtrip.limit.rangeLabel')}
|
||||
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Said out loud rather than left to be discovered: the day figure this is
|
||||
measured against is driving time only. It does not include how long anyone
|
||||
stands still, and it does not include the leg to the hotel, because the
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
{/* Two columns, kept. One column reads more tidily but turns the dialog into
|
||||
something that has to be scrolled, and a setting you cannot see while you
|
||||
change another is worse than a seam down the middle.
|
||||
|
||||
What answers the seam is not the count of columns but what is in them: each
|
||||
section is now a panel of its own with a visible edge, every row inside it
|
||||
ends on the same right edge, and the panels are stacked to one rhythm. The
|
||||
left column is the trip, the right one is the car. */}
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId="limit-legMinutes"
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId="limit-dayMinutes"
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
icon={Signpost}
|
||||
title={t('roadtrip.avoid.section')}
|
||||
note={canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
|
||||
>
|
||||
{AVOID_ROWS.map(({ cls, icon, label }) => (
|
||||
<AvoidRow
|
||||
key={cls}
|
||||
icon={icon}
|
||||
label={label}
|
||||
on={avoiding.includes(cls)}
|
||||
disabled={!canAvoid}
|
||||
onToggle={() => toggleAvoid(cls)}
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
|
||||
{/* The road between one day's last stop and the next day's first is real
|
||||
driving that the rail has never drawn: days are routed one at a time,
|
||||
so that gap was never asked for. Off by default because it costs a
|
||||
routing request per join and changes every day's kilometres. */}
|
||||
<AvoidRow
|
||||
icon={Link2}
|
||||
label={t('roadtrip.line.connect')}
|
||||
on={!!settings.roadtrip_connect_days}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
stroke, and a colour per day is what puts the days back into it. */}
|
||||
<AvoidRow
|
||||
icon={Palette}
|
||||
label={t('roadtrip.line.dayColors')}
|
||||
on={!!settings.roadtrip_day_colors}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
@@ -12,6 +15,11 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
placeId: over.assignmentId * 10,
|
||||
// Filled in by `day()` from the card it is put on, unless the case under test says
|
||||
// otherwise: a stop names the day it is STORED on, which after a night drive is not
|
||||
// the card it is drawn on. -1 is "not stated", never a real id.
|
||||
ownerDayId: -1,
|
||||
ownerIndex: -1,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
@@ -31,12 +39,12 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
const dayId = over.dayId ?? 1
|
||||
return {
|
||||
dayId: 1,
|
||||
dayId,
|
||||
dayNumber: 1,
|
||||
date: null,
|
||||
title: null,
|
||||
stops,
|
||||
legs: [leg()],
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
@@ -44,6 +52,13 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
distance: 100000,
|
||||
duration: 3600,
|
||||
...over,
|
||||
// After the spread, because `over` carries the raw stops this was built from: the
|
||||
// same pair the hook fills in, for every stop that did not state its own.
|
||||
stops: stops.map((s, i) => ({
|
||||
...s,
|
||||
ownerDayId: s.ownerDayId === -1 ? dayId : s.ownerDayId,
|
||||
ownerIndex: s.ownerIndex === -1 ? i : s.ownerIndex,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +66,7 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [day()],
|
||||
lines: [],
|
||||
lineDays: [],
|
||||
segments: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
@@ -201,11 +217,15 @@ describe('RoadtripSidebar', () => {
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Said twice on purpose: once as the break in the chain, once on the arrival after
|
||||
// it, where "01:30" would otherwise read as tonight.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// The day marker rides on the arrival that crossed midnight, where "01:30" would
|
||||
// otherwise read as tonight.
|
||||
// The crossing is no longer a band in the chain: a drive past midnight hands its
|
||||
// stops to the next day's card, and the block around them there is what says so
|
||||
// (`nightSpill.ts`). What is left on a day that could not hand them on — the last day
|
||||
// of a trip, or one whose arrivals are supplied directly as they are here — is the
|
||||
// marker on the arrival, where "01:30" would otherwise read as tonight.
|
||||
// One mention, not two: the marker on the arrival, spelled out for a screen reader.
|
||||
// The band that used to repeat it in the chain is gone — a drive past midnight hands
|
||||
// its stops to the next day's card now, and the block around them there says it.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(1)
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
})
|
||||
|
||||
@@ -224,7 +244,9 @@ describe('RoadtripSidebar', () => {
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// Both findings still reach the stop; only the band that used to repeat the crossing
|
||||
// is gone. Reading just the first warning used to drop the late flag entirely.
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -302,6 +324,23 @@ describe('RoadtripSidebar', () => {
|
||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
|
||||
// The schedule restarts its chain at any pinned time, whatever kind of stop carries
|
||||
// it, so it computes the finding for a fuel halt too. The rail used to hand `late`
|
||||
// only to the numbered branch and throw the pause's own away.
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
|
||||
]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
@@ -540,6 +579,443 @@ describe('RoadtripSidebar', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
|
||||
})
|
||||
|
||||
/**
|
||||
* The block a night drive leaves on the next morning's card.
|
||||
*
|
||||
* The stops are stored on the day they set off from and drawn on the day they are
|
||||
* reached, so what is asserted here is that the block says where they came from and
|
||||
* that the drive through the night is drawn with them. See `nightSpill.ts`.
|
||||
*/
|
||||
describe('a stretch driven onto this day through the night', () => {
|
||||
const spilled = () => day({
|
||||
dayNumber: 2,
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg' }),
|
||||
],
|
||||
spills: [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
departure: '22:30',
|
||||
leg: leg({ distance: 253000, duration: 10380 }),
|
||||
line: [[53, 10], [52, 11]],
|
||||
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
|
||||
}],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-031: names the day the stretch came from', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
|
||||
expect(screen.getByText('From day 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
|
||||
// Without it the card would gain a stop and none of the driving that reaches it.
|
||||
expect(screen.getByText('253 km in 2 h 53 min')).toBeTruthy()
|
||||
expect(screen.getByText('leaves 22:30')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-030: a drag names the day the stop is STORED on, not the card', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [spilled()] })}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>)
|
||||
|
||||
// Dropping the borrowed stop onto its neighbour is a reorder inside day 9 — the day
|
||||
// the server knows it by — even though both are drawn under day 2.
|
||||
// The draggable rows only: the block around the borrowed stretch is a list item
|
||||
// too, and it contains the text of every stop inside it.
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[0], { dataTransfer })
|
||||
fireEvent.dragOver(rows[1], { dataTransfer })
|
||||
fireEvent.drop(rows[1], { dataTransfer })
|
||||
// Dragged from day 9 index 3 — where the server has it — onto day 1 index 1, the
|
||||
// stored position of the row it was dropped on. Neither number is the card's.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(9, 1, 1, 1)
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: an ordinary day draws no block at all', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} />)
|
||||
expect(screen.queryByText(/From day/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The band where the fuel actually ends, and the one search offered from it.
|
||||
*
|
||||
* It hangs off the leg rather than off a stop, because those are two different places:
|
||||
* the range warning marks where somebody finds out, and that can be a long way past the
|
||||
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
|
||||
*/
|
||||
describe('the leg the tank runs out on', () => {
|
||||
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
|
||||
legIndex: 0,
|
||||
intoLegKm: 182,
|
||||
drivenMeters: 182000,
|
||||
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
|
||||
// traveller's own setting, so a band printing it would read the same figure on
|
||||
// every leg of the day.
|
||||
sinceKm: 600,
|
||||
lat: 52.4,
|
||||
lng: 10.2,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Idle unless a case says which part of the search it is standing in. */
|
||||
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
|
||||
openFor: null,
|
||||
loading: false,
|
||||
outcome: null,
|
||||
results: [],
|
||||
offered: [],
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
|
||||
osm_id: `osm-${name}`,
|
||||
name,
|
||||
lat: 52.4,
|
||||
lng: 10.1,
|
||||
category: 'fuel',
|
||||
poi_type: 'fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
alongKm: 170,
|
||||
offRouteKm,
|
||||
spareKm,
|
||||
})
|
||||
|
||||
const empties = dry({ legIndex: 1 })
|
||||
// Three stops, so the leg that empties has one it is not on to be absent from.
|
||||
const thirsty = (): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Hannover' }),
|
||||
stop({ assignmentId: 3, name: 'Kassel' }),
|
||||
],
|
||||
legs: [leg(), leg()],
|
||||
dryPoints: [empties],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
|
||||
// The second row owns the second leg. Drawn on every row instead, the rail would
|
||||
// offer a fill-up on a stretch the car drives with a full tank.
|
||||
const rows = container.querySelectorAll('li')
|
||||
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
|
||||
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
|
||||
// Distance into the leg, which is where a drive band keeps its figures. The 600 is
|
||||
// the setting that was crossed and belongs to no place on the map.
|
||||
expect(screen.getByText('after 182 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
|
||||
// reports the empty tank and pressing it is what does something about it, so the
|
||||
// lamp has to be inside the button rather than next to one.
|
||||
const ask = screen.getByRole('button', { name: 'Find fuel' })
|
||||
expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
expect(within(ask).queryByRole('button')).toBeNull()
|
||||
|
||||
fireEvent.click(ask)
|
||||
// The dry point travels with the ask: the search is a circle pulled back from that
|
||||
// coordinate, and the day alone does not say where on it to look.
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
const overnight = dry({ legIndex: 0 })
|
||||
const card = day({
|
||||
dayNumber: 2,
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg' }),
|
||||
],
|
||||
dryPoints: [overnight],
|
||||
spills: [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
departure: '22:30',
|
||||
leg: leg(),
|
||||
line: [[53, 10], [52, 11]],
|
||||
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
|
||||
}],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search({ openFor: '1:0', loading: true })}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Everything that WRITES names the day a stop is stored on, day 9 here. This is the
|
||||
// exception and it has to be: the open search is filed under the card day and the
|
||||
// leg, so a band asking as day 9 would have its answers arrive under a key no band
|
||||
// is watching and would never open. That it opened at all is the assertion.
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
|
||||
const close = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', loading: true, close })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
// Gone, not merely quieter. Every press is a real request against a shared service,
|
||||
// and a lamp still standing there invites a second one nobody asked for.
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
|
||||
const onAcceptRefuel = vi.fn()
|
||||
const shell = pump('Shell Hannover', 1.4, 48.6)
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
|
||||
onAcceptRefuel={onAcceptRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Two facts about two different things: the detour is what the stop costs and what
|
||||
// the list is sorted by, the spare is what is left when the car draws level. Joined
|
||||
// into one line with a separator, neither exact lookup finds anything.
|
||||
expect(screen.getByText('1.4 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('49 km')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
|
||||
// With the dry point, because the caller has no other way of knowing which leg the
|
||||
// accepted stop belongs on.
|
||||
expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
|
||||
const answered = (outcome: RefuelOutcome) => (
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome })}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
)
|
||||
const { rerender } = render(answered('none'))
|
||||
|
||||
// Only the first of these is a statement about the road. Telling somebody there is
|
||||
// nothing on a stretch that was never fully checked, or never checked at all, is
|
||||
// worse than saying nothing.
|
||||
expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
|
||||
rerender(answered('incomplete'))
|
||||
expect(screen.getByText(/cut short/)).toBeInTheDocument()
|
||||
rerender(answered('failed'))
|
||||
expect(screen.getByText(/did not answer/)).toBeInTheDocument()
|
||||
|
||||
// And a way to ask again rather than a dead end: the place search is a shared
|
||||
// service that does time out, and no answer with no retry reads as broken.
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
|
||||
// The warning marks the stop somebody finds out at and counts the whole tank; the
|
||||
// band sits where the fuel ends and offers a way out. Both at once prints the answer
|
||||
// above the problem.
|
||||
const warned = (): RoadtripDay => ({
|
||||
...thirsty(),
|
||||
driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
expect(screen.queryByText('640 km')).toBeNull()
|
||||
|
||||
unmount()
|
||||
// With no search to run there is nothing better, so the finding is all there is and
|
||||
// has to stay.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
|
||||
expect(screen.getByText('640 km')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Folding a day down to its header.
|
||||
*
|
||||
* The whole header is the control and the hover is the only sign of it: a chevron or a
|
||||
* fold label sitting among the day's own facts reads as a fourth fact about the day
|
||||
* rather than as something to press. Nothing on screen says which way it stands, so the
|
||||
* header has to say it to a screen reader.
|
||||
*/
|
||||
describe('a day folded down to its header', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
// Id and number deliberately apart. The card is titled by its number and folded by
|
||||
// its id, and on a trip whose days were not created in order those two differ —
|
||||
// mixing them up folds a card the reader did not click.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
|
||||
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(onToggleDay).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
|
||||
// Folded by id, again with a card whose id is not its number: a set read as numbers
|
||||
// would fold the card titled "Day 42" here, or nothing at all.
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
|
||||
onToggleDay={vi.fn()}
|
||||
collapsedDayIds={new Set([42])}
|
||||
/>)
|
||||
|
||||
const folded = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(folded).toHaveAttribute('aria-expanded', 'false')
|
||||
// The card keeps its head: a day put away is still one the reader scrolls past, and
|
||||
// its own figures are how it is found again.
|
||||
expect(within(folded).getByText('2 stops')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
|
||||
// One card at a time. Folding is per day, and the id is what decides which.
|
||||
expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getAllByText('Berlin')[1]).toBeVisible()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
|
||||
// A header wearing a button role gets none of a real button's keyboard behaviour, so
|
||||
// it answers both keys itself. Space has to be swallowed as well as answered, or the
|
||||
// rail scrolls a page down behind the day that was just put away. fireEvent reports
|
||||
// a cancelled event as false.
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
// A viewer gets a heading. A control that does nothing when pressed is worse than
|
||||
// one that is not there.
|
||||
expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
|
||||
expect(screen.getByText('Hamburg')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* How full one stop fills the tank.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: the motorway rapid charger is
|
||||
* worth 80 % because the last fifth costs as long again, the one at the hotel is worth
|
||||
* all of it. The badge is the only way into that figure anywhere in TREK.
|
||||
*/
|
||||
describe('the fill badge on a stop that puts fuel back', () => {
|
||||
const refuelling = (fillPercent?: number | null): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
|
||||
],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
|
||||
const onSelectStop = vi.fn()
|
||||
const onSetStopFill = vi.fn()
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [refuelling()] })}
|
||||
onSelectStop={onSelectStop}
|
||||
onSetStopFill={onSetStopFill}
|
||||
/>)
|
||||
|
||||
// The plus stands in the slot the number will occupy: a badge that hid itself until
|
||||
// a figure existed could never be used to make one.
|
||||
fireEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
// The whole row is a button too. A click that reached it would select the stop and
|
||||
// move the map out from under the panel that just opened.
|
||||
expect(onSelectStop).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '80' }))
|
||||
expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
|
||||
const badge = screen.getByRole('button', { name: '+' })
|
||||
|
||||
// The same span-with-a-role bargain as the header, one row further in: unanswered,
|
||||
// the only way to the figure is a mouse.
|
||||
fireEvent.keyDown(badge, { key: 'ArrowDown' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
|
||||
|
||||
// An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
|
||||
// it says this one goes right up, and folding it into the invitation would leave the
|
||||
// traveller reading 80 on a stop that fills to 100.
|
||||
expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
|
||||
// Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
|
||||
// there is a control for a decision nobody makes.
|
||||
expect(screen.queryByText('+')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,4 +140,21 @@ describe('RoadtripStayModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-013: a stay running past midnight says which day it leaves on', () => {
|
||||
// The clock wraps modulo 24 h, so without the carry this reads "01:00" for a
|
||||
// departure the chain places on the next day — the rail says +1 beside it and
|
||||
// the dialog used to say nothing.
|
||||
open({ minutes: 180, arrival: '22:00' })
|
||||
|
||||
expect(screen.getByText('01:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-014: a stay inside one day carries no day marker', () => {
|
||||
open({ minutes: 60, arrival: '12:00' })
|
||||
|
||||
expect(screen.getByText('13:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,6 +81,11 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
return {
|
||||
arrive: formatClockTime(formatClock(arrival), is12h),
|
||||
leave: formatClockTime(formatClock(arrival + minutes), is12h),
|
||||
// `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a
|
||||
// small number again. The rail carries the day it belongs to along with the time;
|
||||
// without the same carry here the dialog would quietly say "leave 01:00" for a
|
||||
// stop the chain places on the next day.
|
||||
leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)),
|
||||
}
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
@@ -164,7 +169,15 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.leave')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
|
||||
{times.leave}
|
||||
{times.leaveCarry > 0 ? (
|
||||
<span className="ms-0.5">
|
||||
{`+${times.leaveCarry}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
|
||||
/**
|
||||
* FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do.
|
||||
*
|
||||
* The figure is two-sided: a stop either has its own or borrows the traveller's, and the
|
||||
* panel has to open showing which. What is worth pinning is the seam between them —
|
||||
* that an inherited figure reads as chosen, that picking it again hands the stop back to
|
||||
* the setting rather than writing the same number, and that the kilometre line under the
|
||||
* pills is absent rather than invented when no range is set.
|
||||
*/
|
||||
|
||||
let anchor: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
anchor = document.createElement('button')
|
||||
document.body.appendChild(anchor)
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never,
|
||||
})
|
||||
})
|
||||
afterEach(() => { anchor.remove() })
|
||||
|
||||
function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) {
|
||||
const onPick = over.onPick ?? vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<StopFillPicker
|
||||
anchor={anchor}
|
||||
current={over.current ?? null}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { onPick, onClose }
|
||||
}
|
||||
|
||||
const pill = (percent: number) => screen.getByRole('button', { name: String(percent) })
|
||||
|
||||
describe('StopFillPicker', () => {
|
||||
it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => {
|
||||
render(<StopFillPicker anchor={null} current={null} onPick={vi.fn()} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => {
|
||||
// Nothing said about this stop, so it fills the way every other one does. Opening
|
||||
// with nothing pressed would suggest the stop has no answer, when it has one.
|
||||
open({ current: null })
|
||||
expect(pill(80)).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pill(100)).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => {
|
||||
open({ current: 60 })
|
||||
expect(pill(60)).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pill(80)).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-004: picking a percentage reports it', () => {
|
||||
const { onPick } = open({ current: null })
|
||||
fireEvent.click(pill(60))
|
||||
expect(onPick).toHaveBeenCalledWith(60)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => {
|
||||
// The same gesture the kind picker uses to un-pick a kind. Writing 60 again would
|
||||
// leave the stop pinned to a number it only ever had by inheritance.
|
||||
const { onPick } = open({ current: 60 })
|
||||
fireEvent.click(pill(60))
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => {
|
||||
// Pressed because it is what the stop uses, but not its own — so clicking it means
|
||||
// "make this one mine", not "take it off".
|
||||
const { onPick } = open({ current: null })
|
||||
fireEvent.click(pill(80))
|
||||
expect(onPick).toHaveBeenCalledWith(80)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => {
|
||||
open({ current: 60 })
|
||||
// 60 % of a 500 km range.
|
||||
expect(screen.getByText(/300 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never,
|
||||
})
|
||||
open({ current: 60 })
|
||||
expect(screen.queryByText(/km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => {
|
||||
// On a stop with no figure of its own it would be a button that says "leave
|
||||
// everything as it is".
|
||||
open({ current: null })
|
||||
expect(screen.queryByText('Use my default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => {
|
||||
const { onPick } = open({ current: 60 })
|
||||
fireEvent.click(screen.getByText('Use my default'))
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import { FS } from './typeScale'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
/**
|
||||
* How full THIS stop fills up.
|
||||
*
|
||||
* A property of the stop rather than of the traveller, which is the whole reason it is
|
||||
* here and not only in the settings dialog: a motorway rapid charger is worth about 80 %
|
||||
* because the last fifth costs as long again, while the one at the hotel is worth all of
|
||||
* it because the car stands there all night. One figure for the whole trip cannot say
|
||||
* both, and the difference between them is a leg.
|
||||
*
|
||||
* Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number
|
||||
* being chosen is a decision — "top up" or "fill right up" — not a measurement, so a
|
||||
* control that offers the six answers people actually give is one tap where a slider is a
|
||||
* drag and a field is a keyboard.
|
||||
*
|
||||
* Under them, what the choice is worth in kilometres, because 80 % is an abstraction and
|
||||
* "about 290 km from here" is the thing the traveller is actually deciding.
|
||||
*/
|
||||
|
||||
/** The answers people actually give. Below half a tank nobody stops on purpose. */
|
||||
const PRESETS = [50, 60, 70, 80, 90, 100] as const
|
||||
|
||||
export default function StopFillPicker({ anchor, current, onPick, onClose }: {
|
||||
/** The element the popover hangs under: the stop's own fill badge. */
|
||||
anchor: HTMLElement | null
|
||||
/** What this stop says, or null when it follows the traveller's own setting. */
|
||||
current: number | null
|
||||
/** A percentage for this stop, or null to hand it back to the setting. */
|
||||
onPick: (percent: number | null) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
|
||||
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const { rangeKm } = useVehicleRange()
|
||||
|
||||
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
|
||||
// here too rather than as three different defaults.
|
||||
const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100
|
||||
const shown = current ?? settingPercent
|
||||
|
||||
return (
|
||||
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.fillTitle')} onClose={onClose}>
|
||||
<div className="flex flex-col gap-2 p-0.5">
|
||||
<p
|
||||
className="px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{t('roadtrip.stop.fillTitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{PRESETS.map(percent => {
|
||||
// Pressed against what the stop will actually use, inherited or not, so the
|
||||
// panel opens showing where the stop stands rather than showing nothing.
|
||||
const on = shown === percent
|
||||
return (
|
||||
<button
|
||||
key={percent}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
// Picking what it already is hands it back to the setting, the same way
|
||||
// the kind picker un-picks a kind it already has.
|
||||
onClick={() => onPick(current === percent ? null : percent)}
|
||||
className={`h-9 w-9 rounded-xl border text-caption font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
on
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{percent}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* What the choice buys. Absent rather than guessed when no range is set: a
|
||||
kilometre figure worked out from nothing would be the most convincing wrong
|
||||
number on the screen. */}
|
||||
<p className="px-1 text-caption text-content-muted">
|
||||
{rangeKm
|
||||
? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) })
|
||||
: t('roadtrip.stop.fillNoRange')}
|
||||
</p>
|
||||
|
||||
{/* Only once there is something to undo. Without an override it would be a button
|
||||
that says "leave everything as it is". */}
|
||||
{current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(null)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<RotateCcw size={14} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="flex-1 text-start">{t('roadtrip.stop.fillDefault')}</span>
|
||||
<span className="tabular-nums text-content-faint">{`${settingPercent} %`}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import React from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
@@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the number. The number sits hard against the left
|
||||
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
|
||||
// the map. Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={t('roadtrip.stop.kind')}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
return (
|
||||
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
|
||||
<div className="flex gap-1">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
|
||||
const on = current === key
|
||||
@@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
{t('roadtrip.stop.backToDestination')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
</AnchoredPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
|
||||
@@ -83,7 +83,7 @@ function mostDistinctPoint(
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
: route.avoids === 'ferry'
|
||||
? labels.noFerry
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
boxAround,
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
haversineKm,
|
||||
pointAtMeters,
|
||||
projectOntoRoute,
|
||||
sliceAtMeters,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor'
|
||||
@@ -155,3 +158,106 @@ describe('simplifyLine', () => {
|
||||
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointAtMeters', () => {
|
||||
// A straight run due east along one parallel, so the arithmetic is checkable by hand:
|
||||
// at 52° north a degree of longitude is about 68.5 km.
|
||||
const WEST: LatLng = { lat: 52, lng: 13 }
|
||||
const EAST: LatLng = { lat: 52, lng: 14 }
|
||||
const LEG = haversineKm(WEST, EAST) * 1000
|
||||
|
||||
it('lands halfway along a single segment', () => {
|
||||
const mid = pointAtMeters([WEST, EAST], LEG / 2)!
|
||||
expect(mid.lat).toBeCloseTo(52, 5)
|
||||
expect(mid.lng).toBeCloseTo(13.5, 3)
|
||||
})
|
||||
|
||||
it('walks past the first segment into the second', () => {
|
||||
const line = [WEST, EAST, { lat: 52, lng: 15 }]
|
||||
const at = pointAtMeters(line, LEG * 1.5)!
|
||||
// One and a half segments in, so halfway through the second.
|
||||
expect(at.lng).toBeGreaterThan(14)
|
||||
expect(at.lng).toBeLessThan(15)
|
||||
})
|
||||
|
||||
it('returns the last point past the end rather than nothing', () => {
|
||||
// A tank that runs out after the day's final stop still ran out somewhere; the
|
||||
// caller decides what to do about that, so this must not answer null.
|
||||
expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST)
|
||||
})
|
||||
|
||||
it('answers the first point for zero, and nothing for an empty line', () => {
|
||||
expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST)
|
||||
expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST)
|
||||
expect(pointAtMeters([], 100)).toBeNull()
|
||||
expect(pointAtMeters([WEST], 100)).toEqual(WEST)
|
||||
})
|
||||
|
||||
it('survives a repeated vertex without dividing by zero', () => {
|
||||
// A route line concatenated from several runs does contain these.
|
||||
const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!
|
||||
expect(at.lng).toBeCloseTo(13.5, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boxAround', () => {
|
||||
it('reaches the asked radius on all four sides', () => {
|
||||
const box = boxAround(BERLIN, 10)
|
||||
// North and south are the easy ones: a degree of latitude is constant.
|
||||
expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0)
|
||||
expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0)
|
||||
})
|
||||
|
||||
it('widens the longitude span the further north it sits', () => {
|
||||
// The same radius is more degrees of longitude in Tromsø than in Berlin.
|
||||
const berlin = boxAround(BERLIN, 10)
|
||||
const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10)
|
||||
expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sliceAtMeters', () => {
|
||||
// A degree of latitude is about 111 km, so this line is roughly 111 km long and every
|
||||
// figure below can be read off it without a calculator.
|
||||
const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
|
||||
|
||||
it('cuts a stretch out of the middle and interpolates both ends', () => {
|
||||
const out = sliceAtMeters(line, 30_000, 60_000)
|
||||
expect(out).toHaveLength(2)
|
||||
expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000)
|
||||
expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000)
|
||||
})
|
||||
|
||||
it('two adjacent slices meet exactly, so the parts rebuild the whole', () => {
|
||||
// The reason this exists: a day's line is its legs end to end, and a night drive
|
||||
// takes its own leg to the next card. A gap at the join would draw as a break in
|
||||
// the road.
|
||||
const first = sliceAtMeters(line, 0, 40_000)
|
||||
const second = sliceAtMeters(line, 40_000, 80_000)
|
||||
const end = first[first.length - 1]
|
||||
expect(haversineKm(end, second[0])).toBeLessThan(0.001)
|
||||
})
|
||||
|
||||
it('clamps to the line rather than running past its end', () => {
|
||||
const out = sliceAtMeters(line, 0, 999_000_000)
|
||||
expect(out[out.length - 1]).toEqual(line[line.length - 1])
|
||||
})
|
||||
|
||||
it('is empty for a zero-length or backwards range', () => {
|
||||
expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([])
|
||||
expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty for a line that is not one', () => {
|
||||
expect(sliceAtMeters([], 0, 100)).toEqual([])
|
||||
expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('survives a repeated vertex without producing NaN', () => {
|
||||
// A concatenated route line really does contain these, and dividing by a zero-length
|
||||
// segment is how a route full of NaN reaches the map.
|
||||
const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
|
||||
const out = sliceAtMeters(doubled, 10_000, 20_000)
|
||||
expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -232,3 +232,111 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
|
||||
/**
|
||||
* The point that lies `metres` along a line, measured the way a car drives it.
|
||||
*
|
||||
* Every other helper in this file goes the other way: given a point, where on the line
|
||||
* is it. This is the inverse, and it exists because the range budget produces a distance
|
||||
* ("the tank is empty 340 km in") with no idea where that is on the map.
|
||||
*
|
||||
* Walked segment by segment rather than approximated, and interpolated inside the
|
||||
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
|
||||
* end it returns the last point rather than null: a tank that runs out after the day's
|
||||
* final stop still ran out somewhere, and the caller decides what to do about that.
|
||||
*/
|
||||
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
|
||||
if (!line.length) return null
|
||||
if (line.length === 1 || metres <= 0) return line[0]
|
||||
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
if (covered + step >= metres) {
|
||||
// How far into this segment, as a fraction. A zero-length segment (two identical
|
||||
// vertices, which a concatenated route line does produce) would divide by zero,
|
||||
// and its start point is the right answer anyway.
|
||||
const t = step > 0 ? (metres - covered) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
covered += step
|
||||
}
|
||||
return line[line.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a line is, in metres, measured the way `sliceAtMeters` walks it.
|
||||
*
|
||||
* Not the same figure the router reports for the same road: that comes off its own graph
|
||||
* while this is great-circle hops between the vertices it sent back. Anything cutting a
|
||||
* line at a distance has to measure it with this, or the last cut falls short of the end
|
||||
* by the difference.
|
||||
*/
|
||||
export function lineMetres(line: LatLng[]): number {
|
||||
let total = 0
|
||||
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch of a line between two distances along it, ends included.
|
||||
*
|
||||
* The router answers a whole run with one polyline and a leg list beside it, so the road
|
||||
* belonging to any single leg has to be cut out afterwards. Which matters because a road
|
||||
* trip draws its stops by the day they are REACHED: a drive across midnight hands its
|
||||
* stops to the next card, and the road under them has to go with them — otherwise the
|
||||
* day they set off from keeps a line nobody drives on it, and the corridor search along
|
||||
* "day 2" looks at road that was covered yesterday.
|
||||
*
|
||||
* Both ends are interpolated inside the segment they fall in rather than snapped to the
|
||||
* nearest vertex, so two adjacent slices meet exactly and concatenating them gives the
|
||||
* original line back.
|
||||
*/
|
||||
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
|
||||
if (line.length < 2 || toMetres <= fromMetres) return []
|
||||
const from = Math.max(0, fromMetres)
|
||||
const out: LatLng[] = []
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
const segStart = covered
|
||||
const segEnd = covered + step
|
||||
covered = segEnd
|
||||
if (segEnd < from) continue
|
||||
if (segStart > toMetres) break
|
||||
// A zero-length segment divides by zero; both its ends are the same point, so the
|
||||
// fraction does not matter and zero is the one that never produces NaN.
|
||||
const at = (m: number): LatLng => {
|
||||
const t = step > 0 ? (m - segStart) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from))
|
||||
out.push(segEnd <= toMetres ? line[i] : at(toMetres))
|
||||
}
|
||||
return out.length > 1 ? out : []
|
||||
}
|
||||
|
||||
/**
|
||||
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
|
||||
*
|
||||
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
|
||||
* r·√2 of reach and finds things in the corners that are further away than they look.
|
||||
* Sized from the radius rather than guessed, and the caller still has to read the
|
||||
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
|
||||
*/
|
||||
export function boxAround(point: LatLng, radiusKm: number): Bbox {
|
||||
const dLat = radiusKm / KM_PER_DEG_LAT
|
||||
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
|
||||
return {
|
||||
south: point.lat - dLat,
|
||||
west: point.lng - dLng,
|
||||
north: point.lat + dLat,
|
||||
east: point.lng + dLng,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dayColor } from './dayColors'
|
||||
|
||||
describe('dayColor', () => {
|
||||
it('FE-DAYCOLORS-001: day one is the blue the route has always been', () => {
|
||||
// So a one-day trip looks exactly as it did before colouring by day existed.
|
||||
expect(dayColor(1).line).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-002: consecutive days differ', () => {
|
||||
const seen = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(n => dayColor(n).line))
|
||||
expect(seen.size).toBe(8)
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-003: the ninth day starts the palette again', () => {
|
||||
expect(dayColor(9)).toEqual(dayColor(1))
|
||||
expect(dayColor(17)).toEqual(dayColor(1))
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-004: the casing is its own colour, not the core', () => {
|
||||
// A single fixed casing went muddy under the warm colours, which is why each entry
|
||||
// carries a darkened hue of its own.
|
||||
for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) {
|
||||
expect(dayColor(n).casing).not.toBe(dayColor(n).line)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-005: a missing or nonsensical day still draws something', () => {
|
||||
// A day number is read off data, and a route with no colour at all draws nothing.
|
||||
expect(dayColor(0)).toEqual(dayColor(1))
|
||||
expect(dayColor(-3)).toEqual(dayColor(1))
|
||||
expect(dayColor(Number.NaN)).toEqual(dayColor(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* A colour per day, for a road trip drawn end to end.
|
||||
*
|
||||
* The map draws one blue line for a whole trip, which is right while the days are read as
|
||||
* separate cards. Once the days are joined into one continuous drive there is nothing in
|
||||
* the line to say where one ends, so the colour has to do it.
|
||||
*
|
||||
* Fixed hex rather than theme tokens, and deliberately so: these sit on map tiles, not on
|
||||
* the app's own surfaces, and a token that follows the user's accent would put the route
|
||||
* in the same colour as the terrain on some schemes. The same exception the rest of the
|
||||
* map's paint takes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Eight, then it repeats.
|
||||
*
|
||||
* Picked for separation at a 5px stroke over a pale basemap rather than for a gradient: a
|
||||
* ramp reads as "later" and days are not a quantity. Blue stays first because it is the
|
||||
* colour the route has always been, so a one-day trip looks unchanged.
|
||||
*
|
||||
* The second value is the casing under the core — the same hue darkened, which is what
|
||||
* keeps the line readable over water and forest. A single fixed casing would have gone
|
||||
* muddy under the warm colours.
|
||||
*/
|
||||
const DAY_COLORS: readonly [string, string][] = [
|
||||
['#0a84ff', '#0a5cc2'], // theme-lint-disable — map paint, see file comment
|
||||
['#ff9f0a', '#c2740a'], // theme-lint-disable
|
||||
['#30d158', '#1f9c40'], // theme-lint-disable
|
||||
['#bf5af2', '#8e3fb8'], // theme-lint-disable
|
||||
['#ff375f', '#c22546'], // theme-lint-disable
|
||||
['#64d2ff', '#3d9dc2'], // theme-lint-disable
|
||||
['#ffd60a', '#c2a30a'], // theme-lint-disable
|
||||
['#ac8e68', '#7d654a'], // theme-lint-disable
|
||||
]
|
||||
|
||||
/** The core and casing a given day of the trip is drawn in. Day numbers start at 1. */
|
||||
export function dayColor(dayNumber: number): { line: string; casing: string } {
|
||||
// A day number that is missing or nonsensical still has to draw something, and the
|
||||
// first colour is the one the route had before any of this existed.
|
||||
const at = Number.isFinite(dayNumber) && dayNumber > 0 ? (dayNumber - 1) % DAY_COLORS.length : 0
|
||||
const [line, casing] = DAY_COLORS[at]
|
||||
return { line, casing }
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { spillChains } from './nightSpill'
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
ownerDayId: 1,
|
||||
ownerIndex: 0,
|
||||
placeId: over.assignmentId * 10,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** A day of the plan, with every stop's stored position filled in for it. */
|
||||
function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay {
|
||||
return {
|
||||
dayId,
|
||||
dayNumber,
|
||||
date: null,
|
||||
title: null,
|
||||
stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })),
|
||||
}
|
||||
}
|
||||
|
||||
const seg = (minutes: number, km = 100): RouteSegment =>
|
||||
({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment
|
||||
|
||||
/** Every leg the same length, which is all most of these cases need. */
|
||||
const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] })
|
||||
|
||||
/** No leg routed at all, which is what an unrouted or offline trip looks like. */
|
||||
const noLegs = () => undefined
|
||||
|
||||
describe('spillChains', () => {
|
||||
it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
])]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(1)
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin'])
|
||||
expect(chains[0].spills).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => {
|
||||
// 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [
|
||||
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
|
||||
stop({ assignmentId: 4, name: 'Leipzig' }),
|
||||
]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg'])
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [
|
||||
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
|
||||
stop({ assignmentId: 4, name: 'Leipzig' }),
|
||||
]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180, 240))
|
||||
|
||||
expect(chains[1].spills).toHaveLength(1)
|
||||
const spill = chains[1].spills[0]
|
||||
expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' })
|
||||
// The leg is the drive that crossed midnight, not the one after it: without it the
|
||||
// card would gain a stop and none of the distance that reaches it.
|
||||
expect(spill.leg?.distance).toBe(240000)
|
||||
expect(spill.line.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => {
|
||||
const plan = [
|
||||
day(7, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
// Drawn under day 2, stored on day 1 — which is what every callback still names.
|
||||
const moved = chains[1].stops.find(s => s.name === 'Neuruppin')
|
||||
expect(moved?.ownerDayId).toBe(7)
|
||||
expect(moved?.ownerIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
])]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(1)
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
|
||||
expect(chains[0].spills).toEqual([])
|
||||
// And it still says so: the arrival is a day past the one the card is under.
|
||||
expect(chains[0].schedule.entries[1].dayOffset).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => {
|
||||
// The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin
|
||||
// to the occurrence nearest the drive reaching it, so nine hours late reads as nearer
|
||||
// than fifteen hours of waiting and 23:00 lands on the PREVIOUS day. The whole chain
|
||||
// is then lifted to stay positive, which leaves the FIRST stop wearing a day offset
|
||||
// and the one after it none. Placing stops on those offsets moved the early stop to
|
||||
// tomorrow and left the late one behind, visiting them out of order.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Cobbelsdorf', time: '07:00', dwellMinutes: 45 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg', time: '23:00', dwellMinutes: 180 }),
|
||||
stop({ assignmentId: 3, name: 'Leipzig' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(50))
|
||||
|
||||
// Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight.
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg'])
|
||||
// Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves.
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-007: two nights land on two different days', () => {
|
||||
// 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and
|
||||
// 08:00 the day after — two backwards jumps of the clock, two days on.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'A', time: '20:00' }),
|
||||
stop({ assignmentId: 2, name: 'B' }),
|
||||
stop({ assignmentId: 3, name: 'C' }),
|
||||
stop({ assignmentId: 4, name: 'D' }),
|
||||
]),
|
||||
day(2, 2, []),
|
||||
day(3, 3, []),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(12 * 60))
|
||||
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['A'])
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C'])
|
||||
expect(chains[2].stops.map(s => s.name)).toEqual(['D'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00' }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], noLegs)
|
||||
|
||||
// No drive, no arrival, no crossing — and a guess would be worse than nothing.
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
|
||||
expect(chains[1].spills).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
])]
|
||||
const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }]
|
||||
|
||||
const chains = spillChains(plan, quiet, everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(2)
|
||||
expect(chains[1].dayId).toBe(2)
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
// The block around it is what says the night happened; a band as well would say it
|
||||
// twice. And it is not "the next day" once it is drawn under the next day's date.
|
||||
expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false)
|
||||
expect(chains[1].schedule.entries[0].dayOffset).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => {
|
||||
// Wittenberg is pinned two hours before the drive can get there, which is a "late"
|
||||
// finding against index 1 of day 1. It moves to day 2 as index 0.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
const late = chains[1].schedule.warnings.filter(w => w.code === 'late')
|
||||
expect(late).toHaveLength(1)
|
||||
expect(late[0].index).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
stop({ assignmentId: 3, name: 'Potsdam' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg'])
|
||||
expect(chains[1].spills).toHaveLength(1)
|
||||
expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { computeSchedule, parseClock, type Schedule } from './roadtripModel'
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/**
|
||||
* Which date each stop of a road trip is drawn on.
|
||||
*
|
||||
* A day plan stores a stop against a day, and that is the right model for planning: the
|
||||
* traveller put it there. But a road trip is read as a drive, and a drive that leaves at
|
||||
* 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band
|
||||
* between the two stops and then carry on listing the arrival under yesterday's date —
|
||||
* the one thing the band had just said was wrong.
|
||||
*
|
||||
* So a road trip lays its stops out by the day they are actually reached, and the drive
|
||||
* across midnight travels with them, which is what makes the kilometres count on the day
|
||||
* they are driven rather than on the day they were set off on.
|
||||
*
|
||||
* **Nothing is written.** The stop still belongs to the day it was planned on, the day
|
||||
* plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. Because
|
||||
* this is worked out again from the arrival times every time they change, shortening the
|
||||
* stay before a night drive brings the stop back on its own — there is no state to undo,
|
||||
* and no way for the arrangement and the times to disagree.
|
||||
*/
|
||||
|
||||
/** A stretch of one card's chain that was driven onto it from an earlier day. */
|
||||
export interface SpillMark {
|
||||
/** Where it starts in the card's `stops`. */
|
||||
at: number
|
||||
/** How many stops it covers. */
|
||||
count: number
|
||||
/** The day they are stored on, as the traveller numbers it. */
|
||||
fromDayNumber: number
|
||||
/** When the drive set off, read off that day's own schedule. */
|
||||
departure: string | null
|
||||
/** The drive across midnight itself, or undefined when that leg never routed. */
|
||||
leg: RouteSegment | undefined
|
||||
/**
|
||||
* The stop the night drive LEAVES from, still on the earlier card.
|
||||
*
|
||||
* Needed because that drive is drawn on this card while belonging, leg-wise, to the
|
||||
* stop before it: anything measured along this card's line that lands on the incoming
|
||||
* stretch has to be anchored to this stop, not to the first one drawn here. Putting a
|
||||
* via there and anchoring it to the wrong stop is what made a route double back.
|
||||
*/
|
||||
fromStop: RoadtripStop | undefined
|
||||
/**
|
||||
* The road it covers, so the card it lands on can draw it and count it.
|
||||
*
|
||||
* Without this the night is a hole: the day it set off from ends before it and the day
|
||||
* it arrives on starts after it, so its kilometres belong to neither and the map draws
|
||||
* two runs with a gap between them.
|
||||
*/
|
||||
line: [number, number][]
|
||||
}
|
||||
|
||||
/** One date's worth of driving, before the legs and warnings are hung off it. */
|
||||
export interface SpillChain {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** In driving order: anything inherited first, then the day's own. */
|
||||
stops: RoadtripStop[]
|
||||
/** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */
|
||||
schedule: Schedule
|
||||
/** The inherited stretches, in the order they appear. Empty on an ordinary day. */
|
||||
spills: SpillMark[]
|
||||
}
|
||||
|
||||
/** A day as the plan holds it, before anything is routed onto it. */
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
/** What the caller can find out about the road between two stops. */
|
||||
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined
|
||||
|
||||
/** One stop on its way to a card, with the times its own day gave it. */
|
||||
interface Placed {
|
||||
stop: RoadtripStop
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** Days past its own day's first stop. Renumbered against the card it lands on. */
|
||||
dayOffset: number
|
||||
/** Findings its own day filed against it, to be renumbered with it. */
|
||||
codes: ('late' | 'overnight' | 'leg' | 'range')[]
|
||||
lateMinutes: number | null
|
||||
/** Which day it came from, or null when it is the card's own. */
|
||||
fromDayNumber: number | null
|
||||
departedAt: string | null
|
||||
leg: RouteSegment | undefined
|
||||
line: [number, number][]
|
||||
from: RoadtripStop | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a trip out by the date each stop is reached.
|
||||
*
|
||||
* Every day of the trip gets a chain, in trip order, including the ones that carry no
|
||||
* drive of their own — a day whose only content was driven onto it through the night is
|
||||
* still a day, and the alternative is a date that silently disappears. The caller drops
|
||||
* the chains that end up with fewer than two stops, exactly as it did before.
|
||||
*/
|
||||
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
|
||||
const all: PlanDay[] = [...plan, ...quietDays.map(d => ({
|
||||
dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops,
|
||||
}))].sort((a, b) => a.dayNumber - b.dayNumber)
|
||||
const numbers = new Set(all.map(d => d.dayNumber))
|
||||
const landing = new Map<number, Placed[]>()
|
||||
for (const d of all) landing.set(d.dayNumber, [])
|
||||
|
||||
for (const d of all) {
|
||||
// A day's own schedule, walked over its own stops: this is what says where midnight
|
||||
// falls, and it is the only reading of these times that is anchored to the times the
|
||||
// traveller actually pinned.
|
||||
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]))
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const schedule = computeSchedule(
|
||||
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
legs.map(l => l?.duration),
|
||||
)
|
||||
/**
|
||||
* Which day of this chain each stop is reached on, read off the clock itself.
|
||||
*
|
||||
* Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the
|
||||
* occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one
|
||||
* pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than
|
||||
* fifteen hours of waiting), the chain drops below midnight, and the schedule lifts
|
||||
* the whole day back up to keep it positive. The result is an early stop wearing a
|
||||
* higher offset than the one after it, and placing stops on those offsets tears the
|
||||
* drive into pieces that are visited out of order.
|
||||
*
|
||||
* A clock that goes backwards is the one signal none of that can fake. Arrivals only
|
||||
* ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one
|
||||
* thing only: midnight happened in between. A stop with no arrival yet inherits the
|
||||
* day of the one before it rather than resetting the count.
|
||||
*/
|
||||
let day = 0
|
||||
let previous: number | null = null
|
||||
d.stops.forEach((stop, i) => {
|
||||
const entry = schedule.entries[i]
|
||||
const clock = parseClock(entry?.arrival)
|
||||
if (clock !== null) {
|
||||
if (previous !== null && clock < previous) day += 1
|
||||
previous = clock
|
||||
}
|
||||
const offset = day
|
||||
const marks = schedule.warnings.filter(w => w.index === i)
|
||||
// A day the trip does not have is not a day to move onto. Those stops stay where
|
||||
// they are and keep the crossing marked the way the rail always marked it, because
|
||||
// the only other answer would be to invent a date nobody planned. The ratchet stays
|
||||
// where it was too, so the rest of the chain stays with them.
|
||||
const reachable = offset > 0 && numbers.has(d.dayNumber + offset)
|
||||
const target = reachable ? d.dayNumber + offset : d.dayNumber
|
||||
const moved = target !== d.dayNumber
|
||||
landing.get(target)?.push({
|
||||
stop,
|
||||
arrival: entry?.arrival ?? null,
|
||||
departure: entry?.departure ?? null,
|
||||
// Renumbered against the card it lands on: 01:23 is not "the next day" once it is
|
||||
// drawn under the next day's date. A stop that could not move keeps its offset,
|
||||
// which is what still makes the "+1" and the crossing band read correctly.
|
||||
dayOffset: moved ? 0 : offset,
|
||||
// The crossing itself is the spill block's own head wherever the stop moved, so
|
||||
// it would be said twice.
|
||||
codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')),
|
||||
lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null,
|
||||
fromDayNumber: moved ? d.dayNumber : null,
|
||||
departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null,
|
||||
leg: moved ? legs[i - 1] : undefined,
|
||||
line: moved ? routed[i - 1]?.line ?? [] : [],
|
||||
from: moved ? d.stops[i - 1] : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const out: SpillChain[] = []
|
||||
for (const d of all) {
|
||||
const placed = landing.get(d.dayNumber) ?? []
|
||||
if (!placed.length) continue
|
||||
// Inherited stretches first, oldest day first, then the card's own stops: that is
|
||||
// the order they are driven in.
|
||||
placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER))
|
||||
const spills: SpillMark[] = []
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const p = placed[i]
|
||||
if (p.fromDayNumber === null) continue
|
||||
const last = spills[spills.length - 1]
|
||||
if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1
|
||||
else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from })
|
||||
}
|
||||
out.push({
|
||||
dayId: d.dayId,
|
||||
dayNumber: d.dayNumber,
|
||||
date: d.date,
|
||||
title: d.title,
|
||||
stops: placed.map(p => p.stop),
|
||||
schedule: {
|
||||
entries: placed.map(p => ({
|
||||
arrival: p.arrival,
|
||||
departure: p.departure,
|
||||
anchored: p.stop.time !== null && p.stop.time !== undefined,
|
||||
dayOffset: p.dayOffset,
|
||||
})),
|
||||
warnings: placed.flatMap((p, i) => p.codes.map(code => (
|
||||
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code }
|
||||
))),
|
||||
},
|
||||
spills,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { reachableRefuels, outcomeOf } from './refuelSuggestion'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
// A straight run due east at 52° north, where a degree of longitude is about 68.5 km.
|
||||
const LINE: LatLng[] = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52, lng: 14 },
|
||||
{ lat: 52, lng: 15 },
|
||||
]
|
||||
|
||||
const poi = (name: string, lat: number, lng: number): Poi =>
|
||||
({ osm_id: `node:${name}`, name, lat, lng } as Poi)
|
||||
|
||||
describe('reachableRefuels', () => {
|
||||
it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => {
|
||||
// Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side
|
||||
// of an empty tank, and offering it is offering a walk.
|
||||
const early = poi('early', 52, 13.73) // ~50 km
|
||||
const late = poi('late', 52, 14.9) // ~130 km
|
||||
const out = reachableRefuels([early, late], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['early'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => {
|
||||
// Both sit at the same point along the road, 60 km in, with 100 km of range. One is
|
||||
// on the road; the other is 20 km off it, so 40 km of driving, which no longer fits
|
||||
// in the 40 km that are left once the reserve is kept back.
|
||||
const onRoad = poi('on-road', 52, 13.876)
|
||||
const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north
|
||||
const out = reachableRefuels([onRoad, offRoad], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['on-road'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => {
|
||||
// 8 km before the dry point is inside the ten kilometre reserve.
|
||||
const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km
|
||||
expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => {
|
||||
// Measured on a real day: the station that got furthest was 9.2 km off the road for
|
||||
// one kilometre more of range. Everything in this list is already reachable, so what
|
||||
// separates them is what the stop costs.
|
||||
const nearRoad = poi('near-road', 52, 13.15) // on the line, early
|
||||
const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later
|
||||
const out = reachableRefuels([bigDetour, nearRoad], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => {
|
||||
// Filling up early wastes the tank that was already paid for.
|
||||
const early = poi('early', 52, 13.15)
|
||||
const later = poi('later', 52, 14.16)
|
||||
const out = reachableRefuels([early, later], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['later', 'early'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => {
|
||||
// Matched on position, not on id: the same station added by hand or from another
|
||||
// source carries a different osm_id.
|
||||
const pump = poi('pump', 52, 13.73)
|
||||
const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] })
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => {
|
||||
const wayOff = poi('way-off', 49, 13.5)
|
||||
expect(reachableRefuels([wayOff], LINE, 100)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('outcomeOf', () => {
|
||||
it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => {
|
||||
// The distinction the whole band hangs on. Saying "none on this stretch" after a
|
||||
// failed request states a fact that was never checked.
|
||||
expect(outcomeOf([], null)).toBe('failed')
|
||||
expect(outcomeOf([], { truncated: true })).toBe('incomplete')
|
||||
expect(outcomeOf([], {})).toBe('none')
|
||||
// `clamped` is not the same thing: it means the server searched a smaller circle
|
||||
// than the box described, and the caller sizes the box so that never happens.
|
||||
// Treating it as "could not check" turned every empty answer into a shrug.
|
||||
expect(outcomeOf([], { clamped: true })).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
|
||||
const found = [{ name: 'x' }] as never
|
||||
expect(outcomeOf(found, { truncated: true })).toBe('found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reachableRefuels — one entry per station', () => {
|
||||
const at = (name: string, lat: number, lng: number): Poi =>
|
||||
({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi)
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => {
|
||||
// Measured on a real day: the top three offers were the same "autostrom plus GmbH"
|
||||
// three times over, because OSM maps every socket as its own node.
|
||||
// The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m
|
||||
// between the pairs.
|
||||
const sockets = [
|
||||
at('autostrom plus GmbH', 52, 13.3),
|
||||
at('autostrom plus GmbH', 52.00005, 13.30005),
|
||||
at('autostrom plus GmbH', 52.004, 13.3005),
|
||||
at('autostrom plus GmbH', 52.00405, 13.30045),
|
||||
]
|
||||
const out = reachableRefuels(sockets, LINE, 100)
|
||||
expect(out).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => {
|
||||
// Name alone is not identity: a chain has a branch everywhere.
|
||||
// Measured: genuinely different branches were 18 to 36 km apart.
|
||||
const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100)
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => {
|
||||
// Position alone is not identity either.
|
||||
const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100)
|
||||
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
/**
|
||||
* Picking a filling station for a tank that is about to run out.
|
||||
*
|
||||
* Pure: no React, no network. The caller does the one request and hands the answers
|
||||
* here, which keeps the judgement — which of these can the car actually reach — testable
|
||||
* without a server.
|
||||
*/
|
||||
|
||||
/** A candidate, with the two figures that decide whether it is any use. */
|
||||
export interface RefuelCandidate extends Poi {
|
||||
/** Kilometres along the day's driving line, in the same space as the dry point. */
|
||||
alongKm: number
|
||||
/** How far off the road it sits. */
|
||||
offRouteKm: number
|
||||
/** Kilometres of range still in the tank when the car draws level with it. */
|
||||
spareKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the last drop to keep back, in kilometres.
|
||||
*
|
||||
* Arriving at a pump with nothing left is not a plan, and the range figure a traveller
|
||||
* types is a round number from a manual rather than a measurement. Ten kilometres is
|
||||
* small enough not to move the suggestion and large enough not to promise the
|
||||
* impossible.
|
||||
*/
|
||||
const RESERVE_KM = 10
|
||||
|
||||
/**
|
||||
* The stations that can actually be reached, nearest to the dry point first.
|
||||
*
|
||||
* Three filters, and each one exists because its absence produces a wrong answer rather
|
||||
* than an untidy one:
|
||||
*
|
||||
* - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the
|
||||
* far side of an empty tank, and offering it is offering a walk.
|
||||
* - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a
|
||||
* tank this close to empty that is the difference between arriving and not.
|
||||
* - What is left has to fit in the range that remains, minus a reserve.
|
||||
*
|
||||
* Sorted by the detour, not by how far along the road it sits. Measured against a real
|
||||
* day: the station that got furthest was 9.2 km off the road for one kilometre more of
|
||||
* range, while one 0.9 km off sat almost as late. Everything in this list is already
|
||||
* reachable, so what separates them is what the stop costs, and that is the detour. Ties
|
||||
* go to the later one, because filling up early wastes the tank you already paid for.
|
||||
*/
|
||||
export function reachableRefuels(
|
||||
pois: Poi[],
|
||||
line: LatLng[],
|
||||
dryAlongKm: number,
|
||||
{ existing = [] }: { existing?: { lat: number; lng: number }[] } = {},
|
||||
): RefuelCandidate[] {
|
||||
const out: RefuelCandidate[] = []
|
||||
for (const poi of pois) {
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line)
|
||||
if (!hit) continue
|
||||
// Reached before the tank empties, once the detour out and back is paid for.
|
||||
const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM
|
||||
if (spareKm < 0) continue
|
||||
// Already planned. Matched on position rather than on id: the same pump added by
|
||||
// hand, or from another source, carries a different osm_id and would otherwise be
|
||||
// suggested next to itself.
|
||||
if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue
|
||||
out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm })
|
||||
}
|
||||
return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm))
|
||||
}
|
||||
|
||||
/**
|
||||
* What a search actually established, as opposed to what it returned.
|
||||
*
|
||||
* An empty list means one of two very different things, and saying the wrong one is
|
||||
* worse than saying nothing: either there is no filling station on this stretch, or we
|
||||
* never managed to look. The server flags the second case itself (`truncated` when it
|
||||
* hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the
|
||||
* third. Only `none` is a statement about the road.
|
||||
*/
|
||||
export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed'
|
||||
|
||||
export function outcomeOf(
|
||||
candidates: RefuelCandidate[],
|
||||
answer: { truncated?: boolean; clamped?: boolean } | null,
|
||||
): RefuelOutcome {
|
||||
if (!answer) return 'failed'
|
||||
if (candidates.length) return 'found'
|
||||
// `truncated` alone. `clamped` only means the server searched a smaller circle than
|
||||
// the box described, which the caller now sizes so it never happens — and reporting it
|
||||
// as "we could not check" turned every empty answer into a shrug.
|
||||
return answer.truncated ? 'incomplete' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per filling station, not one per pump.
|
||||
*
|
||||
* A charging site is mapped in OSM as a node per socket and a forecourt often as one per
|
||||
* pump, all with the same name a few metres apart. Deduplicating by id keeps every one of
|
||||
* them, which is right on a map and wrong in a list of three offers: measured on a real
|
||||
* day, the top three were the same "autostrom plus GmbH" three times over.
|
||||
*
|
||||
* Same name and within half a kilometre counts as the same place. Both halves matter:
|
||||
* not position alone, because two operators do share a motorway services, and not name
|
||||
* alone, because a chain has a branch in every town.
|
||||
*
|
||||
* Half a kilometre comes from the data rather than from taste. In one 25 km search the
|
||||
* four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between
|
||||
* the pairs — while genuinely different branches of the same chain were 18 to 36 km
|
||||
* apart. Anything from 500 m to a few kilometres separates those two cases; the lower end
|
||||
* is the safer place to stand.
|
||||
*/
|
||||
const SAME_PLACE_KM = 0.5
|
||||
|
||||
function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
|
||||
const kept: RefuelCandidate[] = []
|
||||
for (const poi of sorted) {
|
||||
const twin = kept.some(k =>
|
||||
k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM)
|
||||
if (!twin) kept.push(poi)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
refuelStopTypeFor,
|
||||
} from './roadtripModel'
|
||||
|
||||
describe('formatDurationShort', () => {
|
||||
@@ -89,7 +90,10 @@ describe('computeSchedule', () => {
|
||||
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
|
||||
})
|
||||
|
||||
it('leaves stops before the first pinned time blank rather than inventing one', () => {
|
||||
it('works back from the first pinned time to say when to set off', () => {
|
||||
// The museum opens at ten and we want an hour at the stop before it, an hour of
|
||||
// driving in between: be there at eight. Answering that is the reason to pin a time
|
||||
// on the second stop at all.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
@@ -97,10 +101,41 @@ describe('computeSchedule', () => {
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false })
|
||||
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('stops working back at a leg that never routed rather than inventing one', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
{ anchor: null, dwellMinutes: 30 },
|
||||
{ anchor: '10:00', dwellMinutes: 30 },
|
||||
],
|
||||
[undefined, hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
// Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30.
|
||||
expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' })
|
||||
expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('keeps the earliest stop on day zero when working back crosses midnight', () => {
|
||||
// Pinned at one in the morning with three hours of driving before it: the stop before
|
||||
// is the evening before. The chain shifts up a day rather than printing a day below
|
||||
// zero, so the boundary shows between the two stops instead of under the first one.
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
{ anchor: '01:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(3)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 })
|
||||
expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true })
|
||||
expect(warnings).toContainEqual({ index: 1, code: 'overnight' })
|
||||
})
|
||||
|
||||
it('restarts the cascade at a pinned stop instead of pushing it', () => {
|
||||
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
|
||||
// after it counts from 11:00, not from the drive.
|
||||
@@ -545,3 +580,234 @@ describe('deriveDriveWarnings — what counts as driving', () => {
|
||||
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — where the tank actually runs dry', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
const ferry = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => {
|
||||
// 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in;
|
||||
// the tank was empty 100 km earlier, halfway through the second leg. Suggesting a
|
||||
// filling station at the warning would suggest one the car cannot reach.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(120, 200), drive(120, 200)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }])
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => {
|
||||
// A long run with nothing on it warns at every stop on purpose, because a warning is
|
||||
// not a fill-up. One refuel offer per warning would stack three identical offers for
|
||||
// a single tank down one day.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 400), drive(60, 400), drive(60, 400)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toHaveLength(2)
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 400), drive(60, 400), drive(60, 400)],
|
||||
[false, false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
// Empty once before the fuel stop, then once more after it.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0].drivenMeters).toBe(600000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => {
|
||||
// The trap this field exists for. The budget skips a ferry, so the dry point must be
|
||||
// counted in DRIVING metres only; measuring along the day's drawn line instead would
|
||||
// overshoot by the whole crossing and put the marker out at sea.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), ferry(120, 50), drive(60, 200)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), drive(60, 200)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
250,
|
||||
)
|
||||
// Only 50 km left on arrival, so it empties a quarter into the first leg.
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => {
|
||||
const noLimit = deriveDriveWarnings(
|
||||
[drive(60, 900)],
|
||||
[false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(noLimit.emptyAt).toEqual([])
|
||||
|
||||
const withinRange = deriveDriveWarnings(
|
||||
[drive(60, 100)],
|
||||
[false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(withinRange.emptyAt).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => {
|
||||
// The budget goes null and stays null, so there is no honest dry point to offer.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), undefined, drive(60, 400)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.emptyAt).toEqual([])
|
||||
expect(out.carryKm).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refuelsRange — what fills which tank', () => {
|
||||
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
|
||||
// What TREK did before the setting existed, and the right answer for somebody who
|
||||
// never opened the dialog. Anything else would quietly change their warnings.
|
||||
expect(refuelsRange('fuel')).toBe(true)
|
||||
expect(refuelsRange('charging')).toBe(true)
|
||||
expect(refuelsRange('fuel', null)).toBe(true)
|
||||
expect(refuelsRange('charging', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
|
||||
// The bug this exists for: an electric car pausing at a petrol station had its
|
||||
// battery refilled on paper, the warnings went quiet for the rest of the day, and
|
||||
// the driver was told nothing.
|
||||
expect(refuelsRange('charging', 'electric')).toBe(true)
|
||||
expect(refuelsRange('fuel', 'electric')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
|
||||
expect(refuelsRange('fuel', 'combustion')).toBe(true)
|
||||
expect(refuelsRange('charging', 'combustion')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
|
||||
for (const vehicle of [null, 'combustion', 'electric'] as const) {
|
||||
expect(refuelsRange('rest_area', vehicle)).toBe(false)
|
||||
expect(refuelsRange('restaurant', vehicle)).toBe(false)
|
||||
expect(refuelsRange(null, vehicle)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
|
||||
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
|
||||
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
|
||||
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — filling only part way', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => {
|
||||
// Nobody charges to 100 % on the road: the last fifth takes as long as the first
|
||||
// four. Counting a stop as a full tank overstates what comes after it by that fifth.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
)
|
||||
// After the stop the budget restarts at 100 km rather than 0, so the tank is dry
|
||||
// 400 km into the second leg. Filled all the way it would have gone the whole 450.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => {
|
||||
const legs = [drive(60, 100), drive(60, 450)]
|
||||
const refuels = [false, true, false]
|
||||
const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }
|
||||
for (const fillPercent of [undefined, null, 0, 100]) {
|
||||
const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0)
|
||||
// A full tank after the stop covers the remaining 450 km without a finding.
|
||||
expect(out.emptyAt).toEqual([])
|
||||
expect(out.warnings).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 900)],
|
||||
[true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.emptyAt).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => {
|
||||
// The motorway charger tops up to 60 %; the traveller's own default is 80. Reading
|
||||
// the default here would promise 100 km the car does not have.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, 60, null],
|
||||
)
|
||||
// 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than
|
||||
// the 400 the traveller's own figure would have given.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => {
|
||||
// Same day, same stop, nothing said about it: the default has to keep applying, or
|
||||
// adding the field would have quietly changed every trip that never used it.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, null, null],
|
||||
)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => {
|
||||
// The charger at the hotel: the car stands there all night, so this one goes to 100
|
||||
// even on a trip whose default stops at 80.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, 100, null],
|
||||
)
|
||||
expect(out.emptyAt).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
import type { RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
@@ -121,9 +123,32 @@ export interface ScheduleWarning {
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/** Whether stopping here starts the range budget over. */
|
||||
export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
|
||||
/**
|
||||
* What the traveller drives, which decides what actually fills the tank.
|
||||
*
|
||||
* Absent means both count, which is what TREK did before the setting existed and is the
|
||||
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
|
||||
* a petrol station does not charge a battery, and a charger does not fill a tank.
|
||||
*/
|
||||
export type VehicleKind = 'combustion' | 'electric'
|
||||
|
||||
/** The stop kind that refills the named vehicle, for filtering a search. */
|
||||
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
|
||||
if (vehicle === 'combustion') return ['fuel']
|
||||
if (vehicle === 'electric') return ['charging']
|
||||
return REFUELLING_STOP_TYPES
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether stopping here starts the range budget over.
|
||||
*
|
||||
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
|
||||
* named the other stops counting, because the alternative is arithmetic that is simply
|
||||
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
|
||||
* the warnings went quiet for the rest of the day, and the driver was told nothing.
|
||||
*/
|
||||
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
|
||||
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
|
||||
*/
|
||||
export interface DriveLimits {
|
||||
/**
|
||||
* How full a fill-up actually goes, 1 to 100, or null for "all the way".
|
||||
*
|
||||
* Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
|
||||
* the first four and the manual says not to. Treating every stop as a full tank
|
||||
* overstates the range after it by exactly that fifth, which is a whole leg on a long
|
||||
* day.
|
||||
*/
|
||||
fillPercent?: number | null
|
||||
/** Longest single drive between two stops, in minutes. */
|
||||
legMinutes: number | null
|
||||
/** Longest total driving in one day, in minutes. */
|
||||
@@ -212,8 +246,10 @@ function resolveArrival(
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
const entries: ScheduleEntry[] = []
|
||||
const warnings: ScheduleWarning[] = []
|
||||
// Arrivals in minutes, so the chain can be walked twice before anything is formatted.
|
||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null)
|
||||
const anchored: boolean[] = new Array(stops.length).fill(false)
|
||||
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
|
||||
let cursor: number | null = null
|
||||
let dayOffset = 0
|
||||
@@ -224,28 +260,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
|
||||
|
||||
if (arrival === null) {
|
||||
if (arrival === null) continue
|
||||
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
if (offset > dayOffset) dayOffset = offset
|
||||
|
||||
arrivals[i] = arrival
|
||||
anchored[i] = anchor !== null
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
// Then backwards, for the stops the forward walk left blank because nobody had pinned
|
||||
// a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum
|
||||
// opens at ten, so when do we have to leave? Working back from the first known arrival
|
||||
// answers it — departure = the next arrival minus the drive, arrival = that minus the
|
||||
// stay — and it stops at the first leg that never routed rather than inventing one.
|
||||
const firstKnown = arrivals.findIndex(a => a !== null)
|
||||
for (let i = firstKnown - 1; i >= 0; i--) {
|
||||
const leg = legSeconds[i]
|
||||
const next = arrivals[i + 1]
|
||||
if (leg === undefined || next === null) break
|
||||
arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0)
|
||||
}
|
||||
|
||||
// Working back can land before the anchor day's midnight, which would print as a
|
||||
// negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts
|
||||
// up instead until the earliest stop sits on day zero again.
|
||||
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null)
|
||||
const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES
|
||||
|
||||
const entries: ScheduleEntry[] = []
|
||||
let lastOffset = 0
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const raw = arrivals[i]
|
||||
if (raw === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = Math.floor(arrival / (24 * 60))
|
||||
if (offset > dayOffset) {
|
||||
dayOffset = offset
|
||||
warnings.push({ index: i, code: 'overnight' })
|
||||
}
|
||||
|
||||
const dwell = stop.dwellMinutes ?? 0
|
||||
const departure = arrival + dwell
|
||||
const arrival = raw + shift
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
// Read off the finished chain rather than during the forward walk, so a midnight the
|
||||
// backward pass introduced is marked too.
|
||||
if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' })
|
||||
lastOffset = offset
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(departure),
|
||||
anchored: anchor !== null,
|
||||
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
|
||||
anchored: anchored[i],
|
||||
dayOffset: offset,
|
||||
})
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
return { entries, warnings }
|
||||
@@ -308,6 +373,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
* town. A point past the end lands in the last leg rather than nowhere, because that is
|
||||
* a rounding edge and not a missing leg.
|
||||
*/
|
||||
/**
|
||||
* Where a tank runs dry, in the day's own driving coordinates.
|
||||
*
|
||||
* A separate thing from the range WARNING, and the difference is the whole point. The
|
||||
* warning is filed against the stop the car arrives at, which is where somebody finds
|
||||
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
|
||||
* is the only place worth suggesting a filling station.
|
||||
*
|
||||
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
|
||||
* line: that line also carries ferry and walking runs. Converting it to a coordinate
|
||||
* means walking the driving legs, which `dryPointOn` does.
|
||||
*/
|
||||
export interface DryPoint {
|
||||
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
|
||||
legIndex: number
|
||||
/** How far into that leg, in kilometres. */
|
||||
intoLegKm: number
|
||||
/** Metres of driving from the start of the day to that point. */
|
||||
drivenMeters: number
|
||||
/** The range limit that was crossed, which is what the traveller set. */
|
||||
sinceKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings about the driving itself: too long at the wheel, too long in one day, and
|
||||
* the tank running out before anywhere to fill it.
|
||||
@@ -344,13 +432,45 @@ export function deriveDriveWarnings(
|
||||
limits: DriveLimits,
|
||||
/** Kilometres already on the tank when the day starts; null when that is unknown. */
|
||||
carryKm: number | null,
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
|
||||
/**
|
||||
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: a motorway rapid charger is
|
||||
* worth about 80 % because the last fifth costs as long again, while the one at the
|
||||
* hotel is worth all of it because the car stands there all night. One figure for the
|
||||
* whole trip cannot say both, and the difference between them is a leg.
|
||||
*
|
||||
* Last and optional so the four-argument call, which is every caller that has no
|
||||
* per-stop opinion, keeps meaning exactly what it did.
|
||||
*/
|
||||
fillAt: readonly (number | null | undefined)[] = [],
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
const emptyAt: DryPoint[] = []
|
||||
let budget = carryKm
|
||||
let totalSeconds = 0
|
||||
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
|
||||
// line carries every run, and a ferry or a walk in the middle of a day adds to it
|
||||
// without adding to this. Whatever converts a dry point back into a coordinate has to
|
||||
// walk the driving legs only, which is why this is counted here rather than derived.
|
||||
let drivenMeters = 0
|
||||
|
||||
/**
|
||||
* What is already on the clock the moment the car pulls away from stop `i`.
|
||||
*
|
||||
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
|
||||
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
|
||||
* right up, so all three land on nothing used.
|
||||
*/
|
||||
const usedAfterFilling = (i: number): number => {
|
||||
const percent = fillAt[i] ?? limits.fillPercent
|
||||
return limits.rangeKm && percent && percent > 0 && percent < 100
|
||||
? limits.rangeKm * (1 - percent / 100)
|
||||
: 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
if (refuelsAt[i]) budget = 0
|
||||
if (refuelsAt[i]) budget = usedAfterFilling(i)
|
||||
const leg = legs[i]
|
||||
// The stop this leg arrives at. Both findings are about what is true on arrival.
|
||||
const at = i + 1
|
||||
@@ -370,7 +490,25 @@ export function deriveDriveWarnings(
|
||||
if (typeof metres !== 'number') {
|
||||
budget = null
|
||||
} else if (budget !== null) {
|
||||
const before = budget
|
||||
budget += metres / 1000
|
||||
// Where the tank actually runs dry, as opposed to where somebody notices. The
|
||||
// warning below sits on the arriving stop, which can be a hundred kilometres past
|
||||
// the point the fuel ran out; a suggestion has to be offered at the point, or it
|
||||
// suggests filling up somewhere the car cannot reach.
|
||||
//
|
||||
// Once per tank, not once per warning. A long stretch with nothing on it produces
|
||||
// a run of warnings on purpose (see the note above), and one refuel offer per
|
||||
// warning would stack three identical offers down one day for a single fill-up.
|
||||
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
|
||||
const intoLegKm = limits.rangeKm - before
|
||||
emptyAt.push({
|
||||
legIndex: i,
|
||||
intoLegKm,
|
||||
drivenMeters: drivenMeters + intoLegKm * 1000,
|
||||
sinceKm: Math.round(limits.rangeKm),
|
||||
})
|
||||
}
|
||||
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
|
||||
// the plan working, not a problem, and a warning there would sit on the one stop
|
||||
// that answers it. No reset either — a warning is not a fill-up, and the figure has
|
||||
@@ -378,18 +516,19 @@ export function deriveDriveWarnings(
|
||||
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
|
||||
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
|
||||
}
|
||||
drivenMeters += metres
|
||||
}
|
||||
}
|
||||
// The last stop of the day counts too: filling up on arrival is what makes the next
|
||||
// morning start with a full tank.
|
||||
if (refuelsAt[legs.length]) budget = 0
|
||||
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
|
||||
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
const day = limits.dayMinutes && minutes > limits.dayMinutes
|
||||
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
|
||||
: null
|
||||
|
||||
return { warnings, day, carryKm: budget }
|
||||
return { warnings, day, carryKm: budget, emptyAt }
|
||||
}
|
||||
|
||||
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
|
||||
@@ -639,3 +778,26 @@ export function reanchorAfterReorder(
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The road classes a stored setting asks to leave out, validated.
|
||||
*
|
||||
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
|
||||
* the write route stores any key with any value and says so — so an unknown word here
|
||||
* would travel straight into a costing option the router does not have. Unknown entries
|
||||
* are dropped, the order is fixed so two equal settings produce one cache key, and
|
||||
* anything that is not a non-empty list reads as "route normally".
|
||||
*/
|
||||
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
|
||||
return AVOIDABLE.filter(cls => asked.has(cls))
|
||||
}
|
||||
|
||||
/** Every class that can be avoided, in the order they are offered and stored. */
|
||||
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
|
||||
|
||||
/** The setting value for a set of classes, in the fixed order. */
|
||||
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
|
||||
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
@@ -41,7 +41,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { haversineKm, pointAtMeters, type Bbox, type LatLng } from './corridor'
|
||||
|
||||
const { pois, offline, stored } = vi.hoisted(() => ({
|
||||
pois: vi.fn(),
|
||||
offline: vi.fn(),
|
||||
stored: { settings: {} as { roadtrip_vehicle?: string } },
|
||||
}))
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline }))
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
useSettingsStore: (select: (state: typeof stored) => unknown) => select(stored),
|
||||
}))
|
||||
|
||||
import { useRefuelSearch } from './useRefuelSearch'
|
||||
|
||||
/**
|
||||
* A hundred kilometres due north. A straight north-south drive makes "before the dry
|
||||
* point" a plain statement about latitude, so the direction the search looked in can be
|
||||
* read off the box the request carried.
|
||||
*/
|
||||
const LINE: LatLng[] = [{ lat: 53, lng: 10 }, { lat: 53.9, lng: 10 }]
|
||||
const DRY_KM = 60
|
||||
const DRY = pointAtMeters(LINE, DRY_KM * 1000)!
|
||||
|
||||
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
|
||||
osm_id: id,
|
||||
name: id,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
|
||||
/**
|
||||
* A station `alongKm` into the day, `eastKm` to the side of the road.
|
||||
*
|
||||
* The side step is a rough degree conversion rather than the projection the hook uses:
|
||||
* nothing here depends on the exact metre, only on a larger `eastKm` really sitting
|
||||
* further from the line, which is what orders the offers.
|
||||
*/
|
||||
const station = (id: string, alongKm: number, eastKm = 0.5) => {
|
||||
const on = pointAtMeters(LINE, alongKm * 1000)!
|
||||
return hit(id, on.lat, on.lng + eastKm / 66)
|
||||
}
|
||||
|
||||
const answer = (items: ReturnType<typeof hit>[], over: { truncated?: boolean; clamped?: boolean } = {}) =>
|
||||
({ pois: items, source: 'openstreetmap', truncated: false, clamped: false, ...over })
|
||||
|
||||
const boxOf = (call: unknown[]) => call[1] as Bbox
|
||||
const signalOf = (call: unknown[]) => call[3] as AbortSignal
|
||||
|
||||
/** A request left hanging, so a second one can overtake it. */
|
||||
function held() {
|
||||
let settle!: (value: ReturnType<typeof answer>) => void
|
||||
const promise = new Promise<ReturnType<typeof answer>>(resolve => { settle = resolve })
|
||||
return { promise, settle }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pois.mockReset().mockResolvedValue(answer([]))
|
||||
offline.mockReset().mockReturnValue(false)
|
||||
stored.settings = {}
|
||||
})
|
||||
|
||||
describe('useRefuelSearch', () => {
|
||||
it('FE-REFUELSEARCH-001: offline it says so rather than offering a stop that cannot be kept', async () => {
|
||||
// Accepting an offer writes a place and assigns it to a day; only the first of those
|
||||
// queues offline, so anything accepted here would end up an orphan. Saying "could not
|
||||
// look" is the honest answer, and it must not cost a request either.
|
||||
offline.mockReturnValue(true)
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
expect(result.current.outcome).toBe('failed')
|
||||
expect(result.current.loading).toBe(false)
|
||||
// The band still opens on the point that was asked about, or there is nowhere to
|
||||
// print the answer.
|
||||
expect(result.current.openFor).toBe('4:1')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-002: somebody who drives a petrol car is not sent to a charger', async () => {
|
||||
stored.settings = { roadtrip_vehicle: 'combustion' }
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(pois.mock.calls[0][0]).toBe('fuel')
|
||||
// The names come back in the traveller's language, so the rail is not half German.
|
||||
expect(pois.mock.calls[0][2]).toBe('en-US')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-003: an electric car is not sent to a petrol pump', async () => {
|
||||
stored.settings = { roadtrip_vehicle: 'electric' }
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(pois.mock.calls[0][0]).toBe('charging')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-004: with nothing said about the car both kinds are looked for', async () => {
|
||||
const { result, rerender } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
expect(pois.mock.calls[0][0]).toBe('fuel,charging')
|
||||
|
||||
// And a value this hook has never heard of falls back to both rather than narrowing
|
||||
// the search to nothing: an empty category list would read on screen as "there is no
|
||||
// filling station on this road", which is a statement about the road.
|
||||
stored.settings = { roadtrip_vehicle: 'hovercraft' }
|
||||
rerender()
|
||||
await act(async () => { await result.current.ask('4:2', DRY, LINE, DRY_KM, []) })
|
||||
expect(pois.mock.calls[1][0]).toBe('fuel,charging')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-005: it looks at the road already driven, not around the dry point', async () => {
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
const box = boxOf(pois.mock.calls[0])
|
||||
const centre = { lat: (box.south + box.north) / 2, lng: (box.west + box.east) / 2 }
|
||||
// South is backwards along this drive. Centring on the dry point itself would spend
|
||||
// half the circle on road that lies beyond an empty tank.
|
||||
expect(centre.lat).toBeLessThan(DRY.lat)
|
||||
expect(haversineKm(centre, DRY)).toBeGreaterThan(11)
|
||||
expect(haversineKm(centre, DRY)).toBeLessThan(13)
|
||||
// Pulled back, but not so far that the last kilometres before the tank empties fall
|
||||
// outside the box: a pump two kilometres short of the dry point is the best answer
|
||||
// there is.
|
||||
expect(box.north).toBeGreaterThan(DRY.lat)
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-006: a second question abandons the answer to the first', async () => {
|
||||
const first = held()
|
||||
pois.mockImplementationOnce(() => first.promise)
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
let pending!: Promise<void>
|
||||
act(() => { pending = result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
pois.mockResolvedValueOnce(answer([station('second', 30)]))
|
||||
await act(async () => { await result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
// The abandoned request answers late, the way a slow one does. Landing it now would
|
||||
// put day four's stations under day five's heading, with day five's accept button
|
||||
// under them.
|
||||
await act(async () => { first.settle(answer([station('first', 45)])); await pending })
|
||||
|
||||
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
|
||||
expect(result.current.results.map(p => p.osm_id)).toEqual(['second'])
|
||||
expect(result.current.openFor).toBe('5:0')
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-007: asking again empties the rail before the new answer arrives', async () => {
|
||||
pois.mockResolvedValueOnce(answer([station('old', 40)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
expect(result.current.results).toHaveLength(1)
|
||||
|
||||
pois.mockImplementationOnce(() => held().promise)
|
||||
act(() => { void result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
// Left standing, the first search's stations sit under the new heading for as long as
|
||||
// the second one takes, and accepting one would file it against the wrong dry point.
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(result.current.offered).toEqual([])
|
||||
expect(result.current.outcome).toBeNull()
|
||||
expect(result.current.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-008: a request that fails is a failure, not an empty road', async () => {
|
||||
pois.mockRejectedValue(new Error('502'))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(result.current.outcome).toBe('failed')
|
||||
// Left spinning, the band offers no way to ask again.
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-009: stations the car cannot reach are not a find', async () => {
|
||||
// Both come back from the server and both are real filling stations; both sit past
|
||||
// the point the tank empties, so neither answers the question that was asked.
|
||||
pois.mockResolvedValue(answer([station('just-past', 58), station('well-past', 70)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(result.current.outcome).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-010: a search the server cut short is not reported as an empty road', async () => {
|
||||
pois.mockResolvedValue(answer([], { truncated: true }))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
// "There is nothing here" and "we did not finish looking" send a traveller to two
|
||||
// different places, and only the server knows which of them happened.
|
||||
expect(result.current.outcome).toBe('incomplete')
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-011: a pump already on the plan is not offered beside itself', async () => {
|
||||
const planned = station('planned', 40)
|
||||
pois.mockResolvedValue(answer([planned, station('new', 45, 1.5)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ask('4:1', DRY, LINE, DRY_KM, [{ lat: planned.lat, lng: planned.lng }])
|
||||
})
|
||||
|
||||
expect(result.current.results.map(p => p.osm_id)).toEqual(['new'])
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-012: three go on the map, and they are the three the rail lists', async () => {
|
||||
// Ordered by the detour, so the map has to draw the first three of that same list
|
||||
// rather than any three: a pin the rail does not name is a stop nobody can accept.
|
||||
pois.mockResolvedValue(answer([
|
||||
station('far', 30, 2.5),
|
||||
station('nearest', 44, 0.2),
|
||||
station('third', 36, 1.5),
|
||||
station('fourth', 32, 2),
|
||||
station('second', 40, 0.8),
|
||||
]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
expect(result.current.results).toHaveLength(5)
|
||||
expect(result.current.offered.map(p => p.osm_id)).toEqual(['nearest', 'second', 'third'])
|
||||
expect(result.current.offered).toEqual(result.current.results.slice(0, 3))
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-013: with nothing open the offer list is the same array every time', async () => {
|
||||
// The map rebuilds every pin when this reference changes, and the planner re-renders
|
||||
// on anything from a drag to an incoming websocket event. A fresh [] per render is a
|
||||
// map that flickers all day for a search nobody started.
|
||||
const { result, rerender } = renderHook(() => useRefuelSearch())
|
||||
const idle = result.current.offered
|
||||
|
||||
rerender()
|
||||
expect(result.current.offered).toBe(idle)
|
||||
|
||||
pois.mockResolvedValue(answer([station('a', 40)]))
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
const open = result.current.offered
|
||||
rerender()
|
||||
expect(result.current.offered).toBe(open)
|
||||
|
||||
act(() => { result.current.close() })
|
||||
expect(result.current.offered).toBe(idle)
|
||||
})
|
||||
|
||||
it('FE-REFUELSEARCH-014: closing the band drops the answer and the request behind it', async () => {
|
||||
pois.mockImplementationOnce(() => held().promise)
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
act(() => { void result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
|
||||
act(() => { result.current.close() })
|
||||
|
||||
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
|
||||
expect(result.current.openFor).toBeNull()
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(result.current.outcome).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
|
||||
import { boxAround, pointAtMeters, type LatLng } from './corridor'
|
||||
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
|
||||
|
||||
/**
|
||||
* Somewhere to fill up before the tank runs out, asked for once, when asked for.
|
||||
*
|
||||
* Deliberately NOT automatic, for the same reason the corridor search next door is not:
|
||||
* every run is a real request against a shared service. It is also not the corridor
|
||||
* search itself, although it looks like a smaller version of it. Three reasons, all
|
||||
* about behaviour rather than tidiness:
|
||||
*
|
||||
* - that search is bound to the day the panel has selected, so it cannot answer a
|
||||
* warning on day five while the panel shows day one;
|
||||
* - starting it clears the panel's results and the traveller's own name filter;
|
||||
* - it asks up to sixteen boxes along a whole day. This question needs one small circle.
|
||||
*
|
||||
* One request, one day, no shared state touched.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How far around the search point to look, in kilometres.
|
||||
*
|
||||
* Fourteen and not more, because the server turns a box into a circle of half its
|
||||
* diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which
|
||||
* is the largest question it answers in full — ±25 asked for 35 and came back flagged
|
||||
* as narrowed every single time, which then read on screen as "we could not check".
|
||||
*/
|
||||
const LOOK_KM = 14
|
||||
|
||||
/**
|
||||
* How far BEFORE the dry point to centre that circle, in kilometres.
|
||||
*
|
||||
* Only the road already driven is any use: a station past the point is on the far side
|
||||
* of an empty tank. Centring on the dry point spends half the circle on road nobody can
|
||||
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
|
||||
*/
|
||||
const LOOK_BACK_KM = 12
|
||||
|
||||
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
|
||||
const NONE: RefuelCandidate[] = []
|
||||
|
||||
export interface RefuelSearch {
|
||||
/** Which dry point is being answered, as `<dayId>:<legIndex>`, or null when idle. */
|
||||
openFor: string | null
|
||||
loading: boolean
|
||||
outcome: RefuelOutcome | null
|
||||
results: RefuelCandidate[]
|
||||
/**
|
||||
* The ones on offer, for the map to draw.
|
||||
*
|
||||
* The same three the rail lists and no more: somebody is being asked to accept a stop,
|
||||
* and a stop that cannot be seen is not one that can be judged. Empty while nothing is
|
||||
* open, and the same array each time, because the map rebuilds every pin when this
|
||||
* reference changes.
|
||||
*/
|
||||
offered: RefuelCandidate[]
|
||||
/** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise<void>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function useRefuelSearch(): RefuelSearch {
|
||||
const { locale } = useTranslation()
|
||||
// What to look for. Somebody who said they drive an electric car has no use for a
|
||||
// petrol station in the list, and the other way round; with nothing said, both.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const [openFor, setOpenFor] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
|
||||
const [results, setResults] = useState<RefuelCandidate[]>([])
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const close = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setOpenFor(null)
|
||||
setResults([])
|
||||
setOutcome(null)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const ask = useCallback(async (
|
||||
key: string,
|
||||
at: LatLng,
|
||||
line: LatLng[],
|
||||
dryAlongKm: number,
|
||||
existing: LatLng[],
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setOpenFor(key)
|
||||
setResults([])
|
||||
setOutcome(null)
|
||||
|
||||
// Offline the answer would be a list of places that cannot be saved: the place write
|
||||
// queues offline but the day assignment does not, so accepting one would leave an
|
||||
// orphan. Say so instead of offering it.
|
||||
if (isEffectivelyOffline()) {
|
||||
setOutcome('failed')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const kind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
const wanted = refuelStopTypeFor(kind).join(',')
|
||||
// Pulled back along the road, because only what lies before the dry point can be
|
||||
// reached. `at` stays the dry point itself: it is what the band names and what the
|
||||
// map is asked to show.
|
||||
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
|
||||
setResults(candidates)
|
||||
setOutcome(outcomeOf(candidates, answer))
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setOutcome('failed')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [locale, vehicle])
|
||||
|
||||
const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results])
|
||||
|
||||
return { openFor, loading, outcome, results, offered, ask, close }
|
||||
}
|
||||
@@ -21,8 +21,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 1, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
|
||||
@@ -32,7 +32,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
/** The search state the mocked hook hands back, results included. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
|
||||
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
// class declared down there would not exist yet when the hook does its `instanceof`.
|
||||
@@ -17,6 +17,8 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
|
||||
|
||||
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
||||
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
|
||||
import { lineMetres } from './corridor'
|
||||
|
||||
const HAMBURG: [number, number] = [53.5511, 9.9937]
|
||||
const LUENEBURG: [number, number] = [53.2464, 10.4115]
|
||||
@@ -352,6 +354,74 @@ describe('useRoadtripRoutes', () => {
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
/**
|
||||
* Days read by the date each stop is REACHED, and the road between them.
|
||||
*
|
||||
* The arrangement itself is tested in `nightSpill.test.ts`; what is asserted here is
|
||||
* what the hook does with it — the roads it has to ask for that a day-at-a-time plan
|
||||
* never needed, and what the switch over them turns on.
|
||||
*/
|
||||
describe('driving that crosses a day boundary', () => {
|
||||
/** Two days, the first of which sets off late enough to arrive after midnight. */
|
||||
const overnight = () => ({
|
||||
days: [day(1, 1), day(2, 2)],
|
||||
assignments: {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap,
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-017: routes the road onto the day a night drive lands on', async () => {
|
||||
// The stop reached after midnight is drawn under day 2, next to day 2's own first
|
||||
// stop. Those two were never neighbours before, so their road was never asked for —
|
||||
// and a chain with a hole in it draws as two runs with a gap across the middle.
|
||||
const { days, assignments } = overnight()
|
||||
// Three hours per leg, which is what puts the second stop past midnight.
|
||||
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }] })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
await waitFor(() => expect(calculateRouteWithLegs.mock.calls.length).toBeGreaterThan(2))
|
||||
|
||||
// A two-point request, which is the seam rather than either day's own run.
|
||||
const seam = calculateRouteWithLegs.mock.calls.find(c => c[0].length === 2
|
||||
&& c[0][0].lat === LUENEBURG[0] && c[0][1].lat === BERLIN[0])
|
||||
expect(seam).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-018: leaves the gap between two ordinary days alone', async () => {
|
||||
// Nothing moved here, so the road from one day's last stop to the next day's first
|
||||
// is a gap the plan simply has. Asking for it is what the switch is for.
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// One request per day and no more: the seam is not asked for.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-019: hands the map a line per leg, coloured by the day it is driven on', async () => {
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Same length and same order as `lines`, which is the contract the map paints from.
|
||||
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
|
||||
expect(result.current.lineDays).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
@@ -368,4 +438,231 @@ describe('useRoadtripRoutes', () => {
|
||||
unmount()
|
||||
expect(seen!.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-020: gives each leg its share of the drawn line, not its share of the routed metres', async () => {
|
||||
// The router reports 50 km a leg off its own graph while the polyline it sends back
|
||||
// measures 260 km as great-circle hops between the vertices. Cutting at the raw
|
||||
// metres left every run short by the difference: the second leg ended a third of the
|
||||
// way into the drive and the day never reached Berlin at all.
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, LUENEBURG, BERLIN]))
|
||||
const metres = (line: [number, number][]): number => lineMetres(line.map(([lat, lng]) => ({ lat, lng })))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const [first, second] = result.current.lines
|
||||
expect(first[0]).toEqual(HAMBURG)
|
||||
// Both ends are interpolated inside the segment they fall in, so the two pieces meet
|
||||
// on one coordinate and put back together give the run back.
|
||||
expect(first[first.length - 1]).toEqual(second[0])
|
||||
// And the last cut lands on the last vertex rather than somewhere short of it.
|
||||
expect(second[second.length - 1][0]).toBeCloseTo(BERLIN[0], 6)
|
||||
expect(second[second.length - 1][1]).toBeCloseTo(BERLIN[1], 6)
|
||||
// Two legs the router called equal take equal shares of the road that was drawn, and
|
||||
// between them the whole 260 km of it rather than the 100 km the router reported.
|
||||
const drawn = metres(first) + metres(second)
|
||||
expect(metres(first) / drawn).toBeCloseTo(0.5, 2)
|
||||
expect(drawn).toBeCloseTo(metres([HAMBURG, LUENEBURG, BERLIN]), -2)
|
||||
})
|
||||
|
||||
/**
|
||||
* The switch that turns a trip stored as days into one continuous drive.
|
||||
*
|
||||
* Off, a card holds only the driving between its own stops: the road across the join is
|
||||
* not asked for, not drawn and not counted. On, every one of those gaps becomes a leg
|
||||
* like any other — and it is the one stretch of a trip that no day run ever covers, so
|
||||
* a hole left in the chain here is left there by nothing else.
|
||||
*/
|
||||
describe('connecting the days', () => {
|
||||
beforeEach(() => {
|
||||
// Wholesale, so nothing a previous case set can answer for this one.
|
||||
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
|
||||
})
|
||||
|
||||
/** The hook reads the switch off the settings store, the way the limits card writes it. */
|
||||
const setting = (over: Partial<Settings>): void => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) })
|
||||
}
|
||||
|
||||
/** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */
|
||||
const twoDays = () => ({
|
||||
days: [day(1, 1), day(2, 2)],
|
||||
assignments: {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap,
|
||||
})
|
||||
|
||||
/** Every request for one particular road, found by the two ends it was asked for. */
|
||||
const askedFor = (from: [number, number], to: [number, number]) =>
|
||||
calculateRouteWithLegs.mock.calls.filter(c =>
|
||||
c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0])
|
||||
|
||||
/** A run whose legs are a round 100 km each, so the range budget reads off the page. */
|
||||
const hundreds = (legs: number, coordinates: [number, number][]) => ({
|
||||
coordinates,
|
||||
distance: legs * 100000,
|
||||
duration: legs * 3600,
|
||||
legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })),
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => {
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// A gap the day-at-a-time plan simply has, and the totals say so by leaving it out.
|
||||
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0)
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.totalDistance).toBe(200000)
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
||||
expect(result.current.totalDistance).toBe(300000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
||||
|
||||
// Same length and same order as `lines`, or a colour lands on the wrong day.
|
||||
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
|
||||
// The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2
|
||||
// look like it begins somewhere it has no stop.
|
||||
expect(result.current.lineDays).toEqual([1, 1, 2])
|
||||
// The kilometres go the other way: they are driven on the day they arrive.
|
||||
expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => {
|
||||
// Stored inside one day and real driving, but on screen it runs from a stop on one
|
||||
// card to a stop on the next, and a line between two cards is what the switch asks
|
||||
// about. Its kilometres belong to the day it lands on either way.
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
||||
} as AssignmentsMap
|
||||
// Three hours a leg, which is what puts the second stop past midnight.
|
||||
calculateRouteWithLegs.mockResolvedValue({
|
||||
...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.lines).toHaveLength(2))
|
||||
expect(result.current.days[0].distance).toBe(200000)
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
expect(result.current.lineDays).toEqual([1, 2, 2])
|
||||
expect(result.current.days[0].distance).toBe(300000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
// Filed after the last stop of day 1, which is the join itself. Handed over in the
|
||||
// wrong order on purpose: driven through its points backwards the seam doubles back.
|
||||
const vias = {
|
||||
1: [
|
||||
{ id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 },
|
||||
{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 },
|
||||
],
|
||||
}
|
||||
// A shaped seam comes back as one leg per waypoint pair.
|
||||
const inPieces = {
|
||||
...routed(1),
|
||||
legs: [
|
||||
{ distance: 30000, duration: 1000, text: '30 km' },
|
||||
{ distance: 30000, duration: 1000, text: '30 km' },
|
||||
{ distance: 40000, duration: 1600, text: '40 km' },
|
||||
],
|
||||
}
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
||||
Promise.resolve(wp.length === 4 ? inPieces : routed(1)))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias))
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
|
||||
const [waypoints] = askedFor(LUENEBURG, BERLIN)[0]
|
||||
expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]])
|
||||
// Three pieces, one drive: the rail carries the whole road across the join rather
|
||||
// than the first stretch of it.
|
||||
await waitFor(() => expect(result.current.days[1].distance).toBe(200000))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => {
|
||||
// The first answer is filed under the pair of stops. Skipping the seam whenever any
|
||||
// answer existed is what made a via on the join do visibly nothing at all.
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] }
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v),
|
||||
{ initialProps: { v: vias } },
|
||||
)
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
|
||||
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } })
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
|
||||
const [waypoints] = askedFor(LUENEBURG, BERLIN)[1]
|
||||
expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => {
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const { days: daysList, assignments } = twoDays()
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
||||
wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1)))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// Let the refusal settle: what it must not do is file a leg anyway.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.lineDays).toEqual([1, 2])
|
||||
// Partial rather than invented, exactly like any other leg that will not route.
|
||||
expect(result.current.totalDistance).toBe(200000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => {
|
||||
// A tank does not empty overnight and the join is real driving, so the day it
|
||||
// reaches starts with those kilometres already on the clock — which moves the point
|
||||
// the fuel runs out a whole leg earlier.
|
||||
const SOUTH: [number, number] = [51.5, 13.5]
|
||||
const FURTHER_SOUTH: [number, number] = [51.05, 13.74]
|
||||
setting({ roadtrip_range_km: 250 })
|
||||
const daysList = [day(1, 1), day(2, 2)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: SOUTH }, { id: 5, at: FURTHER_SOUTH }]),
|
||||
} as AssignmentsMap
|
||||
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve(
|
||||
wp[0].lat === BERLIN[0]
|
||||
? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH])
|
||||
: hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG])))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
await waitFor(() => expect(result.current.days).toHaveLength(2))
|
||||
await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1))
|
||||
// 100 km carried over from day 1, so the 250th is reached halfway down the second leg.
|
||||
expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 })
|
||||
|
||||
setting({ roadtrip_connect_days: true })
|
||||
await waitFor(() => expect(result.current.days[1].distance).toBe(300000))
|
||||
const [dry] = result.current.days[1].dryPoints!
|
||||
expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 })
|
||||
// Walked along the roads this day drives, not along the drawn line: 50 km down that
|
||||
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
|
||||
expect(dry.lat).toBeLessThan(BERLIN[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,30 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning,
|
||||
} from './roadtripModel'
|
||||
import { projectOntoRoute } from './corridor'
|
||||
deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint } from './roadtripModel'
|
||||
import { projectOntoRoute, pointAtMeters, sliceAtMeters, lineMetres } from './corridor'
|
||||
import { spillChains, type SpillMark } from './nightSpill'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
|
||||
export interface RoadtripStop {
|
||||
assignmentId: number
|
||||
/**
|
||||
* The day this stop is STORED on, and its position there.
|
||||
*
|
||||
* A road trip draws its stops by the day they are reached, which after a drive across
|
||||
* midnight is not the day they are stored on — see `nightSpill.ts`. Nothing is written
|
||||
* to make that so, which means every callback that reorders, moves, re-anchors or adds
|
||||
* beside a stop has to name the day the server knows it by. That is this pair, and it
|
||||
* travels with the stop so no caller has to work it out.
|
||||
*/
|
||||
ownerDayId: number
|
||||
ownerIndex: number
|
||||
placeId: number
|
||||
name: string
|
||||
lat: number
|
||||
@@ -27,6 +39,17 @@ export interface RoadtripStop {
|
||||
incomingLegMode: string | null
|
||||
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
|
||||
stopType: string | null
|
||||
/**
|
||||
* How full THIS stop fills up, 1-100, or null to follow the traveller's own setting.
|
||||
*
|
||||
* A property of the stop: the motorway rapid charger is worth 80 % because the last
|
||||
* fifth costs as long again, the one at the hotel is worth all of it because the car
|
||||
* stands there all night.
|
||||
*
|
||||
* Optional, like `offRoadMeters`: absent and null say the same thing, and a stop built
|
||||
* anywhere that has no opinion about filling up should not have to say so.
|
||||
*/
|
||||
fillPercent?: number | null
|
||||
/**
|
||||
* How far the router had to go to find a road, in metres, once this stop has routed.
|
||||
*
|
||||
@@ -53,6 +76,27 @@ export interface RoadtripDay {
|
||||
* stop. Kept out of `RouteSegment` because that type is read by half the map.
|
||||
*/
|
||||
legVias: RouteVia[][]
|
||||
/**
|
||||
* Classes this day was asked to leave out and could not.
|
||||
*
|
||||
* Empty when the drive got what it asked for, and empty when nothing was asked. The
|
||||
* switch says "where possible", and this is what makes that honest rather than a
|
||||
* hedge: a drive with no untolled crossing comes back on the toll road, and the day
|
||||
* has to be able to say so instead of wearing a label the road disproves.
|
||||
*/
|
||||
avoidMissed?: RouteAvoidClass[]
|
||||
/**
|
||||
* Where the tank runs dry on this day, once per fill-up, with a coordinate.
|
||||
*
|
||||
* Empty when no range limit is set, when the day never crosses it, or when a leg did
|
||||
* not route — the budget gives up rather than guessing, and so does this.
|
||||
*/
|
||||
dryPoints?: (DryPoint & { lat: number; lng: number })[]
|
||||
/**
|
||||
* `geometry` with the non-driving runs left out, which is what `dryPoints` are measured
|
||||
* along. The same array as `geometry` on a day that drives the whole way.
|
||||
*/
|
||||
drivingGeometry?: [number, number][]
|
||||
/**
|
||||
* The roads actually driven that day, as [lat, lng] — not the straight lines between
|
||||
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
|
||||
@@ -71,8 +115,23 @@ export interface RoadtripDay {
|
||||
driveWarnings: ScheduleWarning[]
|
||||
/** More driving in this day than the traveller allows, or null. */
|
||||
dayWarning: DayWarning | null
|
||||
/**
|
||||
* Stretches of this day's chain that were driven onto it from an earlier day.
|
||||
*
|
||||
* Empty on an ordinary day, which is most of them. See `nightSpill.ts`: a drive that
|
||||
* crosses midnight is read on the date it ARRIVES, and the stops it reaches are drawn
|
||||
* here even though they are stored on the day they set off from. Each stop still names
|
||||
* that day in `ownerDayId`, so nothing that writes has to know about this.
|
||||
*/
|
||||
spills?: SpillMark[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A day with its stops and nothing routed onto it yet — what the routing effect is handed
|
||||
* and what the arrangement by date is worked out from.
|
||||
*/
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
/** A place, the road it is driven to from, and how far apart the two are. */
|
||||
export interface AccessSpur {
|
||||
line: [[number, number], [number, number]]
|
||||
@@ -104,6 +163,14 @@ export interface RoadtripRoutes {
|
||||
quietDays: QuietDay[]
|
||||
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
|
||||
lines: [number, number][][]
|
||||
/**
|
||||
* The day each entry of `lines` is driven on, same length and same order.
|
||||
*
|
||||
* So the map can colour the trip by day without knowing anything about how the days
|
||||
* were worked out. A night drive belongs to the day it ARRIVES on, the same rule its
|
||||
* kilometres follow.
|
||||
*/
|
||||
lineDays: number[]
|
||||
/**
|
||||
* The gap between a place and the road it was routed from, as a two-point line.
|
||||
*
|
||||
@@ -190,14 +257,30 @@ export interface SnappedPoint {
|
||||
const stopKey = (s: RoadtripStop): string =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
|
||||
|
||||
/**
|
||||
* The via points that shape the drive leaving a stop, as one comparable string.
|
||||
*
|
||||
* Read in two places that must agree: when deciding whether a seam still matches the
|
||||
* answer already in hand, and when recording what an answer was fetched for. One function
|
||||
* so the two cannot drift and quietly stop refetching.
|
||||
*/
|
||||
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
|
||||
(viasByDay[from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === from.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
||||
.join('|')
|
||||
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
return {
|
||||
assignmentId: a.id,
|
||||
ownerDayId,
|
||||
ownerIndex,
|
||||
placeId: a.place_id,
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
@@ -207,6 +290,7 @@ const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
||||
stopType: p.stop_type ?? null,
|
||||
fillPercent: typeof p.fill_percent === 'number' ? p.fill_percent : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,42 +331,81 @@ export function useRoadtripRoutes(
|
||||
* filing it twice would draw the spur twice.
|
||||
*/
|
||||
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
|
||||
/** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */
|
||||
const [missedByDay, setMissedByDay] = useState<Record<number, RouteAvoidClass[]>>({})
|
||||
/**
|
||||
* Drives that only exist because a night moved a stop onto the next day's card.
|
||||
*
|
||||
* The trip is routed a stored day at a time, so the road between the last stop of one
|
||||
* day and the first of the next was never asked for — there was no chain that needed
|
||||
* it. Once a drive across midnight hands its stops forward, the stop it ends at and the
|
||||
* next day's own first stop become NEIGHBOURS in one chain, and a chain with a hole in
|
||||
* it draws two disconnected runs on the map. Filed by leg key like every other leg, so
|
||||
* everything downstream reads them without knowing where they came from.
|
||||
*/
|
||||
const [seamLegs, setSeamLegs] = useState<Record<string, RoutedLeg & { shape: string }>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
/**
|
||||
* Road classes to weight away, as the settings row stores them: a comma list.
|
||||
*
|
||||
* Parsed against the known classes rather than trusted, because there is no
|
||||
* server-side validation for a per-user setting — the write route stores any key with
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
/**
|
||||
* Whether the gaps between days are driven too.
|
||||
*
|
||||
* A trip is stored as days and routed as days, so the road from one day's last stop to
|
||||
* the next day's first was never asked for: no chain needed it. Turning this on makes
|
||||
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
|
||||
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
|
||||
*/
|
||||
const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
// What the car is and how far it goes on one fill, assembled in one place because the
|
||||
// rail needs the same answer to say what a given fill buys at a given stop.
|
||||
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
const limits = useMemo<DriveLimits>(
|
||||
() => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
|
||||
[legMinutes, dayMinutes, rangeKm],
|
||||
() => ({
|
||||
legMinutes: legMinutes || null,
|
||||
dayMinutes: dayMinutes || null,
|
||||
rangeKm: planningRangeKm,
|
||||
// Only a real fraction counts. Zero, absent and 100 all mean "fills right up",
|
||||
// which is what the budget did before the setting existed.
|
||||
fillPercent: fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : null,
|
||||
}),
|
||||
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
|
||||
)
|
||||
|
||||
const plan = useMemo<RoadtripDay[]>(() => {
|
||||
const plan = useMemo<PlanDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => {
|
||||
const stops = (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(asStop)
|
||||
.map(a => asStop(a, d.id, 0))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
// counting before the filter would name a row the rail does not draw.
|
||||
.map((s, i) => ({ ...s, ownerIndex: i }))
|
||||
return {
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops,
|
||||
legs: [],
|
||||
legVias: [],
|
||||
driveWarnings: [],
|
||||
dayWarning: null,
|
||||
schedule: { entries: [], warnings: [] },
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
}
|
||||
})
|
||||
.filter(d => d.stops.length > 1)
|
||||
@@ -299,8 +422,9 @@ export function useRoadtripRoutes(
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(asStop)
|
||||
.filter((s): s is RoadtripStop => s !== null),
|
||||
.map(a => asStop(a, d.id, 0))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
.map((s, i) => ({ ...s, ownerIndex: i })),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments])
|
||||
@@ -327,6 +451,7 @@ export function useRoadtripRoutes(
|
||||
if (!plan.length) {
|
||||
setLegsByDay({})
|
||||
setSnapByDay({})
|
||||
setMissedByDay({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -339,6 +464,7 @@ export function useRoadtripRoutes(
|
||||
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
||||
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const day of plan) {
|
||||
@@ -353,7 +479,6 @@ export function useRoadtripRoutes(
|
||||
dfMode,
|
||||
))
|
||||
|
||||
const dayVias = viasByDay[day.dayId] ?? []
|
||||
|
||||
for (const { stops: run, mode } of runs) {
|
||||
tasks.push(async () => {
|
||||
@@ -367,9 +492,14 @@ export function useRoadtripRoutes(
|
||||
stopAt.push(waypoints.length)
|
||||
waypoints.push({ lat: stop.lat, lng: stop.lng })
|
||||
if (i === run.length - 1) return
|
||||
const dayIndex = day.stops.indexOf(stop)
|
||||
dayVias
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
// A via is filed against the day it was dropped on and the position it sits
|
||||
// after IN THAT DAY. Since a chain can hold stops from an earlier day (see
|
||||
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
|
||||
// index — this used to be the position within the chain, so on any day that
|
||||
// received a night drive every via matched nothing and quietly stopped
|
||||
// shaping the road.
|
||||
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
||||
})
|
||||
@@ -379,7 +509,7 @@ export function useRoadtripRoutes(
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
||||
)
|
||||
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
||||
// vias threaded in between are skipped: a via is a shape handle, not a
|
||||
@@ -406,6 +536,18 @@ export function useRoadtripRoutes(
|
||||
// together would get wrong.
|
||||
const legEndMeters: number[] = []
|
||||
const legKeys: string[] = []
|
||||
/**
|
||||
* Where each leg starts and ends along the run, so its own stretch of road
|
||||
* can be cut out of the run's one polyline.
|
||||
*
|
||||
* Collected rather than cut on the spot because the cut is proportional:
|
||||
* the router reports a leg's length off its own graph while the polyline is
|
||||
* great-circle hops between the vertices it sent back, and the two never
|
||||
* agree exactly. Cutting at absolute metres left the last leg of every run
|
||||
* short by the difference. Cutting at the same FRACTION of each makes the
|
||||
* last cut land on the last vertex, so the pieces still rebuild the run.
|
||||
*/
|
||||
const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = []
|
||||
let travelled = 0
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
@@ -418,11 +560,26 @@ export function useRoadtripRoutes(
|
||||
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
||||
}
|
||||
const startedAt = travelled
|
||||
travelled += merged.distance ?? 0
|
||||
legEndMeters.push(travelled)
|
||||
const key = legKey(from, to)
|
||||
legKeys.push(key)
|
||||
dayLegs[key] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [], vias: [] }
|
||||
cuts.push({ key, from: startedAt, to: travelled, seg: { ...merged, mode } })
|
||||
}
|
||||
// The road under each leg, in the polyline's own measure. It used to be
|
||||
// "the whole run hangs off leg 0", which was enough while a day was always
|
||||
// drawn whole — but a drive across midnight hands its stops to the next
|
||||
// card and the road has to travel with them.
|
||||
const drawnMetres = lineMetres(spine)
|
||||
const scale = travelled > 0 ? drawnMetres / travelled : 0
|
||||
for (const cut of cuts) {
|
||||
dayLegs[cut.key] = {
|
||||
seg: cut.seg,
|
||||
line: sliceAtMeters(spine, cut.from * scale, cut.to * scale)
|
||||
.map(pt => [pt.lat, pt.lng] as [number, number]),
|
||||
vias: [],
|
||||
}
|
||||
}
|
||||
for (const { via, alongMeters } of viaAlong) {
|
||||
const idx = legIndexForAlong(legEndMeters, alongMeters)
|
||||
@@ -456,6 +613,7 @@ export function useRoadtripRoutes(
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
setMissedByDay({ ...collectedMisses })
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
@@ -466,73 +624,298 @@ export function useRoadtripRoutes(
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey stands in for `plan`: same geometry, same legs.
|
||||
// planKey stands in for `plan`: same geometry, same legs. avoidKey, not `avoid`:
|
||||
// a fresh array every render would re-route on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, routeProfile, distanceUnit, tripId])
|
||||
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
|
||||
|
||||
/**
|
||||
* Every leg known so far, by the two stops it connects.
|
||||
*
|
||||
* Filed under a key built from the stops' own coordinates, so which day it was fetched
|
||||
* under stops mattering the moment a drive across midnight moves its stops to the next
|
||||
* card — the road between two places is the road between them whichever date it is
|
||||
* driven on.
|
||||
*/
|
||||
const allLegs = useMemo(() => {
|
||||
const out: Record<string, RoutedLeg> = {}
|
||||
for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {})
|
||||
return { ...out, ...seamLegs }
|
||||
}, [plan, legsByDay, seamLegs])
|
||||
|
||||
// Which date each stop is actually reached on. Nothing is written to make it so; see
|
||||
// `nightSpill.ts`.
|
||||
const chains = useMemo(
|
||||
() => spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]),
|
||||
[plan, quietDays, allLegs],
|
||||
)
|
||||
|
||||
/**
|
||||
* Pairs a chain needs a road for and does not have.
|
||||
*
|
||||
* Only ever a seam between two stored days: everything inside one day was routed as a
|
||||
* run. Left unrouted the rail shows a chain that stops halfway and the map draws the
|
||||
* day in two pieces with a gap across the middle.
|
||||
*/
|
||||
const seams = useMemo(() => {
|
||||
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
|
||||
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
|
||||
// Routed as part of a day's own run — that request is rebuilt whenever its vias
|
||||
// change, so there is nothing to catch up here.
|
||||
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
|
||||
// A seam already fetched is skipped only while it was fetched for the SHAPE it has
|
||||
// now. Skipping it whenever any answer existed is what made a via on a seam do
|
||||
// nothing at all: the first answer was cached under the pair, and dragging the
|
||||
// point changed the request nobody was going to send again.
|
||||
const have = seamLegs[legKey(from, to)]
|
||||
if (have && have.shape === seamShape(from, viasByDay)) return
|
||||
out.push({ from, to, dayId })
|
||||
}
|
||||
for (const chain of chains) {
|
||||
for (let i = 0; i < chain.stops.length - 1; i++) {
|
||||
const from = chain.stops[i]
|
||||
const to = chain.stops[i + 1]
|
||||
// Inside one stored day it was routed as part of that day's run.
|
||||
if (from.ownerDayId === to.ownerDayId) continue
|
||||
want(from, to, chain.dayId)
|
||||
}
|
||||
}
|
||||
// And, when the traveller asks for one continuous drive, the road from each card's
|
||||
// last stop to the next card's first — the one gap a day-at-a-time routing leaves.
|
||||
if (connectDays) {
|
||||
for (let d = 0; d < chains.length - 1; d++) {
|
||||
const from = chains[d].stops[chains[d].stops.length - 1]
|
||||
const to = chains[d + 1].stops[0]
|
||||
if (from && to) want(from, to, chains[d + 1].dayId)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chains, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
||||
|
||||
useEffect(() => {
|
||||
if (!seams.length) return
|
||||
const controller = new AbortController()
|
||||
void (async () => {
|
||||
for (const seam of seams) {
|
||||
if (controller.signal.aborted) return
|
||||
const mode = resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: seam.from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
|
||||
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
|
||||
)
|
||||
try {
|
||||
// The via points on this seam, threaded in the same way the day runs thread
|
||||
// theirs. Without them a seam is the one stretch of the trip a via cannot
|
||||
// shape: it is asked for on its own, so the points the traveller dropped on it
|
||||
// never reached the router and dragging one did visibly nothing.
|
||||
const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === seam.from.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
const r = await calculateRouteWithLegs(
|
||||
[
|
||||
{ lat: seam.from.lat, lng: seam.from.lng },
|
||||
...shaping.map(v => ({ lat: v.lat, lng: v.lng })),
|
||||
{ lat: seam.to.lat, lng: seam.to.lng },
|
||||
],
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
|
||||
// wants the whole drive: summed here, exactly as a day run folds its via legs
|
||||
// back onto the stop pair they belong to.
|
||||
if (!r.legs.length) continue
|
||||
const merged = r.legs.length === 1 ? r.legs[0] : {
|
||||
...r.legs[0],
|
||||
distance: r.legs.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
duration: r.legs.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
||||
}
|
||||
setSeamLegs(prev => ({
|
||||
...prev,
|
||||
[legKey(seam.from, seam.to)]: {
|
||||
seg: { ...merged, mode },
|
||||
line: r.coordinates,
|
||||
vias: [],
|
||||
shape: seamShape(seam.from, viasByDay),
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
// A seam that will not route stays missing, exactly like any other leg that
|
||||
// will not route: the chain shows a gap and the totals are partial rather than
|
||||
// invented. Retried on the next run of this effect.
|
||||
if (controller.signal.aborted) return
|
||||
}
|
||||
await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
})()
|
||||
return () => controller.abort()
|
||||
// seamKey stands in for `seams`: same pairs, same requests. avoidKey, not `avoid`:
|
||||
// a fresh array every render would re-ask for every seam on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seamKey, routeProfile, tripId, avoidKey])
|
||||
|
||||
return useMemo(() => {
|
||||
// Legs and snaps, flat. They are filed under a key built from the two stops' own
|
||||
// coordinates, so which day they were fetched under stops mattering the moment a
|
||||
// drive across midnight moves its stops to the next card — the road between two
|
||||
// places is the road between them whichever date it is driven on.
|
||||
const allSnaps: Record<string, SnappedWaypoint> = {}
|
||||
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {})
|
||||
const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
|
||||
|
||||
const lines: [number, number][][] = []
|
||||
const lineDays: number[] = []
|
||||
const segments: RouteSegment[] = []
|
||||
const accessLines: RoadtripRoutes['accessLines'] = []
|
||||
// A loop rather than a map, because the range budget outlives a single day: a tank
|
||||
// does not empty overnight, so what is left at the end of one day is what the next
|
||||
// one starts with. `plan` is already in day order.
|
||||
// one starts with. `chains` is already in day order, and the legs inside them are in
|
||||
// driving order, which is the order the tank is emptied in.
|
||||
const out: RoadtripDay[] = []
|
||||
let carryKm: number | null = 0
|
||||
for (const day of plan) {
|
||||
const dayLegs = legsByDay[day.dayId] ?? {}
|
||||
const daySnaps = snapByDay[day.dayId] ?? {}
|
||||
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
|
||||
for (const leg of routed) {
|
||||
/** The stop the previous card ended at, so the next one can be driven to from it. */
|
||||
let previousStop: RoadtripStop | undefined
|
||||
/** And which day that was, so the road out of it is drawn as that day's. */
|
||||
let previousDayNumber: number | undefined
|
||||
for (const chain of chains) {
|
||||
const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]))
|
||||
/**
|
||||
* The drives that reach this card rather than leave from it, and only when the
|
||||
* traveller asked for them.
|
||||
*
|
||||
* Every one of these crosses from one card to the next: a night drive that handed
|
||||
* its stops forward, or plainly the road from yesterday's last stop to today's
|
||||
* first. Whether a road trip is one continuous line or a day at a time is exactly
|
||||
* what the switch asks, so with it off a card holds only the driving between its
|
||||
* own stops — no stroke across the join, and none of those kilometres in its total.
|
||||
*
|
||||
* A night drive is no exception. It is stored inside one day and it is real
|
||||
* driving, but on screen it runs from a stop on one card to a stop on the next, and
|
||||
* a line between two cards is the thing the switch is about.
|
||||
*
|
||||
* `drawnAs` is the day it LEAVES rather than the one it arrives on. The kilometres
|
||||
* count towards the arrival, because that is when they are driven, but the stroke
|
||||
* starts at a stop on yesterday's card and colouring it as tomorrow makes tomorrow
|
||||
* look like it begins at a stop that is not on it.
|
||||
*/
|
||||
const inboundAt = new Map<number, { seg: RouteSegment | undefined; line: [number, number][]; drawnAs: number }>()
|
||||
if (connectDays) {
|
||||
for (const spill of chain.spills) {
|
||||
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber })
|
||||
}
|
||||
// And the road out of the previous card where no night drive already opens this
|
||||
// one. Nothing moved for that one: it is the gap a day-at-a-time plan leaves.
|
||||
const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0])
|
||||
if (joined) inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber })
|
||||
}
|
||||
previousStop = chain.stops[chain.stops.length - 1] ?? previousStop
|
||||
previousDayNumber = chain.stops.length ? chain.dayNumber : previousDayNumber
|
||||
for (let i = 0; i < chain.stops.length; i++) {
|
||||
const inbound = inboundAt.get(i)
|
||||
if (inbound) {
|
||||
if (inbound.line.length > 1) { lines.push(inbound.line); lineDays.push(inbound.drawnAs) }
|
||||
if (inbound.seg) segments.push(inbound.seg)
|
||||
}
|
||||
const leg = routed[i]
|
||||
if (!leg) continue
|
||||
if (leg.line.length > 1) lines.push(leg.line)
|
||||
if (leg.line.length > 1) { lines.push(leg.line); lineDays.push(chain.dayNumber) }
|
||||
segments.push(leg.seg)
|
||||
}
|
||||
// The run polylines end to end: leg 0 of a run carries its whole geometry, the
|
||||
// rest carry none, so concatenating what is there gives the day as driven.
|
||||
const geometry = routed.flatMap(l => l?.line ?? [])
|
||||
// Each leg carries its own stretch of road now, so the day's line is simply its
|
||||
// legs end to end — with the night that reached it threaded in at the stop it
|
||||
// arrives at, so the line is drawn in the order it is driven.
|
||||
const geometry: [number, number][] = []
|
||||
for (let i = 0; i < chain.stops.length; i++) {
|
||||
const inbound = inboundAt.get(i)
|
||||
if (inbound) geometry.push(...inbound.line)
|
||||
geometry.push(...(routed[i]?.line ?? []))
|
||||
}
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const inbound = [...inboundAt.values()].map(l => l.seg)
|
||||
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
|
||||
+ inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
|
||||
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
|
||||
const schedule = computeSchedule(
|
||||
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
legs.map(l => l?.duration),
|
||||
)
|
||||
+ inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
|
||||
const schedule = chain.schedule
|
||||
const legVias = routed.map(l => l?.vias ?? [])
|
||||
const stops = day.stops.map(s => {
|
||||
const snap = daySnaps[stopKey(s)]
|
||||
const stops = chain.stops.map(s => {
|
||||
const snap = allSnaps[stopKey(s)]
|
||||
const line = spurFor(snap)
|
||||
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
|
||||
return { ...s, offRoadMeters: line ? snap.meters : null }
|
||||
})
|
||||
// The night drive is on the tank too, and it is in no leg list: it ends at this
|
||||
// chain's first stop rather than leaving from it. Added to the carry so the budget
|
||||
// has spent it before the first stop of the day gets a chance to fill up again.
|
||||
const inboundKm = inbound
|
||||
.filter(l => l && (l.mode === undefined || l.mode === 'driving'))
|
||||
.reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000
|
||||
const startKm = carryKm === null ? null : carryKm + inboundKm
|
||||
const drive = deriveDriveWarnings(
|
||||
legs,
|
||||
// One longer than the legs: the last stop of the day counts too, because filling
|
||||
// up on arrival is what makes the next morning start full.
|
||||
stops.map(s => refuelsRange(s.stopType)),
|
||||
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
|
||||
limits,
|
||||
carryKm,
|
||||
startKm,
|
||||
// Same length and same order, so a stop that says how far it fills is read
|
||||
// against itself rather than against the traveller's default.
|
||||
stops.map(s => s.fillPercent),
|
||||
)
|
||||
carryKm = drive.carryKm
|
||||
// Where each tank runs dry, as a place rather than a distance.
|
||||
//
|
||||
// Walked along the DRIVING legs only. `geometry` above is every leg end to end,
|
||||
// ferries and walks included, while the budget counts none of those — so walking
|
||||
// the drawn line to the same figure overshoots by the whole length of any crossing
|
||||
// the day happens to contain, and the marker lands a ferry's width off the road.
|
||||
const drivingLine = routed
|
||||
.filter(l => l && l.seg.mode !== undefined && l.seg.mode === 'driving')
|
||||
.flatMap(l => l?.line ?? [])
|
||||
const dryPoints = drive.emptyAt
|
||||
.map(dry => {
|
||||
const at = pointAtMeters(drivingLine.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters)
|
||||
return at ? { ...dry, lat: at.lat, lng: at.lng } : null
|
||||
})
|
||||
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null)
|
||||
// The same line the dry points are measured along, so anything offered against
|
||||
// them is projected in the same space. Handed on as the SAME array when the day
|
||||
// drives all the way through, which is nearly every day — only a day carrying a
|
||||
// ferry or a walk pays for a second copy.
|
||||
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
|
||||
out.push({
|
||||
...day, stops, legs, legVias, schedule, geometry, distance, duration,
|
||||
dayId: chain.dayId,
|
||||
dayNumber: chain.dayNumber,
|
||||
date: chain.date,
|
||||
title: chain.title,
|
||||
avoidMissed: missedByDay[chain.dayId],
|
||||
spills: chain.spills,
|
||||
stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry,
|
||||
driveWarnings: drive.warnings,
|
||||
dayWarning: drive.day,
|
||||
})
|
||||
}
|
||||
const drives = out.filter(d => d.stops.length > 1)
|
||||
return {
|
||||
days: out,
|
||||
days: drives,
|
||||
lines,
|
||||
lineDays,
|
||||
segments,
|
||||
accessLines,
|
||||
vias: out.flatMap(d => d.legVias.flat()),
|
||||
totalDistance: out.reduce((s, d) => s + d.distance, 0),
|
||||
totalDuration: out.reduce((s, d) => s + d.duration, 0),
|
||||
totalDistance: drives.reduce((s, d) => s + d.distance, 0),
|
||||
totalDuration: drives.reduce((s, d) => s + d.duration, 0),
|
||||
// Over the days the head is a summary OF, which is the ones with a drive: a day
|
||||
// holding a single stop draws no card and contributes nothing else here either.
|
||||
// Service stops are not stops in this sense — a charger on the way is part of the
|
||||
// drive, and counting it here would make the head disagree with the day cards.
|
||||
totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
quietDays,
|
||||
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
// A day left with one stop or none draws no drive, so it stays what it was: an
|
||||
// outline that appears while a stop is in flight and is not worth a row otherwise.
|
||||
quietDays: out
|
||||
.filter(d => d.stops.length < 2)
|
||||
.map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
loading,
|
||||
}
|
||||
}, [plan, legsByDay, snapByDay, loading, quietDays, limits])
|
||||
}
|
||||
}, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays])
|
||||
}
|
||||
@@ -11,13 +11,43 @@ const { roadtripApi } = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('../../api/client', () => ({ roadtripApi }))
|
||||
|
||||
// Offline is the hook's headline rule and it has no other way in: `editable` is read
|
||||
// straight off useNetworkMode, so a test that never drives it can only ever see the
|
||||
// online answer.
|
||||
const { networkMode } = vi.hoisted(() => ({ networkMode: { offline: false } }))
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => networkMode }))
|
||||
|
||||
// The remote path is driven by hand: the hook hands its handler to addListener, and the
|
||||
// cases below call it the way a message from another screen would arrive.
|
||||
const { addListener, removeListener } = vi.hoisted(() => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener,
|
||||
removeListener,
|
||||
}))
|
||||
|
||||
import { useRoadtripVias } from './useRoadtripVias'
|
||||
|
||||
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
|
||||
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
|
||||
})
|
||||
|
||||
/** Whatever the hook last gave the socket, which is how a change from another screen gets in. */
|
||||
const socketHandler = (): ((event: Record<string, unknown>) => void) => {
|
||||
const call = addListener.mock.calls[addListener.mock.calls.length - 1]
|
||||
if (!call) throw new Error('the hook never subscribed to the socket')
|
||||
return call[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
networkMode.offline = false
|
||||
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
|
||||
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
|
||||
@@ -107,4 +137,141 @@ describe('useRoadtripVias', () => {
|
||||
|
||||
expect(roadtripApi.addVia).not.toHaveBeenCalled()
|
||||
})
|
||||
it('FE-ROADTRIP-VIAS-008: a via somebody else moved replaces that day and only that day', async () => {
|
||||
// The screen holds the id the router handed it, a string, and the socket sends the
|
||||
// number the server broadcasts. Compared as they arrive, every remote change is lost.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias('7', true))
|
||||
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
|
||||
const untouched = result.current.byDay[1]
|
||||
|
||||
// dayId travels as the route param it came from, so it is a string on the wire too.
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed',
|
||||
tripId: 7,
|
||||
dayId: '2',
|
||||
vias: [via({ id: 8, day_id: 2, lat: 48 }), via({ id: 9, day_id: 2, lat: 49 })],
|
||||
}))
|
||||
|
||||
// The server sends the day's whole list, so this replaces rather than merges: a merge
|
||||
// keeps drawing the point the other screen has just deleted.
|
||||
expect(result.current.byDay[2].map(v => v.id)).toEqual([8, 9])
|
||||
expect(result.current.byDay[1]).toBe(untouched)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-009: a change to another trip is not applied to this one', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const before = result.current.byDay
|
||||
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed', tripId: 8, dayId: '1', vias: [via({ id: 4, day_id: 1 })],
|
||||
}))
|
||||
|
||||
// The same object, not merely an equal one: this list feeds the routing key, so even
|
||||
// handing back a fresh copy re-routes the trip for an event meant for somebody else.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-010: a day emptied elsewhere leaves the map instead of lingering empty', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
|
||||
|
||||
// An empty list left behind still answers yes to a `dayId in byDay` check, which is
|
||||
// how the surfaces ask whether a day is shaped at all.
|
||||
expect(2 in result.current.byDay).toBe(false)
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
|
||||
const before = result.current.byDay
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
|
||||
// Nothing left to drop, so nothing new to hand out — a copy here would re-route the
|
||||
// whole trip for an event that changed nothing.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-011: only a track event moves the track, and it clears just its own day', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({
|
||||
vias: [via({ id: 1, day_id: 1 })],
|
||||
tracks: [{ day_id: 1, place_id: 11, stray_km: 0.4 }, { day_id: 2, place_id: 22, stray_km: null }],
|
||||
})
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.trackByDay[1]?.place_id).toBe(11))
|
||||
|
||||
// A via event carries no track at all. Letting it reach the track map would drop the
|
||||
// badge for the road the day follows every time anybody nudged a point on it.
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripVia:changed', tripId: 7, dayId: '1', vias: [via({ id: 3, day_id: 1 })],
|
||||
}))
|
||||
expect(result.current.trackByDay[1]?.place_id).toBe(11)
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripTrack:changed', tripId: 7, dayId: '1', track: null }))
|
||||
expect(1 in result.current.trackByDay).toBe(false)
|
||||
expect(result.current.trackByDay[2]?.place_id).toBe(22)
|
||||
|
||||
act(() => socketHandler()({
|
||||
type: 'roadtripTrack:changed', tripId: 7, dayId: '2', track: { day_id: 2, place_id: 33, stray_km: 1 },
|
||||
}))
|
||||
expect(result.current.trackByDay[2].place_id).toBe(33)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-012: the listener follows the trip and goes down with the screen', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result, rerender, unmount } = renderHook(({ id }: { id: number }) => useRoadtripVias(id, true), {
|
||||
initialProps: { id: 7 },
|
||||
})
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const first = socketHandler()
|
||||
|
||||
rerender({ id: 8 })
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledWith(8))
|
||||
const second = socketHandler()
|
||||
expect(second).not.toBe(first)
|
||||
expect(removeListener).toHaveBeenCalledWith(first)
|
||||
|
||||
// The live handler is the one built for the trip on screen. One kept from the previous
|
||||
// trip would keep writing that trip's days into this one.
|
||||
act(() => second({
|
||||
type: 'roadtripVia:changed',
|
||||
tripId: 7,
|
||||
dayId: '1',
|
||||
vias: [via({ id: 4, day_id: 1 }), via({ id: 5, day_id: 1 })],
|
||||
}))
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
|
||||
// A handler that outlives its screen writes into a trip nobody is looking at.
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(second)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-014: with no network the map is told not to offer a drag', async () => {
|
||||
// A via anchors to a POSITION in the day stop list, not to an id, so a write queued
|
||||
// offline and replayed after somebody else added a stop would re-pin the drive onto
|
||||
// a leg nobody chose, silently and hours later. Until the anchor is an assignment id
|
||||
// the honest answer is to stop offering the gesture rather than to lose the edit one
|
||||
// write at a time.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [] })
|
||||
networkMode.offline = true
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
expect(result.current.editable).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-013: an event that names no day is dropped rather than filed under NaN', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
const before = result.current.byDay
|
||||
|
||||
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, vias: [via({ id: 4, day_id: 1 })] }))
|
||||
|
||||
// Unguarded this lands under the key NaN, and everything that walks the days then
|
||||
// routes a day the trip does not have.
|
||||
expect(result.current.byDay).toBe(before)
|
||||
expect(Object.keys(result.current.byDay)).toEqual(['1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { roadtripApi } from '../../api/client'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
|
||||
@@ -66,7 +67,7 @@ export interface RoadtripVias {
|
||||
/** Absent leaves the day's track alone, null clears it, an object records a new one. */
|
||||
track?: { place_id: number; stray_km?: number | null } | null,
|
||||
) => Promise<void>
|
||||
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
|
||||
move: (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => Promise<void>
|
||||
remove: (dayId: number, id: number) => Promise<void>
|
||||
/**
|
||||
* Correct a day's anchors after its stops changed shape.
|
||||
@@ -92,6 +93,14 @@ const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
|
||||
* patched by hand — a via has a server-assigned id and sequence, and guessing them would
|
||||
* be a second source of truth for the sake of one round trip.
|
||||
*/
|
||||
/** A copy of the map without one day, so an emptied day does not linger as an empty list. */
|
||||
function omit<T>(map: Record<number, T>, dayId: number): Record<number, T> {
|
||||
if (!(dayId in map)) return map
|
||||
const next = { ...map }
|
||||
delete next[dayId]
|
||||
return next
|
||||
}
|
||||
|
||||
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
|
||||
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
|
||||
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
|
||||
@@ -146,6 +155,36 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
|
||||
|
||||
useEffect(() => { void reload() }, [reload])
|
||||
|
||||
/**
|
||||
* What somebody else did to the drive, applied as it happens.
|
||||
*
|
||||
* Its own listener rather than a slice in the store, the way the collab tabs do it:
|
||||
* these points live in this hook and nowhere else, and giving them a store slice would
|
||||
* be a second copy of the same list to keep in step.
|
||||
*
|
||||
* The server sends the whole day's list, so applying it is a replace rather than a
|
||||
* merge — and it excludes the socket that wrote, so a drag never gets its own point
|
||||
* handed back mid-gesture.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!tripId || !active) return
|
||||
const handler = (event: Record<string, unknown>) => {
|
||||
if (String(event.tripId) !== String(tripId)) return
|
||||
const dayId = Number(event.dayId)
|
||||
if (!Number.isFinite(dayId)) return
|
||||
if (event.type === 'roadtripVia:changed') {
|
||||
const vias = (event.vias ?? []) as RoadtripVia[]
|
||||
setByDay(prev => (vias.length ? { ...prev, [dayId]: vias } : omit(prev, dayId)))
|
||||
}
|
||||
if (event.type === 'roadtripTrack:changed') {
|
||||
const track = event.track as RoadtripDayTrack | null
|
||||
setTrackByDay(prev => (track ? { ...prev, [dayId]: track } : omit(prev, dayId)))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, active])
|
||||
|
||||
const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
|
||||
@@ -171,9 +210,16 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
||||
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
|
||||
// The anchor rides along when the caller worked out a new one: a via dragged past the
|
||||
// stop it used to sit before belongs to the next leg now, and saying only where it is
|
||||
// leaves it claiming the old one.
|
||||
await roadtripApi.moveVia(tripId, dayId, id, {
|
||||
lat,
|
||||
lng,
|
||||
...(afterOrderIndex === undefined ? {} : { after_order_index: afterOrderIndex }),
|
||||
})
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
|
||||
/**
|
||||
* Seven settings assembled into one answer.
|
||||
*
|
||||
* The arithmetic is pinned next door in vehicleRange.test.ts; what is worth guarding
|
||||
* here is the assembly: that an unrecognised word is no vehicle rather than a vehicle
|
||||
* nobody can refuel, that every one of the parts really reaches the division, and that
|
||||
* a range which rounds away arrives as no limit rather than as a zero one.
|
||||
*
|
||||
* The identity cases are worth less than they look, and are kept small for that reason.
|
||||
* Every reader today destructures the object on the spot, so its reference reaches
|
||||
* nothing; what they guard is the day a reader stops doing that.
|
||||
*/
|
||||
|
||||
// setState replaces the settings object wholesale, which is what the hook has to cope
|
||||
// with anyway: the settings load hands it a new one on every fetch.
|
||||
function setSettings(over: Partial<Settings>): void {
|
||||
act(() => { useSettingsStore.setState({ settings: over as never }) })
|
||||
}
|
||||
|
||||
/** Changes one field and leaves the rest, the way the settings dialog does. */
|
||||
function changeSetting(over: Partial<Settings>): void {
|
||||
act(() => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } as never }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Wholesale, so nothing a previous case stored can answer for this one.
|
||||
useSettingsStore.setState({ settings: {} as never })
|
||||
})
|
||||
|
||||
describe('useVehicleRange', () => {
|
||||
it('FE-VEHRANGEHOOK-001: a word the picker never wrote is no vehicle at all', () => {
|
||||
// The setting is a free string on the wire and nothing on the server checks it, so a
|
||||
// hand-edited value or a leftover from an older picker arrives here as-is. Anything
|
||||
// but the two known words has to fall back to "not said", which is the state where a
|
||||
// stop of either kind still counts as a fill-up.
|
||||
setSettings({ roadtrip_vehicle: 'lorry' })
|
||||
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
|
||||
|
||||
setSettings({ roadtrip_vehicle: 'Electric' })
|
||||
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
|
||||
|
||||
setSettings({})
|
||||
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-002: the two words it does know come back untouched', () => {
|
||||
setSettings({ roadtrip_vehicle: 'combustion' })
|
||||
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('combustion')
|
||||
|
||||
setSettings({ roadtrip_vehicle: 'electric' })
|
||||
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('electric')
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-003: a complete pair of parts beats the round number that was typed', () => {
|
||||
// 55 litres at 7 per hundred is 786 km, and the traveller who filled both fields said
|
||||
// something more exact than "about 500".
|
||||
setSettings({
|
||||
roadtrip_vehicle: 'combustion',
|
||||
roadtrip_tank_litres: 55,
|
||||
roadtrip_litres_per_100: 7,
|
||||
roadtrip_range_km: 500,
|
||||
})
|
||||
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(786)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-004: half a specification leaves the typed number standing', () => {
|
||||
// Half a pair is not a range, and the typed figure is still a real answer — dropping
|
||||
// it here would take the range budget away from somebody who had set one.
|
||||
setSettings({
|
||||
roadtrip_vehicle: 'combustion',
|
||||
roadtrip_tank_litres: 55,
|
||||
roadtrip_range_km: 500,
|
||||
})
|
||||
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-005: figures left over from the other kind of car are not used', () => {
|
||||
// Someone who filled in a tank and then switched the picker to electric still has
|
||||
// both petrol fields in their settings. Feeding them to an electric car would hand
|
||||
// back a range that has nothing to do with the battery.
|
||||
setSettings({
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_tank_litres: 55,
|
||||
roadtrip_litres_per_100: 7,
|
||||
roadtrip_range_km: 500,
|
||||
})
|
||||
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-006: the battery figures arrive complete, age included', () => {
|
||||
// 58 kWh less a tenth is 52.2, and 52.2 at 16 per hundred is 326 km. Forgetting to
|
||||
// forward the degradation would give 363 here, which is a warning that fires 37 km
|
||||
// after the car has stopped.
|
||||
setSettings({
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 58,
|
||||
roadtrip_kwh_per_100: 16,
|
||||
roadtrip_battery_degradation: 10,
|
||||
})
|
||||
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(326)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-007: an unset or zeroed range is no limit, never a range of nothing', () => {
|
||||
// Zero is a legal thing to type into the field and means the same as leaving it
|
||||
// empty. Handing a 0 downstream would put the dry point at the first metre and light
|
||||
// up every stop on the trip.
|
||||
setSettings({ roadtrip_range_km: 0 })
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull()
|
||||
|
||||
setSettings({})
|
||||
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-008: a re-render with nothing changed hands back the same object', () => {
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 })
|
||||
const { result, rerender } = renderHook(() => useVehicleRange())
|
||||
|
||||
const first = result.current
|
||||
rerender()
|
||||
|
||||
// Identity, not equality: the readers put this straight into an effect dependency
|
||||
// list, so a new object per render means the whole trip is re-routed per render.
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-009: a setting this hook does not read causes no churn', () => {
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: false })
|
||||
const { result } = renderHook(() => useVehicleRange())
|
||||
const first = result.current
|
||||
|
||||
// A fresh settings object arrives on every settings load and after every unrelated
|
||||
// toggle, and the seven fields are read as primitives so none of that reaches here.
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: true })
|
||||
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-011: every part of the car reaches the arithmetic while mounted', () => {
|
||||
// Each field changed on a hook that is already mounted, which is the only way a
|
||||
// missing memo dependency shows itself: mounting afresh recomputes regardless, so a
|
||||
// suite built out of fresh mounts passes with half the dependency list deleted.
|
||||
setSettings({ roadtrip_vehicle: 'electric', roadtrip_battery_kwh: 58, roadtrip_kwh_per_100: 16 })
|
||||
const { result } = renderHook(() => useVehicleRange())
|
||||
expect(result.current.rangeKm).toBe(363)
|
||||
|
||||
changeSetting({ roadtrip_battery_kwh: 77 })
|
||||
expect(result.current.rangeKm).toBe(481)
|
||||
|
||||
changeSetting({ roadtrip_kwh_per_100: 22 })
|
||||
expect(result.current.rangeKm).toBe(350)
|
||||
|
||||
changeSetting({ roadtrip_battery_degradation: 10 })
|
||||
expect(result.current.rangeKm).toBe(315)
|
||||
|
||||
// And the kind itself: the same figures mean nothing to a petrol car, which has no
|
||||
// tank size stored here at all.
|
||||
changeSetting({ roadtrip_vehicle: 'combustion' })
|
||||
expect(result.current.vehicleKind).toBe('combustion')
|
||||
expect(result.current.rangeKm).toBeNull()
|
||||
|
||||
changeSetting({ roadtrip_tank_litres: 55, roadtrip_litres_per_100: 7 })
|
||||
expect(result.current.rangeKm).toBe(786)
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-012: a range that rounds away leaves no limit rather than a zero one', () => {
|
||||
// The branch this hook owns, as opposed to the one its neighbour owns: parts that
|
||||
// round to nothing must not arrive as the number 0, which every reader downstream
|
||||
// would take for a car that cannot leave the drive.
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_tank_litres: 1, roadtrip_litres_per_100: 500 })
|
||||
const { result } = renderHook(() => useVehicleRange())
|
||||
|
||||
expect(result.current.rangeKm).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-VEHRANGEHOOK-010: a range the traveller actually changed does come through', () => {
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 })
|
||||
const { result } = renderHook(() => useVehicleRange())
|
||||
const first = result.current
|
||||
|
||||
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 640 })
|
||||
|
||||
// The other half of case 009: memoised is not frozen. A stale object here would keep
|
||||
// the old budget on screen until something else happened to re-render.
|
||||
expect(result.current.rangeKm).toBe(640)
|
||||
expect(result.current).not.toBe(first)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { effectiveRangeKm } from './vehicleRange'
|
||||
import type { VehicleKind } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* How far the traveller's car goes on one fill, and what kind of car it is.
|
||||
*
|
||||
* One place rather than two, because the answer is assembled from seven settings and two
|
||||
* readers need it: the routes hook, which turns it into the range budget every warning is
|
||||
* measured against, and the rail, which tells a traveller what a given fill buys at a
|
||||
* given stop. Assembling it twice is the hand-mirrored state this codebase refuses
|
||||
* everywhere else, and the two copies would drift the first time a field is added.
|
||||
*
|
||||
* Each setting is read as its own primitive so the memo compares numbers. An object
|
||||
* rebuilt on every render would give the result a new identity every time, and what
|
||||
* depends on it is the effect that recalculates every day's driving.
|
||||
*/
|
||||
export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
// Validated rather than trusted: a per-user setting gets no server-side check, and an
|
||||
// unknown word here would silently stop both kinds of stop counting as a fill-up.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
|
||||
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
|
||||
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
|
||||
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
|
||||
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
|
||||
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
|
||||
// The parts win over the typed number when they add up, because they are the more
|
||||
// specific answer; the settings dialog shows the result, so it is never a surprise.
|
||||
// Zero and absent both mean "no limit", which is why the falsy fold happens here once.
|
||||
const planningKm = useMemo(
|
||||
() => effectiveRangeKm(
|
||||
vehicleKind,
|
||||
{ tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent },
|
||||
rangeKm,
|
||||
) || null,
|
||||
[vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm],
|
||||
)
|
||||
|
||||
return useMemo(() => ({ vehicleKind, rangeKm: planningKm }), [vehicleKind, planningKm])
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of.
|
||||
*
|
||||
* Two things are worth guarding here. The arithmetic itself, because it decides where
|
||||
* every fuel warning lands, and the unit round trip, because an imperial traveller who
|
||||
* types 15 into a field labelled gallons must not end up with 15 litres in storage.
|
||||
*/
|
||||
|
||||
describe('rangeFromSpec', () => {
|
||||
it('FE-ROADTRIP-VEHRANGE-001: a tank divided by a consumption is a range', () => {
|
||||
expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-002: a battery divided by a consumption is a range', () => {
|
||||
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-003: age comes off the battery before the division', () => {
|
||||
// 58 kWh minus a tenth is 52.2, and 52.2 at 16 per hundred is 326.25 km.
|
||||
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-004: half a pair is not a range', () => {
|
||||
// Guessing the missing half would put the warnings at the wrong place while
|
||||
// looking exact, which is worse than saying nothing.
|
||||
expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull()
|
||||
expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull()
|
||||
expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-005: figures belong to the kind of car they came from', () => {
|
||||
// A tank size on an electric car is a leftover from before the picker changed,
|
||||
// not a battery.
|
||||
expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull()
|
||||
expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull()
|
||||
expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-006: a battery reported as entirely gone is a typo, not a car', () => {
|
||||
// Capped at 90 %, because zero usable capacity would put the dry point at the
|
||||
// first metre of the trip and divide by nothing.
|
||||
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-007: nonsense in a field is the same as an empty field', () => {
|
||||
expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull()
|
||||
expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull()
|
||||
expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveRangeKm', () => {
|
||||
it('FE-ROADTRIP-VEHRANGE-008: the parts win over a typed round number', () => {
|
||||
// Somebody who gave a tank size and a consumption said something more exact
|
||||
// than "about 500", and the dialog shows them the result.
|
||||
expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-009: an incomplete pair hands the typed number straight back', () => {
|
||||
expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500)
|
||||
expect(effectiveRangeKm(null, {}, 500)).toBe(500)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-010: no figures anywhere is no limit', () => {
|
||||
expect(effectiveRangeKm(null, {}, 0)).toBeNull()
|
||||
expect(effectiveRangeKm('electric', {}, undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unit round trip', () => {
|
||||
it('FE-ROADTRIP-VEHRANGE-011: metric figures are stored exactly as typed', () => {
|
||||
expect(storeSpec('tankLitres', 55, false)).toBe(55)
|
||||
expect(showSpec('tankLitres', 55, false)).toBe(55)
|
||||
expect(showSpec('litresPer100', 7.4, false)).toBe(7.4)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-012: gallons go in and come back out as the same gallons', () => {
|
||||
// 15 US gallons is 56.78 litres, and storage is litres — the same rule the
|
||||
// range itself follows, where storage is always kilometres.
|
||||
const stored = storeSpec('tankLitres', 15, true)
|
||||
expect(stored).toBe(56.78)
|
||||
expect(showSpec('tankLitres', stored, true)).toBe(15)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-013: miles per gallon is the inverse, and survives the trip', () => {
|
||||
// The one field that is not a scale factor: 30 mpg is 7.84 L/100 km, and a
|
||||
// straight multiplication would have stored a car four times as thirsty.
|
||||
const stored = storeSpec('litresPer100', 30, true)
|
||||
expect(stored).toBe(7.84)
|
||||
expect(showSpec('litresPer100', stored, true)).toBe(30)
|
||||
expect(specUnit('litresPer100', true)).toBe('mpg')
|
||||
expect(specUnit('litresPer100', false)).toBe('L/100 km')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-014: electric consumption is quoted per 100 miles', () => {
|
||||
// What the EPA prints on the window sticker, so the figure is one a US
|
||||
// traveller can copy rather than convert.
|
||||
expect(showSpec('kwhPer100', 16, true)).toBe(25.7)
|
||||
expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi')
|
||||
// A capacity is a capacity everywhere.
|
||||
expect(specUnit('batteryKwh', true)).toBe('kWh')
|
||||
expect(showSpec('batteryKwh', 58, true)).toBe(58)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-016: a consumption typed a hundred times too high does not silence the typed range', () => {
|
||||
// A consumption of 500 rather than 5.0 makes the parts round to nothing. Zero used
|
||||
// to count as an answer, so the `??` in effectiveRangeKm never reached the 500 km
|
||||
// the traveller had typed by hand, and every range warning went quiet with nothing
|
||||
// on screen to say which field did it.
|
||||
expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 500 })).toBeNull()
|
||||
expect(effectiveRangeKm('combustion', { tankLitres: 1, litresPer100: 500 }, 500)).toBe(500)
|
||||
|
||||
// Same shape on the other kind of car, where a decimal point is just as easy to lose.
|
||||
expect(rangeFromSpec('electric', { batteryKwh: 0.5, kwhPer100: 200 })).toBeNull()
|
||||
expect(effectiveRangeKm('electric', { batteryKwh: 0.5, kwhPer100: 200 }, 420)).toBe(420)
|
||||
|
||||
// One kilometre is still a figure, so the cut is below it and not at some round guess.
|
||||
expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 100 })).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VEHRANGE-015: an empty field stays empty rather than becoming a zero', () => {
|
||||
expect(showSpec('tankLitres', undefined, false)).toBeUndefined()
|
||||
expect(showSpec('tankLitres', 0, true)).toBeUndefined()
|
||||
expect(storeSpec('tankLitres', 0, true)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { VehicleKind } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* How far the vehicle goes on one fill, worked out from what it is made of.
|
||||
*
|
||||
* Two ways to the same number, and the point of having both is that people know their
|
||||
* car in different ways. Some know "about 600 km"; some know the tank holds 55 litres
|
||||
* and it does 7 per hundred, and would have to do the division themselves. Whoever types
|
||||
* a range directly still gets exactly what they typed — this only fills that number in
|
||||
* when it can and the field is empty.
|
||||
*
|
||||
* Deliberately not a consumption model. ABRP asks for reference speed, relative speed and
|
||||
* a drive profile because it predicts a specific drive; this answers "how far on one
|
||||
* fill", which is a division. Adding speed terms would make the number look precise
|
||||
* without making it right, since the biggest factor by far is how the person drives.
|
||||
*
|
||||
* Pure: no React, no store, no network.
|
||||
*/
|
||||
|
||||
export interface VehicleSpec {
|
||||
/** Litres the tank holds. Petrol only. */
|
||||
tankLitres?: number | null
|
||||
/** Litres per 100 km. Petrol only. */
|
||||
litresPer100?: number | null
|
||||
/** Usable battery when new, in kWh. Electric only. */
|
||||
batteryKwh?: number | null
|
||||
/** kWh per 100 km. Electric only. */
|
||||
kwhPer100?: number | null
|
||||
/**
|
||||
* How much of the battery the years have taken, as a percentage.
|
||||
*
|
||||
* A five-year-old car does not carry the capacity on its data sheet, and the gap is
|
||||
* the difference between arriving and not on the last leg of a day.
|
||||
*/
|
||||
degradationPercent?: number | null
|
||||
}
|
||||
|
||||
/** A positive, finite number, or null. Everything here comes from a text field. */
|
||||
function positive(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The range in kilometres that the parts add up to, or null when they do not add up.
|
||||
*
|
||||
* Null rather than a guess whenever a piece is missing: half a specification is not a
|
||||
* range, and a made-up figure here becomes a fuel warning that fires at the wrong place.
|
||||
*/
|
||||
/**
|
||||
* A rounded range that came out at nothing is a typo, not a car.
|
||||
*
|
||||
* A consumption typed as 500 rather than 5.0 rounds to a range of zero, and zero is a
|
||||
* number: `effectiveRangeKm` would take it as the answer and throw away the range the
|
||||
* traveller had typed by hand right next to it. What reached the rail was "no range at
|
||||
* all", so every warning went quiet, with nothing on screen to say which field did it.
|
||||
* Reported as nothing instead, so the typed figure gets its turn.
|
||||
*/
|
||||
function atLeastAKilometre(km: number): number | null {
|
||||
return km >= 1 ? km : null
|
||||
}
|
||||
|
||||
export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: VehicleSpec): number | null {
|
||||
if (vehicle === 'combustion') {
|
||||
const tank = positive(spec.tankLitres)
|
||||
const per100 = positive(spec.litresPer100)
|
||||
if (!tank || !per100) return null
|
||||
return atLeastAKilometre(Math.round((tank / per100) * 100))
|
||||
}
|
||||
|
||||
if (vehicle === 'electric') {
|
||||
const battery = positive(spec.batteryKwh)
|
||||
const per100 = positive(spec.kwhPer100)
|
||||
if (!battery || !per100) return null
|
||||
// Degradation only ever takes away, and never all of it: a battery reported as 100 %
|
||||
// gone is a typo, not a car, and dividing by zero range would put the dry point at
|
||||
// the first metre of the trip.
|
||||
const lost = Math.min(90, Math.max(0, positive(spec.degradationPercent) ?? 0))
|
||||
const usable = battery * (1 - lost / 100)
|
||||
return atLeastAKilometre(Math.round((usable / per100) * 100))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The range to plan with: what the parts say, or what was typed, in that order.
|
||||
*
|
||||
* The computed one wins because it is the more specific answer — somebody who filled in
|
||||
* a tank size and a consumption said something more exact than a round number, and the
|
||||
* dialog shows them the result so it is never a surprise.
|
||||
*/
|
||||
export function effectiveRangeKm(
|
||||
vehicle: VehicleKind | null | undefined,
|
||||
spec: VehicleSpec,
|
||||
typedKm: number | null | undefined,
|
||||
): number | null {
|
||||
return rangeFromSpec(vehicle, spec) ?? positive(typedKm)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit each field is typed in, per measurement system.
|
||||
*
|
||||
* Storage is always metric, exactly as the range itself is always kilometres: an imperial
|
||||
* traveller who typed 15 into a field labelled "gallons" must not have 15 litres stored,
|
||||
* or every warning after it fires at a quarter of the right distance.
|
||||
*
|
||||
* The imperial pair is not a straight translation of the metric one. Fuel economy is
|
||||
* quoted as miles per gallon, which is the INVERSE of litres per hundred, while electric
|
||||
* economy is quoted by the EPA as kWh per 100 miles, which is not. Forcing one shape onto
|
||||
* both would leave one of the two reading as a figure nobody recognises.
|
||||
*/
|
||||
export type SpecKey = keyof VehicleSpec
|
||||
|
||||
const LITRES_PER_GALLON = 3.785411784
|
||||
/** litres/100 km × mpg = this. The one constant that makes the inversion exact. */
|
||||
const MPG_PRODUCT = 235.214583
|
||||
const KM_PER_MILE = 1.609344
|
||||
|
||||
export function specUnit(key: SpecKey, imperial: boolean): string {
|
||||
switch (key) {
|
||||
case 'tankLitres': return imperial ? 'gal' : 'L'
|
||||
case 'litresPer100': return imperial ? 'mpg' : 'L/100 km'
|
||||
case 'batteryKwh': return 'kWh'
|
||||
case 'kwhPer100': return imperial ? 'kWh/100 mi' : 'kWh/100 km'
|
||||
default: return '%'
|
||||
}
|
||||
}
|
||||
|
||||
/** How many decimals the field is worth typing. A tank is not measured to a tenth. */
|
||||
function places(key: SpecKey, imperial: boolean): number {
|
||||
if (key === 'degradationPercent') return 0
|
||||
if (key === 'tankLitres') return imperial ? 1 : 0
|
||||
if (key === 'litresPer100') return imperial ? 0 : 1
|
||||
return 1
|
||||
}
|
||||
|
||||
function convert(key: SpecKey, value: number, imperial: boolean, toStorage: boolean): number {
|
||||
if (!imperial) return value
|
||||
switch (key) {
|
||||
case 'tankLitres': return toStorage ? value * LITRES_PER_GALLON : value / LITRES_PER_GALLON
|
||||
// Inverse in both directions, which is why one expression covers the pair.
|
||||
case 'litresPer100': return MPG_PRODUCT / value
|
||||
case 'kwhPer100': return toStorage ? value / KM_PER_MILE : value * KM_PER_MILE
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number, decimals: number): number {
|
||||
const f = 10 ** decimals
|
||||
return Math.round(value * f) / f
|
||||
}
|
||||
|
||||
/** The stored figure as it should appear in the field, or undefined when there is none. */
|
||||
export function showSpec(key: SpecKey, stored: number | null | undefined, imperial: boolean): number | undefined {
|
||||
const value = positive(stored)
|
||||
if (value === null) return undefined
|
||||
return round(convert(key, value, imperial, false), places(key, imperial))
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed figure as it should be stored. Zero comes back as zero, meaning "not said".
|
||||
*
|
||||
* Two decimals rather than none: a gallon is 3.79 litres, so an imperial figure that
|
||||
* round-tripped through a whole number would drift by up to half a litre and show the
|
||||
* traveller a different number than the one they typed.
|
||||
*/
|
||||
export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number {
|
||||
if (!(shown > 0)) return 0
|
||||
return round(convert(key, shown, imperial, true), 2)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BookDocument } from '@trek/shared'
|
||||
import { SpreadView } from './SpreadView'
|
||||
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets'
|
||||
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode, foliosOf } from './bookSheets'
|
||||
|
||||
/**
|
||||
* The book laid out for a press.
|
||||
@@ -107,7 +107,7 @@ function SheetView({
|
||||
<SpreadView
|
||||
spread={sheet.spread}
|
||||
page={doc.page}
|
||||
spreadIndex={sheet.spreadIndex}
|
||||
folios={foliosOf(doc.spreads, sheet.spreadIndex, doc.page.pageNumbers.startAt)}
|
||||
big
|
||||
print
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { folioInk } from './folioColour'
|
||||
import { isNumbered } from './bookSheets'
|
||||
|
||||
/**
|
||||
* The folios.
|
||||
@@ -17,33 +18,46 @@ import { folioInk } from './folioColour'
|
||||
* not the ones that get printed.
|
||||
*
|
||||
* The cover and the back cover carry none. A folio on a cover is a mistake in
|
||||
* every book ever bound.
|
||||
* every book ever bound. The single first and last pages carry one each, on
|
||||
* the side of the book they are bound on (#2317).
|
||||
*/
|
||||
export function PageNumbers({
|
||||
spread, page, spreadIndex,
|
||||
spread, page, folios,
|
||||
}: {
|
||||
spread: BookSpread
|
||||
page: BookPageSetup
|
||||
/** Position in the document, cover included. */
|
||||
spreadIndex: number
|
||||
/**
|
||||
* The numbers this spread carries, from foliosOf: two for a spread, one for
|
||||
* a first or last page, none for a cover. Counted by the caller, which has
|
||||
* the whole book — a spread on its own does not know how many pages sit
|
||||
* before it.
|
||||
*/
|
||||
folios: readonly number[]
|
||||
}) {
|
||||
const cfg = page.pageNumbers
|
||||
if (!cfg?.show || spread.role !== 'inner') return null
|
||||
if (!cfg?.show || !isNumbered(spread.role) || folios.length === 0) return null
|
||||
|
||||
/*
|
||||
* The cover is one sheet with a front and a back, so the first inner spread
|
||||
* opens on `startAt` — 2 by default, the page you see when you open a book
|
||||
* whose cover is page 1. `spreadIndex` counts the cover, hence the -1.
|
||||
* Which page of the sheet each number sits on. A spread numbers both; the
|
||||
* first page is a right-hand leaf and the last a left-hand one, and each
|
||||
* carries its one number on the side it actually is.
|
||||
*/
|
||||
const left = cfg.startAt + (spreadIndex - 1) * 2
|
||||
const right = left + 1
|
||||
const sides: Array<['left' | 'right', number]> = spread.role === 'inner'
|
||||
? [['left', folios[0]], ['right', folios[1]]]
|
||||
: [[spread.role === 'first' ? 'right' : 'left', folios[0]]]
|
||||
|
||||
const size = cfg.size
|
||||
const y = page.pageHeight - cfg.margin
|
||||
|
||||
/** Where the number sits on each page, given which edge it hangs from. */
|
||||
/**
|
||||
* Where the number sits on each page, given which edge it hangs from.
|
||||
*
|
||||
* A single page is one page wide, so its number sits at x 0 whichever
|
||||
* side of the book it is bound on; the side only decides which edge is
|
||||
* the outer one.
|
||||
*/
|
||||
const place = (side: 'left' | 'right') => {
|
||||
const pageX = side === 'left' ? 0 : page.pageWidth
|
||||
const pageX = side === 'left' || spread.role !== 'inner' ? 0 : page.pageWidth
|
||||
if (cfg.position === 'centre') {
|
||||
return { x: pageX, w: page.pageWidth, align: 'center' as const }
|
||||
}
|
||||
@@ -58,7 +72,7 @@ export function PageNumbers({
|
||||
|
||||
return (
|
||||
<>
|
||||
{([['left', left], ['right', right]] as const).map(([side, number]) => {
|
||||
{sides.map(([side, number]) => {
|
||||
const at = place(side)
|
||||
/*
|
||||
* Sampled at the middle of the number's own box, which is where the
|
||||
|
||||
@@ -427,7 +427,7 @@ export function ElementView({
|
||||
export function SpreadView({
|
||||
spread,
|
||||
page,
|
||||
spreadIndex = 0,
|
||||
folios = [],
|
||||
big = false,
|
||||
showGuides = false,
|
||||
print = false,
|
||||
@@ -436,11 +436,12 @@ export function SpreadView({
|
||||
spread: BookSpread
|
||||
page: BookPageSetup
|
||||
/**
|
||||
* Where this spread sits in the book. Only the folios need it — a page
|
||||
* number is the one thing on the page that is a function of position rather
|
||||
* than of the document.
|
||||
* The page numbers this spread carries, from foliosOf. Only PageNumbers
|
||||
* needs them — a page number is the one thing on the page that is a
|
||||
* function of position in the book rather than of the spread itself, and
|
||||
* the caller is the one holding the book.
|
||||
*/
|
||||
spreadIndex?: number
|
||||
folios?: readonly number[]
|
||||
big?: boolean
|
||||
showGuides?: boolean
|
||||
/** The print renderer passes this: no guides, no placeholders, no chrome. */
|
||||
@@ -470,7 +471,7 @@ export function SpreadView({
|
||||
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
|
||||
))}
|
||||
|
||||
<PageNumbers spread={spread} page={page} spreadIndex={spreadIndex} />
|
||||
<PageNumbers spread={spread} page={page} folios={folios} />
|
||||
|
||||
{showGuides && (
|
||||
<>
|
||||
|
||||
@@ -59,6 +59,7 @@ const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }
|
||||
export function StudioCanvas({
|
||||
spread,
|
||||
spreadIndex,
|
||||
folios = [],
|
||||
page,
|
||||
zoom,
|
||||
pxPerMm,
|
||||
@@ -71,6 +72,8 @@ export function StudioCanvas({
|
||||
}: {
|
||||
spread: BookSpread | null
|
||||
spreadIndex: number
|
||||
/** The page numbers this spread carries, from foliosOf. */
|
||||
folios?: readonly number[]
|
||||
page: BookPageSetup
|
||||
zoom: number
|
||||
pxPerMm: number
|
||||
@@ -256,7 +259,7 @@ export function StudioCanvas({
|
||||
}}
|
||||
onPointerDown={() => select([])}
|
||||
>
|
||||
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
|
||||
<SpreadView spread={spread} page={page} folios={folios} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
|
||||
|
||||
{cursors && cursors.length > 0 && (
|
||||
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
@@ -427,6 +428,7 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
<StudioCanvas
|
||||
spread={s.spread}
|
||||
spreadIndex={s.activeSpread}
|
||||
folios={s.doc ? foliosOf(s.doc.spreads, s.activeSpread, s.page.pageNumbers.startAt) : []}
|
||||
page={s.page}
|
||||
zoom={s.zoom}
|
||||
pxPerMm={s.pxPerMm}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useStudioStore } from '../../store/studioStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { folioLabel, foliosOf } from './bookSheets'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { elementId as uid } from './bookIds'
|
||||
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
|
||||
@@ -200,14 +201,14 @@ function PagesPanel({
|
||||
transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<SpreadView spread={sp} page={page} spreadIndex={i} />
|
||||
<SpreadView spread={sp} page={page} folios={foliosOf(spreads, i, page.pageNumbers.startAt)} />
|
||||
</div>
|
||||
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover' ? t('journey.studio.cover')
|
||||
: sp.role === 'back' ? t('journey.studio.backCover')
|
||||
: `${i * 2} – ${i * 2 + 1}`}
|
||||
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1531,7 +1531,7 @@ function backSpread(input: AutoInput): BookSpread {
|
||||
}
|
||||
|
||||
export function buildBook(input: AutoInput): BookDocument {
|
||||
const spreads: BookSpread[] = [coverSpread(input)]
|
||||
const spreads: BookSpread[] = [coverSpread(input), blankSpread('first')]
|
||||
|
||||
/*
|
||||
* The summary and the countries open the book, before the entries.
|
||||
@@ -1572,15 +1572,41 @@ export function buildBook(input: AutoInput): BookDocument {
|
||||
}
|
||||
}
|
||||
flushRun()
|
||||
spreads.push(backSpread(input))
|
||||
spreads.push(blankSpread('last'), backSpread(input))
|
||||
return {
|
||||
version: 1,
|
||||
title: input.title.slice(0, MAX_BOOK_TITLE),
|
||||
page: input.page,
|
||||
page: withFirstPage(input.page),
|
||||
spreads: spreads.slice(0, MAX_SPREADS),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page with nothing on it, of the given kind.
|
||||
*
|
||||
* The first and the last page of a book are laid down empty on purpose: the
|
||||
* first is where a title page or a dedication goes, the last where a colophon
|
||||
* does, and neither is something a layout should decide for a person.
|
||||
*/
|
||||
function blankSpread(role: BookSpread['role']): BookSpread {
|
||||
return { id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page setup for a book that opens on a single page.
|
||||
*
|
||||
* `startAt` numbers the first page inside the covers. A book from before the
|
||||
* single first page existed opened straight onto a spread and started at 2,
|
||||
* so that its left-hand page read as the second page of the object. With a
|
||||
* first page in front of that spread the first page is page 1 — so a book laid
|
||||
* out with one starts there, whatever the setup it inherited said (#2317).
|
||||
* Nothing else of the setup is touched: the format and the folio styling are
|
||||
* the person's, only the count moved with the page that moved.
|
||||
*/
|
||||
function withFirstPage(page: BookPageSetup): BookPageSetup {
|
||||
return { ...page, pageNumbers: { ...page.pageNumbers, startAt: 1 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A book nobody has laid out yet.
|
||||
*
|
||||
@@ -1592,17 +1618,15 @@ export function buildBook(input: AutoInput): BookDocument {
|
||||
*
|
||||
* The covers are here rather than left out because a book has them and there is
|
||||
* no other way to add one: the pages rail only inserts inner spreads, between
|
||||
* the two. Empty, though, in the same way the page between them is.
|
||||
* the two. The single first and last pages likewise (#2317). Empty, though, in
|
||||
* the same way the spread between them is.
|
||||
*/
|
||||
export function emptyBook(input: AutoInput): BookDocument {
|
||||
const blank = (role: BookSpread['role']): BookSpread => ({
|
||||
id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null,
|
||||
})
|
||||
return {
|
||||
version: 1,
|
||||
title: input.title.slice(0, MAX_BOOK_TITLE),
|
||||
page: input.page,
|
||||
spreads: [blank('cover'), blank('inner'), blank('back')],
|
||||
page: withFirstPage(input.page),
|
||||
spreads: [blankSpread('cover'), blankSpread('first'), blankSpread('inner'), blankSpread('last'), blankSpread('back')],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
|
||||
doc.spreads.forEach((spread, spreadIndex) => {
|
||||
const single = spread.role !== 'inner'
|
||||
const spreadWidth = single ? pageWidth : pageWidth * 2
|
||||
const folios = foliosOf(doc.spreads, spreadIndex, pageNumbers.startAt)
|
||||
|
||||
if (single || mode === 'spreads') {
|
||||
out.push({
|
||||
@@ -120,7 +121,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
|
||||
height: pageHeight,
|
||||
spreadWidth,
|
||||
single,
|
||||
label: single ? '' : folioRange(spreadIndex, pageNumbers.startAt),
|
||||
label: folioLabel(folios),
|
||||
})
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
|
||||
height: pageHeight,
|
||||
spreadWidth,
|
||||
single: true,
|
||||
label: String(folio(spreadIndex, pageNumbers.startAt) + half),
|
||||
label: String(folios[half]),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -142,18 +143,40 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The number the left-hand page of a spread carries.
|
||||
*
|
||||
* Mirrors PageNumbers.tsx: the first inner spread opens at `startAt`, and the
|
||||
* cover is a separate sheet, which is why the index is offset rather than used
|
||||
* raw. It is only ever a label here — nothing is printed from it.
|
||||
*/
|
||||
function folio(spreadIndex: number, startAt: number): number {
|
||||
return startAt + (spreadIndex - 1) * 2
|
||||
/** Which of a book's leaves carry a page number: everything inside the covers. */
|
||||
export function isNumbered(role: BookSpread['role']): boolean {
|
||||
return role !== 'cover' && role !== 'back'
|
||||
}
|
||||
|
||||
function folioRange(spreadIndex: number, startAt: number): string {
|
||||
const left = folio(spreadIndex, startAt)
|
||||
return `${left} – ${left + 1}`
|
||||
/**
|
||||
* The page numbers a spread carries, in reading order.
|
||||
*
|
||||
* Counted along the book rather than from the index: the covers carry none,
|
||||
* a first or last page carries one, an inner spread two, and the first
|
||||
* numbered page is `startAt`. A book from before the single first page
|
||||
* existed has `startAt` 2 and no such page, so its first spread still reads
|
||||
* 2 – 3; a book laid out since has a first page and starts at 1, so that page
|
||||
* is 1 and the same spread behind it is 2 – 3 (#2317). Either way the number
|
||||
* follows what is in the book, not where the spread sits in the array.
|
||||
*
|
||||
* The renderer and the print labels both come through here, so a number seen
|
||||
* on screen is the one that is printed.
|
||||
*
|
||||
* Empty for a cover, and for an index past the end.
|
||||
*/
|
||||
export function foliosOf(spreads: readonly BookSpread[], spreadIndex: number, startAt: number): number[] {
|
||||
const spread = spreads[spreadIndex]
|
||||
if (!spread || !isNumbered(spread.role)) return []
|
||||
let next = startAt
|
||||
for (let i = 0; i < spreadIndex; i++) {
|
||||
const role = spreads[i].role
|
||||
if (!isNumbered(role)) continue
|
||||
next += role === 'inner' ? 2 : 1
|
||||
}
|
||||
return spread.role === 'inner' ? [next, next + 1] : [next]
|
||||
}
|
||||
|
||||
/** "3", or "2 – 3" for a spread; nothing for a cover. */
|
||||
export function folioLabel(folios: readonly number[]): string {
|
||||
return folios.length === 2 ? `${folios[0]} – ${folios[1]}` : folios.length === 1 ? String(folios[0]) : ''
|
||||
}
|
||||
|
||||
+50
-1
@@ -523,6 +523,44 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Roadtrip: die Zzz am Tageswechsel im Streckenstrang. Drei Buchstaben auf einer
|
||||
Grundlinie, jeder ein Drittel des Laufs nach dem vorigen, damit immer einer
|
||||
unterwegs ist. Rein dekorativ — das Wort daneben sagt schon, was das Band ist. */
|
||||
@keyframes trek-doze {
|
||||
0% { opacity: 0; transform: translate(0, 3px) scale(0.7); }
|
||||
22% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translate(6px, -10px) scale(1.15); }
|
||||
}
|
||||
.trek-doze > * {
|
||||
animation: trek-doze 3s ease-out infinite;
|
||||
}
|
||||
.trek-doze > *:nth-child(2) { animation-delay: 1s; }
|
||||
.trek-doze > *:nth-child(3) { animation-delay: 2s; }
|
||||
|
||||
/* Roadtrip: der Block, den eine Nachtfahrt auf dem Folgetag hinterlaesst. Er faellt
|
||||
von oben in die Karte, weil er von der Karte darueber kommt — kurz genug, dass ein
|
||||
Neuberechnen der Ankunftszeiten nicht als Zappeln liest. */
|
||||
@keyframes trek-spill-in {
|
||||
from { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.trek-spill-in {
|
||||
animation: trek-spill-in 320ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
/* Roadtrip: die Reserveleuchte am Band, an dem der Tank leerlaeuft. Das Gegenstueck
|
||||
zu den Zzz am Tageswechsel — dort schlaeft der Tag ein, hier geht die Lampe im
|
||||
Armaturenbrett an. Langsam an, kurz aus: eine Warnleuchte blinkt nicht hektisch,
|
||||
sie glimmt. */
|
||||
@keyframes trek-lowfuel {
|
||||
0%, 62%, 100% { opacity: 1; }
|
||||
74% { opacity: 0.28; }
|
||||
86% { opacity: 1; }
|
||||
}
|
||||
.trek-lowfuel {
|
||||
animation: trek-lowfuel 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
|
||||
@keyframes trek-fade-up {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
@@ -544,13 +582,24 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
/* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter,
|
||||
.trek-modal-enter, .trek-toast-enter, .trek-stagger > * {
|
||||
.trek-modal-enter, .trek-toast-enter, .trek-stagger > *, .trek-spill-in {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
/* Die Reserveleuchte steht still statt zu glimmen — sie bleibt an, was die Aussage
|
||||
ohnehin ist. */
|
||||
.trek-lowfuel {
|
||||
animation: none;
|
||||
}
|
||||
.trek-skeleton {
|
||||
animation: none;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
/* Die Zzz stehen still statt zu treiben — der Tageswechsel bleibt lesbar. */
|
||||
.trek-doze > * {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
}
|
||||
button:not(:disabled):not([data-no-press]):active,
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
|
||||
[data-press]:active {
|
||||
|
||||
@@ -261,6 +261,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
|
||||
import { Bike, Bus, Car, CarTaxiFront, Check, ChevronDown, ChevronUp, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2, X } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
@@ -70,6 +70,16 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
|
||||
// The places a driver planned between pick-up and return (#1797), the same shape the
|
||||
// desktop TransportModal edits. Without an editor here the save below would rebuild the
|
||||
// endpoint list from from/to alone and the server, which replaces every endpoint row of a
|
||||
// booking, would drop the stops a phone never showed.
|
||||
interface CarStopForm {
|
||||
location: LocationPoint | null
|
||||
time: string
|
||||
}
|
||||
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
|
||||
|
||||
// A flight is an ordered list of airports; N waypoints = N-1 legs. The origin
|
||||
// only departs, the destination only arrives, each stop does both.
|
||||
interface WaypointForm {
|
||||
@@ -154,6 +164,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
// Travelers assigned to this booking (#1517) — seeded from the editing
|
||||
// reservation on open, persisted separately after the save resolves.
|
||||
@@ -296,6 +307,17 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
setWaypoints([emptyWaypoint(), emptyWaypoint()])
|
||||
setTrainWaypoints([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
// Stops persist for every type; only a car offers an editor for them, so only a
|
||||
// car reads them back into one. The others keep passing theirs through untouched.
|
||||
setCarStops(
|
||||
src.type === 'car'
|
||||
? (src.endpoints ?? [])
|
||||
.filter(e => e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setForm({ ...EMPTY, start_day_id: transportModalDayId ?? '', end_day_id: transportModalDayId ?? '' })
|
||||
@@ -303,6 +325,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(transportModalDayId ?? ''), emptyWaypoint(transportModalDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(transportModalDayId ?? ''), emptyStationWaypoint(transportModalDayId ?? '')])
|
||||
setCarStops([])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showTransportModal])
|
||||
@@ -311,6 +334,16 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
const prefill = snap.prefill
|
||||
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const moveCarStop = (index: number, delta: number): void => {
|
||||
setCarStops(prev => {
|
||||
const to = index + delta
|
||||
if (to < 0 || to >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
;[next[index], next[to]] = [next[to], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleTraveler = (id: number) => setTravelerIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
@@ -491,7 +524,14 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
const stops = keepTransit
|
||||
// A car writes the stops the driver planned; every other type keeps passing the
|
||||
// itinerary's transfer stops through while the route is unchanged (#1065).
|
||||
const carEndpoints = form.type === 'car'
|
||||
? carStops
|
||||
.filter(s => s.location)
|
||||
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
|
||||
: []
|
||||
const stops = keepTransit && form.type !== 'car'
|
||||
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
: []
|
||||
stops.forEach((s, i) => endpoints.push({
|
||||
@@ -499,7 +539,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
||||
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
||||
}))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
carEndpoints.forEach(e => endpoints.push(e))
|
||||
const stopCount = stops.length + carEndpoints.length
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
@@ -890,6 +932,72 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.meta.to')}</Eyebrow>
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
|
||||
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
|
||||
pick-up and return; these are the places in between, in order. */}
|
||||
{form.type === 'car' && (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('roadtrip.stops.label')}</Eyebrow>
|
||||
<div className="flex flex-col gap-2">
|
||||
{carStops.map((stop, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
{/* The order of these stops IS the route: `sequence` is the array
|
||||
index at save time. Arrows rather than dragging, because the
|
||||
row already carries a location picker and a time picker. */}
|
||||
{carStops.length > 1 && (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, 1)}
|
||||
disabled={i === carStops.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<LocationSelect
|
||||
value={stop.location}
|
||||
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[92px] shrink-0">
|
||||
<CustomTimePicker
|
||||
value={stop.time}
|
||||
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
|
||||
aria-label={t('roadtrip.stops.remove')}
|
||||
className="flex shrink-0 items-center p-1 text-m-muted"
|
||||
>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
|
||||
className="flex w-full items-center justify-center gap-[5px] rounded-full border-[1.5px] border-dashed border-[color:var(--m-rowbr)] py-2 font-geist text-[0.6875rem] font-semibold text-m-muted"
|
||||
>
|
||||
<Plus size={12} strokeWidth={2.2} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Departure row */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
@@ -26,10 +27,24 @@ interface Suggestion {
|
||||
placeId: string
|
||||
mainText: string
|
||||
secondaryText: string
|
||||
/** Which of the two indexes this row came from, when the list is both. */
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
|
||||
/** The same mark the desktop form shows, in the sheet's own tokens. */
|
||||
function SourceMark({ label }: { label: string | null }) {
|
||||
if (!label) return null
|
||||
return (
|
||||
<span className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 font-geist text-[0.5625rem] font-medium text-m-muted">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlPlaceSearchProps {
|
||||
planner: TripPlanner
|
||||
/** Search bias derived from the trip's existing places (trip centre). */
|
||||
@@ -76,6 +91,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
// The name the whole list carries, for rows that do not name their own index.
|
||||
const [acSource, setAcSource] = useState('')
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -93,6 +110,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query: input, source: result.source || 'unknown' }
|
||||
setAcSource(result.source || '')
|
||||
setSuggestions(result.suggestions || [])
|
||||
} catch (err: unknown) {
|
||||
// Superseded request — axios rejects an aborted call with CanceledError.
|
||||
@@ -211,6 +229,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
} catch {
|
||||
// fall through to text search
|
||||
}
|
||||
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
|
||||
// The layer's second line is the local name, not an address, so joining
|
||||
// the two makes a query nobody typed — and its first answer would be
|
||||
// silently taken as the place the user picked. The row already knows
|
||||
// where it is.
|
||||
place = {
|
||||
name: suggestion.mainText,
|
||||
address: '',
|
||||
lat: suggestion.lat,
|
||||
lng: suggestion.lng,
|
||||
osm_id: suggestion.placeId,
|
||||
source: 'openstreetmap',
|
||||
}
|
||||
}
|
||||
if (!place) {
|
||||
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias))
|
||||
@@ -269,10 +301,15 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
onClick={() => handleSelectSuggestion(s)}
|
||||
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
|
||||
>
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceMark label={sourceLabelFor(s, acSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1352,4 +1352,102 @@ describe('SharedTripPage', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2320: the richer read-only detail ─────────────────────────────────
|
||||
|
||||
describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => {
|
||||
const days = [{ id: 21, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }];
|
||||
const assignments = {
|
||||
21: [
|
||||
{
|
||||
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
|
||||
place: {
|
||||
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null,
|
||||
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('renders every field the owner filled, as separate lines', async () => {
|
||||
await open('detail-token', payload({ days, assignments }));
|
||||
fireEvent.click(screen.getByText('Day One'));
|
||||
// The name is also on the map marker's tooltip, so the list row is the one with the address under it.
|
||||
await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument());
|
||||
expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByText('The big one')).toBeInTheDocument();
|
||||
expect(screen.getByText('go early, before the coaches')).toBeInTheDocument();
|
||||
expect(screen.getByText('Skip the pyramid queue')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 h 30 min')).toBeInTheDocument();
|
||||
|
||||
const website = screen.getByRole('link', { name: /website/i });
|
||||
expect(website).toHaveAttribute('href', 'https://louvre.fr');
|
||||
expect(website).toHaveAttribute('target', '_blank');
|
||||
expect(website).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
const maps = screen.getByRole('link', { name: /google maps/i });
|
||||
expect(maps.getAttribute('href')).toContain('google.com/maps/search/');
|
||||
expect(maps.getAttribute('href')).toContain(encodeURIComponent('Louvre, Rue de Rivoli, Paris'));
|
||||
expect(maps).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
const phone = screen.getByRole('link', { name: '+33 1 40 20 50 50' });
|
||||
expect(phone).toHaveAttribute('href', 'tel:+33140205050');
|
||||
});
|
||||
|
||||
it('leaves out what is empty and never renders a link that is not http(s)', async () => {
|
||||
const bare = {
|
||||
21: [{
|
||||
id: 302, day_id: 21, order_index: 0, notes: null,
|
||||
place: {
|
||||
id: 502, name: 'Somewhere', description: null, address: null, lat: null, lng: null,
|
||||
duration_minutes: 0, notes: ' ', website: 'javascript:alert(1)', phone: null,
|
||||
category_id: null, price: null, place_time: null, end_time: null, image_url: null,
|
||||
transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
}],
|
||||
};
|
||||
await open('bare-token', payload({ days, assignments: bare }));
|
||||
fireEvent.click(screen.getByText('Day One'));
|
||||
await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument());
|
||||
|
||||
expect(screen.queryByRole('link', { name: /website/i })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: /google maps/i })).toBeNull();
|
||||
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
expect(screen.queryByText(/min$/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
|
||||
it('renders the note and a link labelled by its host', async () => {
|
||||
await open('booking-token', payload({
|
||||
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
|
||||
reservations: [
|
||||
{
|
||||
id: 91, title: 'Night train', type: 'train', status: 'confirmed', day_id: null, end_day_id: null,
|
||||
reservation_time: '2026-07-01T21:00:00', reservation_end_time: null, metadata: null,
|
||||
notes: 'Meet at the north entrance', url: 'https://www.bahn.example/booking/abc',
|
||||
},
|
||||
{
|
||||
id: 92, title: 'Sketchy', type: 'other', status: 'pending', day_id: null, end_day_id: null,
|
||||
reservation_time: null, reservation_end_time: null, metadata: null,
|
||||
notes: null, url: 'javascript:alert(1)',
|
||||
},
|
||||
],
|
||||
}));
|
||||
fireEvent.click(screen.getByText('Bookings'));
|
||||
await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'bahn.example' });
|
||||
expect(link).toHaveAttribute('href', 'https://www.bahn.example/booking/abc');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
expect(screen.getByText('Sketchy')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
|
||||
import { resolveBasemap } from '../utils/tileUrl';
|
||||
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
||||
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
|
||||
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
|
||||
|
||||
@@ -738,7 +740,7 @@ export default function SharedTripPage() {
|
||||
key={`p-${item.data.id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
@@ -773,19 +775,7 @@ export default function SharedTripPage() {
|
||||
>
|
||||
{place.name}
|
||||
</div>
|
||||
{(place.address || place.description) && (
|
||||
<div
|
||||
className="text-[#9ca3af]"
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{place.address || place.description}
|
||||
</div>
|
||||
)}
|
||||
<SharedPlaceDetails place={place} assignmentNotes={item.data.notes} />
|
||||
</div>
|
||||
{place.place_time && (
|
||||
<span
|
||||
@@ -834,7 +824,7 @@ export default function SharedTripPage() {
|
||||
<div
|
||||
key={r.id}
|
||||
className="border border-edge-faint bg-surface-card"
|
||||
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}
|
||||
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'flex-start', gap: 12 }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f3f4f6]"
|
||||
@@ -900,6 +890,7 @@ export default function SharedTripPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SharedBookingDetails notes={r.notes} url={r.url} />
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -48,6 +48,7 @@ import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, T
|
||||
import { ListTodo, Download, Plus, Trash2, FolderPlus } from 'lucide-react'
|
||||
import { useTripPlanner } from './tripPlanner/useTripPlanner'
|
||||
import { usePoiExplore } from '../components/Map/usePoiExplore'
|
||||
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
|
||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
|
||||
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
|
||||
|
||||
@@ -252,7 +253,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
@@ -266,9 +267,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
@@ -322,7 +324,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
||||
// The corridor search draws into the same map channel and answers the same question for
|
||||
// a drive, so the explore pill stands down while road trip mode is on.
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
|
||||
// Also in road trip mode: searching the view is a different question from searching the
|
||||
// drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the
|
||||
// two answers are drawn side by side rather than one hiding the other.
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
// The refuel offers ride the same channel: in road trip mode this is the only way a
|
||||
// POI reaches the map, so without them somebody is asked to accept a stop they cannot
|
||||
// see. They vanish with the offer rather than lingering as a search result.
|
||||
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered)
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
@@ -397,10 +406,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={mapPlaces}
|
||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripRoutes.lines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={overviewActive ? tripOverview.lineColors : undefined}
|
||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
|
||||
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
@@ -429,12 +438,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={roadtripActive
|
||||
/* `visible`, not `search.results`: the map is the picture of this very
|
||||
list, and filtering the list while seventy pins stay on the map no
|
||||
longer answers "which of these". */
|
||||
? roadtripCorridor.visible
|
||||
: poi.pois}
|
||||
/* In road trip mode the corridor's `visible` (not `search.results`: the map is
|
||||
the picture of that very list, and filtering the list while seventy pins stay
|
||||
on the map no longer answers "which of these") plus whatever the category
|
||||
pill found in view. Outside it, only the pill's hits. */
|
||||
pois={mapPois}
|
||||
onPoiClick={handlePoiClick}
|
||||
// Only while road trip mode is on: outside it there is no drive to drop onto.
|
||||
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
|
||||
@@ -443,7 +451,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={overviewActive ? tripOverview.focusPoints : alternativeFocusPoints}
|
||||
focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
@@ -568,9 +576,15 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
|
||||
onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined}
|
||||
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
|
||||
viaCounts={roadtripViaCounts}
|
||||
trackNames={followTrack.namesByDay}
|
||||
refuel={refuel}
|
||||
onAskRefuel={askRefuel}
|
||||
onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined}
|
||||
collapsedDayIds={collapsedRoadtripDays}
|
||||
onToggleDay={toggleRoadtripDay}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -707,6 +721,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
accommodations={tripAccommodations}
|
||||
selectedDayId={selectedDayId}
|
||||
onClearSelectedDay={() => handleSelectDay(null)}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true) }}
|
||||
@@ -874,7 +889,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,6 +150,9 @@ function baseState(): HookState {
|
||||
roadtripActive: false,
|
||||
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
|
||||
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
|
||||
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
|
||||
askRefuel: vi.fn(),
|
||||
acceptRefuel: vi.fn(),
|
||||
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import { isHttpUrl, linkHost } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* The note and the link on a shared booking (#2320).
|
||||
*
|
||||
* A booking on a public link used to be its title, its time and its status.
|
||||
* The note the owner attached ("meet at the north entrance") and the booking
|
||||
* page they linked are what a fellow traveller opens the link for, so both
|
||||
* show now. What does not show is anything that would let a stranger touch
|
||||
* the booking: the server keeps the confirmation number and the ticket data
|
||||
* back, and this block only knows the two fields it is handed.
|
||||
*
|
||||
* The link opens in a new tab with no opener, and only when it is http(s) —
|
||||
* checked here as well as on the server, so the page never trusts a payload
|
||||
* to have done it.
|
||||
*/
|
||||
export function SharedBookingDetails({ notes, url }: { notes?: string | null; url?: string | null }) {
|
||||
const note = notes?.trim() || null
|
||||
const link = isHttpUrl(url) ? url.trim() : null
|
||||
if (!note && !link) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
|
||||
{note && (
|
||||
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{note}</span>
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#2563eb]" // theme-lint-disable — public page, no user theme
|
||||
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none', width: 'fit-content' }}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{linkHost(link)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* The read-only detail of one stop on a shared plan (#2320).
|
||||
*
|
||||
* The page used to show a place as a name and, under it, the address *or* the
|
||||
* description, one string on one line. The owner had written both, and had
|
||||
* usually also left a note on the place and another on the day it was
|
||||
* planned for, and a link would show none of that. This shows what the owner
|
||||
* chose to share, laid out the way the planner lays it out: address, then
|
||||
* description, then the two notes, then how long they meant to stay, then
|
||||
* the ways to reach the place.
|
||||
*
|
||||
* Nothing here is editable and nothing here is a route into the app: every
|
||||
* link leaves the page, in a new tab, with no opener. The server has already
|
||||
* decided what a public viewer may see; this only renders what arrived.
|
||||
*/
|
||||
export interface SharedPlaceLike {
|
||||
name: string
|
||||
address?: string | null
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
duration_minutes?: number | null
|
||||
website?: string | null
|
||||
phone?: string | null
|
||||
place_time?: string | null
|
||||
end_time?: string | null
|
||||
}
|
||||
|
||||
export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPlaceLike; assignmentNotes?: string | null }) {
|
||||
const { t } = useTranslation()
|
||||
const website = isHttpUrl(place.website) ? place.website : null
|
||||
const phone = place.phone?.trim() || null
|
||||
const mapsUrl = getGoogleMapsUrlForPlace({
|
||||
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
|
||||
google_place_id: null, google_ftid: null,
|
||||
})
|
||||
const duration = formatDurationMinutes(place.duration_minutes)
|
||||
const dayNote = assignmentNotes?.trim() || null
|
||||
const placeNote = place.notes?.trim() || null
|
||||
const hasLinks = !!(website || phone || mapsUrl)
|
||||
|
||||
return (
|
||||
<div className="shared-place-details" style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
||||
{place.address && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', alignItems: 'flex-start', gap: 4 }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<MapPin size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="text-[#4b5563]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
{place.description}
|
||||
</div>
|
||||
)}
|
||||
{dayNote && (
|
||||
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.assignmentNotes')}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{dayNote}</span>
|
||||
</div>
|
||||
)}
|
||||
{placeNote && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.formNotes')}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{placeNote}</span>
|
||||
</div>
|
||||
)}
|
||||
{(duration || hasLinks) && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '2px 10px', marginTop: 1 }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
{duration && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
<Clock size={10} />
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
{mapsUrl && (
|
||||
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<ExternalLink size={10} />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</a>
|
||||
)}
|
||||
{website && (
|
||||
<a href={website} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<Globe size={10} />
|
||||
{t('places.formWebsite')}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
<a href={`tel:${phone.replace(/[^+\d]/g, '')}`} className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<Phone size={10} />
|
||||
{phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel'
|
||||
|
||||
describe('sharedTripModel (#2320)', () => {
|
||||
it('accepts http and https and nothing else', () => {
|
||||
expect(isHttpUrl('https://a.example/x')).toBe(true)
|
||||
expect(isHttpUrl(' http://a.example ')).toBe(true)
|
||||
expect(isHttpUrl('javascript:alert(1)')).toBe(false)
|
||||
expect(isHttpUrl('data:text/html,hi')).toBe(false)
|
||||
expect(isHttpUrl('ftp://a.example')).toBe(false)
|
||||
expect(isHttpUrl('not a url')).toBe(false)
|
||||
expect(isHttpUrl('')).toBe(false)
|
||||
expect(isHttpUrl(null)).toBe(false)
|
||||
expect(isHttpUrl(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('writes a planned stay as minutes, hours, or both', () => {
|
||||
expect(formatDurationMinutes(45)).toBe('45 min')
|
||||
expect(formatDurationMinutes(60)).toBe('1 h')
|
||||
expect(formatDurationMinutes(150)).toBe('2 h 30 min')
|
||||
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
|
||||
})
|
||||
|
||||
it('writes nothing for a missing, zero or nonsense figure', () => {
|
||||
expect(formatDurationMinutes(null)).toBeNull()
|
||||
expect(formatDurationMinutes(undefined)).toBeNull()
|
||||
expect(formatDurationMinutes(0)).toBeNull()
|
||||
expect(formatDurationMinutes(-5)).toBeNull()
|
||||
expect(formatDurationMinutes(Number.NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a link by its host, without the www', () => {
|
||||
expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example')
|
||||
expect(linkHost('https://booking.example')).toBe('booking.example')
|
||||
expect(linkHost('nonsense')).toBe('nonsense')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Pure helpers for the public shared-trip page (#2320). React-free, so the
|
||||
* page and its detail blocks can share them and a test can drive them
|
||||
* without rendering.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a string is a link the page may render as one.
|
||||
*
|
||||
* The server already drops anything that is not http(s) (share.service.ts),
|
||||
* so this is the second gate rather than the first — the page must not become
|
||||
* the place where a `javascript:` value turns into an anchor because a future
|
||||
* payload forgot to filter it.
|
||||
*/
|
||||
export function isHttpUrl(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || !value.trim()) return false
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "45 min", "2 h", "1 h 30 min" — the planned time at a place.
|
||||
*
|
||||
* Nothing for a missing, zero or negative figure: the planner's default of an
|
||||
* hour is a default, not a plan, and the field stores what the owner typed.
|
||||
*/
|
||||
export function formatDurationMinutes(minutes: number | null | undefined): string | null {
|
||||
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return null
|
||||
const whole = Math.round(minutes)
|
||||
const h = Math.floor(whole / 60)
|
||||
const m = whole % 60
|
||||
if (h === 0) return `${m} min`
|
||||
if (m === 0) return `${h} h`
|
||||
return `${h} h ${m} min`
|
||||
}
|
||||
|
||||
/** The hostname a booking link points at, for a label that says where it goes. */
|
||||
export function linkHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-056
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-074
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi,
|
||||
} from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { dayColor } from '../../components/Roadtrip/dayColors'
|
||||
|
||||
/**
|
||||
* The road trip half of the planner hook.
|
||||
@@ -89,6 +90,7 @@ const rt = vi.hoisted(() => {
|
||||
days: [] as Array<Record<string, unknown>>,
|
||||
quietDays: [] as unknown[],
|
||||
lines: [] as unknown[],
|
||||
lineDays: [] as number[],
|
||||
accessLines: [] as unknown[],
|
||||
vias: [] as unknown[],
|
||||
segments: [] as unknown[],
|
||||
@@ -271,6 +273,8 @@ beforeEach(() => {
|
||||
rt.vias.stale = false
|
||||
rt.vias.editable = true
|
||||
rt.routes.days = []
|
||||
rt.routes.lines = []
|
||||
rt.routes.lineDays = []
|
||||
rt.corridor.day = undefined
|
||||
rt.corridor.visible = []
|
||||
rt.corridor.widthKm = 5
|
||||
@@ -1060,13 +1064,65 @@ describe('useTripPlanner road trip: shaping the drive on the map', () => {
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) })
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7)
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7, 0)
|
||||
|
||||
rt.vias.move.mockRejectedValue(new Error('stale'))
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) })
|
||||
expect(toasts.some(t => t.type === 'error' && t.message === 'stale')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-053: a via dragged past a stop is re-pinned to the leg it landed on', async () => {
|
||||
// The bug this exists for. A drag used to send only the new coordinates, so a via
|
||||
// pulled beyond the stop it used to precede kept claiming the earlier leg: the route
|
||||
// ran out to the point and back before carrying on, which reads as the drag doing
|
||||
// nothing at all. Three stops so there are two legs to land between.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
stops: [{ lat: 53.55, lng: 9.99 }, { lat: 53.85, lng: 11.45 }, { lat: 52.52, lng: 13.4 }],
|
||||
geometry: [[53.55, 9.99], [53.87, 10.7], [53.85, 11.45], [53.87, 11.53], [52.52, 13.4]],
|
||||
}]
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// Dropped on the first leg, then dragged past the middle stop onto the second.
|
||||
await act(async () => { await result.current.addRoadtripVia(53.87, 10.7) })
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.87, 10.7)
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 9, 53.87, 11.53) })
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 53.87, 11.53, 1)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-054: a drag is measured against its own day, however close another one runs', async () => {
|
||||
// Placing a via lets the nearest day win, which is right for a click on the map.
|
||||
// A drag is not that: the via already belongs to a day, and handing it to a
|
||||
// neighbouring day whose road happens to pass closer would make it vanish from the
|
||||
// one it was dragged in.
|
||||
twoRoutedDays()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// Right on day 6's line, but dragged within day 5.
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 47.9, 12.3) })
|
||||
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 47.9, 12.3, expect.any(Number))
|
||||
// Day 6 never saw it.
|
||||
expect(rt.vias.move).not.toHaveBeenCalledWith(6, expect.anything(), expect.anything(), expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-055: a drag far off the road still moves, without an anchor to offer', async () => {
|
||||
// No distance guard on a drag, unlike on a click: pulling a via well away from the
|
||||
// current road is the whole point of the gesture. A day with no line to measure
|
||||
// against simply sends no anchor, and the existing pin stays as it was.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{ dayId: 5, stops: [], geometry: [] }]
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 41.9, 12.5) })
|
||||
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 41.9, 12.5, undefined)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-052: removing a via lets the drive take the direct road again', async () => {
|
||||
twoRoutedDays()
|
||||
const { result } = await renderRoadtrip()
|
||||
@@ -1137,3 +1193,346 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => {
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */
|
||||
const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({
|
||||
placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex,
|
||||
})
|
||||
|
||||
/** Four drawn legs: two on the first day, one each on the two after it. */
|
||||
const LINES: [number, number][][] = [
|
||||
[[53.55, 9.99], [53.2, 10.7]],
|
||||
[[53.2, 10.7], [52.52, 13.4]],
|
||||
[[52.52, 13.4], [50.1, 12.0]],
|
||||
[[50.1, 12.0], [48.13, 11.58]],
|
||||
]
|
||||
|
||||
/** Per-day colours on or off, without dropping the defaults the providers read. */
|
||||
function setDayColors(on: boolean) {
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, roadtrip_day_colors: on } })
|
||||
}
|
||||
|
||||
describe('useTripPlanner road trip: folding a day off the map', () => {
|
||||
/**
|
||||
* Three cards, and two of the places drawn on two cards each: the stop a day ends on is
|
||||
* the stop the next one sets off from. That overlap is what the second pass below is
|
||||
* about, and a fixture without it would let a one-pass answer through.
|
||||
*/
|
||||
const threeCards = () => {
|
||||
seedTrip({
|
||||
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 }), buildDay({ id: 7, day_number: 3 })],
|
||||
places: [
|
||||
buildPlace({ id: 1101, lat: 53.55, lng: 9.99 }),
|
||||
buildPlace({ id: 1102, lat: 52.52, lng: 13.4 }),
|
||||
buildPlace({ id: 1103, lat: 50.1, lng: 12.0 }),
|
||||
buildPlace({ id: 1104, lat: 48.13, lng: 11.58 }),
|
||||
],
|
||||
})
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [
|
||||
{ dayId: 5, dayNumber: 1, stops: [drawn(1101, 53.55, 9.99, 5, 0), drawn(1102, 52.52, 13.4, 5, 1)], geometry: LINES[0] },
|
||||
{ dayId: 6, dayNumber: 2, stops: [drawn(1102, 52.52, 13.4, 6, 0), drawn(1103, 50.1, 12.0, 6, 1)], geometry: LINES[2] },
|
||||
{ dayId: 7, dayNumber: 3, stops: [drawn(1103, 50.1, 12.0, 7, 0), drawn(1104, 48.13, 11.58, 7, 1)], geometry: LINES[3] },
|
||||
]
|
||||
rt.routes.lines = LINES
|
||||
rt.routes.lineDays = [1, 1, 2, 3]
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-060: folding a day folds that one alone, and folding it again brings it back', async () => {
|
||||
// One set for the whole rail, so a header that wrote its own answer over it instead
|
||||
// of editing a copy would fold one card and unfold every other in the same click.
|
||||
threeCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(6) })
|
||||
act(() => { result.current.toggleRoadtripDay(7) })
|
||||
expect([...result.current.collapsedRoadtripDays]).toEqual([6, 7])
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(6) })
|
||||
expect([...result.current.collapsedRoadtripDays]).toEqual([7])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-061: a folded card takes ITS lines off the map and leaves the rest drawn', async () => {
|
||||
// The fold is keyed by day id and the lines are labelled by day number. They are
|
||||
// different numbers on this trip, as on any trip whose days were not created in
|
||||
// order, so a filter built from the wrong one quietly hides nothing at all.
|
||||
threeCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
expect(result.current.roadtripMapLines).toHaveLength(4)
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(6) })
|
||||
|
||||
expect(result.current.roadtripMapLines).toEqual([LINES[0], LINES[1], LINES[3]])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-062: the colours drop with the lines, so what is left still lines up', async () => {
|
||||
// The map reads the two lists side by side. Leaving a folded day colour in shifts
|
||||
// every colour after it onto the wrong road, which reads as the days having moved.
|
||||
threeCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
// Off is an absent list rather than a list of blues: a trip that never turns this on
|
||||
// is handed nothing and the map paints what it always painted.
|
||||
expect(result.current.roadtripLineColors).toBeUndefined()
|
||||
|
||||
act(() => { setDayColors(true) })
|
||||
act(() => { result.current.toggleRoadtripDay(6) })
|
||||
|
||||
expect(result.current.roadtripLineColors).toEqual([dayColor(1), dayColor(1), dayColor(3)])
|
||||
expect(result.current.roadtripLineColors).toHaveLength(result.current.roadtripMapLines.length)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-063: a folded card takes its own stops off the map with it', async () => {
|
||||
threeCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(5) })
|
||||
|
||||
expect(result.current.roadtripMapPlaces.map(p => p.id)).not.toContain(1101)
|
||||
// The fold and nothing else: the planner own map still holds it, so a filter that
|
||||
// dropped the place for some unrelated reason would show up right here.
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toContain(1101)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-064: a stop another card still draws stays, and goes only when that card folds too', async () => {
|
||||
// The second pass is the whole rule. The place a day ends on is the place the next
|
||||
// day sets off from, so folding one card must not rub it off the other.
|
||||
threeCards()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(5) })
|
||||
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1102, 1103, 1104])
|
||||
|
||||
act(() => { result.current.toggleRoadtripDay(6) })
|
||||
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1103, 1104])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: how full one stop fills up', () => {
|
||||
it('FE-TP-ROAD-065: the fill is one field on one place, and handing it back sends null rather than nothing', async () => {
|
||||
// null is not "leave it alone", it is "follow my own setting again" — the only way
|
||||
// back from a figure typed on a single stop. Left out of the payload the update folds
|
||||
// it into "unchanged" and the stop keeps the number for good.
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })], assignments: { '5': [stopAt(11, 5, 0)] } })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.setRoadtripStopFill(1011, 80) })
|
||||
expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { fill_percent: 80 })
|
||||
|
||||
actions.updatePlace.mockRejectedValue(new Error('read only'))
|
||||
await act(async () => { await result.current.setRoadtripStopFill(1011, null) })
|
||||
expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 1011, { fill_percent: null })
|
||||
expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a card that opens with yesterday stop', () => {
|
||||
/**
|
||||
* One card, day 6, whose drive begins with a stop stored on day 5: the night drive
|
||||
* arrived after midnight, so the rail draws that stop here while the server still files
|
||||
* it under the day it set off from.
|
||||
*/
|
||||
const spilledCard = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
|
||||
rt.corridor.day = { dayId: 6, dayNumber: 2 }
|
||||
rt.routes.days = [{
|
||||
dayId: 6,
|
||||
dayNumber: 2,
|
||||
stops: [drawn(1102, 53.0, 11.5, 5, 1), drawn(1103, 52.52, 13.4, 6, 0)],
|
||||
geometry: [[53.55, 9.99], [53.0, 11.5], [52.8, 12.4], [52.52, 13.4]],
|
||||
}]
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-066: a via is filed under the day its anchor stop is STORED on, not the card it is drawn on', async () => {
|
||||
// Written with the card own numbers the via matches no stop the next time the day is
|
||||
// routed: the road springs back to where it was and the gesture reads as having done
|
||||
// nothing at all.
|
||||
spilledCard()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(52.8, 12.4) })
|
||||
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 52.8, 12.4)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-072: a via dropped on the incoming night drive follows the stop it left from', async () => {
|
||||
// The stretch between the card first drawn point and its first stop is last night
|
||||
// driving: it is drawn here, but it leaves from a stop on the card before this one.
|
||||
// Anchored to this card first stop instead, the via would be filed on the leg AFTER
|
||||
// that stop, and the route would run forward, double back to the point and carry on.
|
||||
//
|
||||
// The rule used to be asked of the insert index, which cannot answer it:
|
||||
// insertIndexForAlong clamps to at least 1 for any card with two stops or more, and
|
||||
// the rail publishes no other kind. The guard read correctly and never ran.
|
||||
spilledCard()
|
||||
rt.routes.days[0].spills = [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
// The stop the night drive left from: FIRST on day 5, while the first stop drawn
|
||||
// on this card is the second one, reached after midnight. Different numbers on
|
||||
// purpose, or the two paths through the lookup would answer alike and this case
|
||||
// would pass whichever one ran.
|
||||
fromStop: drawn(1101, 53.55, 9.99, 5, 0),
|
||||
}]
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// Half way along the night stretch, well before the first stop drawn on this card.
|
||||
await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
|
||||
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.3, 10.7)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-073: a point before the first stop of an ordinary card still takes that stop', async () => {
|
||||
// The other half of the same rule, and the reason it is asked of the spill rather
|
||||
// than of the distance alone: a card that received no night drive has nothing before
|
||||
// its first stop but its own first leg, so a point there belongs on that leg.
|
||||
spilledCard()
|
||||
rt.routes.days[0].spills = []
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
|
||||
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 53.3, 10.7)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-067: a drag keeps to the day the via is stored on, wherever that day stops are drawn', async () => {
|
||||
// Not "the card with that id". After a night drive the stops of day 5 are drawn on
|
||||
// card 6, and measuring the drag against card 5 alone leaves it with no anchor to
|
||||
// offer — which is exactly the anchor it needed.
|
||||
spilledCard()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 9, 52.8, 12.4) })
|
||||
expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 52.8, 12.4, 1)
|
||||
|
||||
// The same rule the other way round: the stop nearest that point belongs to day 5, so
|
||||
// a via of day 6 takes no anchor from it and the pin it already has stands.
|
||||
await act(async () => { await result.current.moveRoadtripVia(6, 9, 52.8, 12.4) })
|
||||
expect(rt.vias.move).toHaveBeenLastCalledWith(6, 9, 52.8, 12.4, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
/**
|
||||
* A day driven straight down the tenth meridian, so a kilometre along the road is a
|
||||
* kilometre anybody can check: a degree of latitude is 111 km and nothing here bends.
|
||||
* Its first two stops are stored on the day before, the night drive having arrived
|
||||
* after midnight, which is what the accepting case turns on.
|
||||
*/
|
||||
const drivenCard = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
|
||||
rt.corridor.day = { dayId: 6, dayNumber: 2 }
|
||||
rt.routes.days = [{
|
||||
dayId: 6,
|
||||
dayNumber: 2,
|
||||
stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)],
|
||||
geometry: [[53, 10], [52, 10], [51, 10]],
|
||||
drivingGeometry: [[53, 10], [52, 10], [51, 10]],
|
||||
}]
|
||||
}
|
||||
|
||||
/** Where the tank runs out: 190 km into a 222 km day, on the second leg. */
|
||||
const dry = { legIndex: 1, intoLegKm: 79, drivenMeters: 190_000, sinceKm: 500, lat: 51.29, lng: 10 }
|
||||
|
||||
const pump = (name: string, lat: number) => ({
|
||||
osm_id: `node/${name}`, name, lat, lng: 10, category: 'fuel', poi_type: 'fuel',
|
||||
address: null, website: null, phone: null, opening_hours: null, cuisine: null,
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-068: the day own stops go with the question, so a pump already on the plan is not offered again', async () => {
|
||||
drivenCard()
|
||||
const pois = vi.spyOn(mapsApi, 'pois').mockResolvedValue({
|
||||
pois: [pump('Rasthof Dammer Berge', 52.5), pump('Schon geplant', 52)],
|
||||
source: 'openstreetmap',
|
||||
truncated: false,
|
||||
} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// A day the rail draws no drive for has no line to measure along, and asking anyway
|
||||
// spends a request on a question that cannot be answered.
|
||||
act(() => { result.current.askRefuel(999, dry) })
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => { result.current.askRefuel(6, dry) })
|
||||
await waitFor(() => expect(result.current.refuel.outcome).toBe('found'))
|
||||
|
||||
expect(pois).toHaveBeenCalledTimes(1)
|
||||
// The second one stands on the day own middle stop, and both are within range of the
|
||||
// dry point — so only the day stops travelling with the question keep it out.
|
||||
expect(result.current.refuel.results.map(r => r.name)).toEqual(['Rasthof Dammer Berge'])
|
||||
// Which dry point is being answered, so a second warning cannot read the first list.
|
||||
expect(result.current.refuel.openFor).toBe('6:1')
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-069: while offers are on the table the map frames THEM, not the alternatives under them', async () => {
|
||||
// Somebody is being asked to accept a stop, and a stop off the edge of the map cannot
|
||||
// be judged. Averaging the two frames would have shown neither properly.
|
||||
drivenCard()
|
||||
rt.alt.open = {
|
||||
dayId: 6, index: 0, loading: false, error: false,
|
||||
// Two of them, because one road is not a choice and the picker draws nothing for it.
|
||||
routes: [
|
||||
{ coordinates: [[53, 10], [51, 10]], distance: 222_000, duration: 8_400, divergence: { lat: 52, lng: 10 } },
|
||||
{ coordinates: [[53, 10], [52.4, 10.6], [51, 10]], distance: 240_000, duration: 9_000, divergence: { lat: 52.4, lng: 10.6 } },
|
||||
],
|
||||
}
|
||||
vi.spyOn(mapsApi, 'pois').mockResolvedValue({
|
||||
pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
|
||||
} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
expect(result.current.mapFocusPoints).toEqual(result.current.alternativeFocusPoints)
|
||||
expect(result.current.mapFocusPoints.length).toBeGreaterThan(0)
|
||||
|
||||
await act(async () => { result.current.askRefuel(6, dry) })
|
||||
await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
|
||||
|
||||
expect(result.current.mapFocusPoints).toEqual([[52.5, 10]])
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-070: accepting one closes the offers and opens the popup at the stop OWN day and position', async () => {
|
||||
// The index the offer was measured at counts along the card, and this card begins
|
||||
// with two stops stored on the day before. Written with the card numbers the new stop
|
||||
// lands in the wrong day list, at a position that means something else there.
|
||||
drivenCard()
|
||||
vi.spyOn(mapsApi, 'pois').mockResolvedValue({
|
||||
pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
|
||||
} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { result.current.askRefuel(6, dry) })
|
||||
await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
|
||||
|
||||
// Dry on the first leg, so the stop goes in front of the one it was measured against:
|
||||
// the card second, which day 5 holds at position 2.
|
||||
act(() => { result.current.acceptRefuel(6, result.current.refuel.offered[0], { ...dry, legIndex: 0 }) })
|
||||
|
||||
expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 2 })
|
||||
expect(result.current.refuel.openFor).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-074: a reader changing a stop from the rail writes nothing at all', async () => {
|
||||
// The two one-field writes the rail offers used to go straight to the API while their
|
||||
// neighbour setRoadtripStay checked first. The server refused them, so nothing was
|
||||
// ever saved, but the reader got an error toast for touching a control that should
|
||||
// not have acted — and the rail redrew off a store the write never reached.
|
||||
asReader('place_edit')
|
||||
drivenCard()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.setRoadtripStopKind(1102, 'fuel') })
|
||||
await act(async () => { await result.current.setRoadtripStopFill(1102, 80) })
|
||||
|
||||
expect(actions.updatePlace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-071: a reader is not handed the stop to accept', async () => {
|
||||
asReader('day_edit')
|
||||
drivenCard()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.acceptRefuel(6, pump('Rasthof Dammer Berge', 52.5) as never, dry) })
|
||||
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { dayColor } from '../../components/Roadtrip/dayColors'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
@@ -25,6 +26,8 @@ import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
|
||||
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
@@ -35,8 +38,7 @@ import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder,
|
||||
} from '../../components/Roadtrip/roadtripModel'
|
||||
reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
@@ -555,6 +557,7 @@ export function useTripPlanner() {
|
||||
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
||||
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
|
||||
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
|
||||
const refuel = useRefuelSearch()
|
||||
const roadtripRoutes = useRoadtripRoutes(
|
||||
tripId,
|
||||
roadtripActive ? days : EMPTY_DAYS,
|
||||
@@ -788,7 +791,82 @@ export function useTripPlanner() {
|
||||
* per-user preferences on the settings table, the same path the map provider and the
|
||||
* distance unit take, and they are read locally the moment they change.
|
||||
*/
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number) => {
|
||||
/**
|
||||
* A colour per drawn line, or none at all.
|
||||
*
|
||||
* Off is not "all the same colour" but an absent array: the map then paints the blue it
|
||||
* has always painted, so a trip that never turns this on renders byte for byte the way
|
||||
* it did.
|
||||
*/
|
||||
/**
|
||||
* Days folded down to their header in the road trip rail.
|
||||
*
|
||||
* Its own state rather than the day plan's `expandedDayIds`: that one is owned by
|
||||
* `DayPlanSidebar`, which republishes it from its own state every time it mounts — so a
|
||||
* day folded in the rail would spring back open the moment the other view was visited.
|
||||
*/
|
||||
const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState<Set<number>>(new Set())
|
||||
const toggleRoadtripDay = useCallback((dayId: number) => {
|
||||
setCollapsedRoadtripDays(prev => {
|
||||
const next = new Set(prev)
|
||||
if (!next.delete(dayId)) next.add(dayId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* The road trip's lines, minus the days that are folded away.
|
||||
*
|
||||
* Folding a card takes that day off the map, which is most of what folding is for: a
|
||||
* rail entry can be scrolled past, a line across the map cannot be looked away from.
|
||||
* `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours
|
||||
* stay lined up with what is left.
|
||||
*/
|
||||
const roadtripMapLines = useMemo(() => {
|
||||
if (!collapsedRoadtripDays.size) return roadtripRoutes.lines
|
||||
const hidden = new Set(
|
||||
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
||||
)
|
||||
return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i]))
|
||||
}, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays])
|
||||
|
||||
/**
|
||||
* The map's places with the folded cards' stops taken out.
|
||||
*
|
||||
* A second pass rather than a branch inside `mapPlaces`: that memo runs long before the
|
||||
* road trip is routed, and the answer here needs the CHAINS — a card is a date, and
|
||||
* after a night drive it holds stops stored on the day before (`nightSpill.ts`), so
|
||||
* what a folded card hides is what is drawn on it, not what is filed under its day.
|
||||
*
|
||||
* A place still drawn on some other card stays, which is the rule the day plan's own
|
||||
* declutter follows.
|
||||
*/
|
||||
const roadtripMapPlaces = useMemo(() => {
|
||||
if (!collapsedRoadtripDays.size) return mapPlaces
|
||||
const hidden = new Set<number>()
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (!collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.add(stop.placeId)
|
||||
}
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.delete(stop.placeId)
|
||||
}
|
||||
return mapPlaces.filter(p => !hidden.has(p.id))
|
||||
}, [mapPlaces, roadtripRoutes.days, collapsedRoadtripDays])
|
||||
|
||||
const roadtripLineColors = useMemo(
|
||||
() => {
|
||||
if (!settings.roadtrip_day_colors) return undefined
|
||||
const hidden = new Set(
|
||||
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
||||
)
|
||||
return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n))
|
||||
},
|
||||
[settings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
||||
)
|
||||
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => {
|
||||
try {
|
||||
await updateSettings({ [key]: value } as Partial<Settings>)
|
||||
} catch {
|
||||
@@ -895,12 +973,30 @@ export function useTripPlanner() {
|
||||
* field on one place, and the rail redraws itself off the store the moment it lands.
|
||||
*/
|
||||
const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
try {
|
||||
await tripActions.updatePlace(tripId, placeId, { stop_type: kind })
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [tripId, tripActions, toast, t])
|
||||
}, [tripId, tripActions, toast, t, can, trip])
|
||||
|
||||
/**
|
||||
* How full THIS stop fills the tank, from the road trip rail.
|
||||
*
|
||||
* The same shape as the kind above and for the same reason: one field on one place,
|
||||
* with the rail redrawing off the store the moment it lands. Null hands the stop back
|
||||
* to whatever the traveller set as their own default, which is what every stop does
|
||||
* until somebody has an opinion about one.
|
||||
*/
|
||||
const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
try {
|
||||
await tripActions.updatePlace(tripId, placeId, { fill_percent: percent })
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [tripId, tripActions, toast, t, can, trip])
|
||||
|
||||
/**
|
||||
* Moves a stop within its day, from the road trip rail.
|
||||
@@ -1070,6 +1166,7 @@ export function useTripPlanner() {
|
||||
current: t('roadtrip.alt.current'),
|
||||
noMotorway: t('roadtrip.alt.noMotorway'),
|
||||
noToll: t('roadtrip.alt.noToll'),
|
||||
noFerry: t('roadtrip.alt.noFerry'),
|
||||
}),
|
||||
[routeAlternatives.open, t],
|
||||
)
|
||||
@@ -1088,6 +1185,21 @@ export function useTripPlanner() {
|
||||
[alternativeOverlays],
|
||||
)
|
||||
|
||||
/**
|
||||
* What the map should bring into view.
|
||||
*
|
||||
* Refuel offers win while they are open, and for the reason they exist at all: somebody
|
||||
* is being asked to accept a stop, and a stop off the edge of the map cannot be judged.
|
||||
* They are the newer, smaller and more specific answer, so they take the view from the
|
||||
* alternatives rather than being averaged with them into a frame that shows neither.
|
||||
*/
|
||||
const mapFocusPoints = useMemo<[number, number][]>(
|
||||
() => (refuel.offered.length
|
||||
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
|
||||
: alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
@@ -1148,7 +1260,7 @@ export function useTripPlanner() {
|
||||
const stop = day?.stops[open.index]
|
||||
if (!day || !stop) return
|
||||
try {
|
||||
await roadtripVias.add(open.dayId, day.stops.indexOf(stop), alt.divergence.lat, alt.divergence.lng)
|
||||
await roadtripVias.add(stop.ownerDayId ?? day.dayId, stop.ownerIndex ?? open.index, alt.divergence.lat, alt.divergence.lng)
|
||||
routeAlternatives.close()
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
@@ -1163,10 +1275,28 @@ export function useTripPlanner() {
|
||||
* third stop" means the same thing everywhere. The day is the one whose line was hit,
|
||||
* found by trying each day's geometry and keeping the closest.
|
||||
*/
|
||||
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
/**
|
||||
* Which stop of which day a point belongs behind, measured along the drive.
|
||||
*
|
||||
* Shared by placing a via and by dragging one, because it is the same question both
|
||||
* times and the answer has to be recomputed both times. A drag used to send only the
|
||||
* new coordinates, so a via pulled past the stop it used to precede kept claiming the
|
||||
* earlier leg: the route then ran out to the point and back before carrying on, which
|
||||
* looks exactly like a drag that did nothing.
|
||||
*/
|
||||
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
|
||||
// on, but that day's stops are no longer all on the card of the same name: after a
|
||||
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card
|
||||
// measured the new position against a line that no longer covers those stops — a
|
||||
// point dragged near Brandenburg was projected onto the short remainder of card 1
|
||||
// and came back anchored to its last stop, which put the via on the night drive
|
||||
// itself and pushed the stop before it over midnight.
|
||||
//
|
||||
// So every card is measured, and the answer is filtered by the day the ANCHOR is
|
||||
// stored on. Same promise, kept against the stops rather than against the card.
|
||||
if (day.geometry.length < 2) continue
|
||||
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
|
||||
const hit = projectOntoRoute({ lat, lng }, spine)
|
||||
@@ -1174,9 +1304,46 @@ export function useTripPlanner() {
|
||||
if (best && hit.offRouteKm >= best.offRouteKm) continue
|
||||
// Which stop the via follows: the last one the car passes before reaching it.
|
||||
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
|
||||
const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1)
|
||||
best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm }
|
||||
// Before the card's first stop means the incoming night drive, which is drawn here
|
||||
// but leaves from a stop on the card BEFORE this one (`nightSpill.ts`). Anchoring
|
||||
// it to this card's first stop would file the via on the leg AFTER that stop, and
|
||||
// the route would run forward, double back to the point, and carry on.
|
||||
//
|
||||
// Asked of the distance rather than of the index, because the index cannot answer
|
||||
// it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and
|
||||
// the rail only ever publishes cards with two stops or more. Written against the
|
||||
// index this read as a guard and behaved as dead code, so a via dropped on the
|
||||
// night stretch went to the first drawn stop after all, which is the exact failure
|
||||
// the paragraph above describes.
|
||||
const spilledIn = day.spills?.find(sp => sp.at === 0)?.fromStop
|
||||
if (spilledIn && hit.alongKm < (stopsAlong[0] ?? 0)) {
|
||||
const owner = spilledIn.ownerDayId ?? day.dayId
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: spilledIn.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
|
||||
continue
|
||||
}
|
||||
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
|
||||
// Named by the day the anchor stop is STORED on and its position there, not by the
|
||||
// card and the position within it. A card is a date and can hold stops from the day
|
||||
// before (`nightSpill.ts`), so those two numbers differ on any day that received a
|
||||
// night drive — and a via filed under the card's numbers matches no stop when the
|
||||
// route is next built, which reads as a drag that did nothing at all.
|
||||
const anchor = day.stops[at]
|
||||
if (!anchor) continue
|
||||
// Falling back to the card's own numbers is not a guard against a bug, it is the
|
||||
// meaning: a stop that names no other day IS stored on the card it is drawn on,
|
||||
// which is every stop on a trip that never drives past midnight.
|
||||
const owner = anchor.ownerDayId ?? day.dayId
|
||||
// A drag stays on its own day; a fresh click may land wherever it landed.
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm }
|
||||
}
|
||||
return best
|
||||
}, [roadtripRoutes.days])
|
||||
|
||||
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
const best = anchorFor(lat, lng)
|
||||
// A click that landed on some other line is not a via anywhere.
|
||||
if (!best || best.offRouteKm > 2) return
|
||||
try {
|
||||
@@ -1184,17 +1351,75 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [roadtripRoutes.days, roadtripVias, can, trip, toast, t])
|
||||
}, [anchorFor, roadtripVias, can, trip, toast, t])
|
||||
|
||||
/** Dragging a via redraws the route through its new position. */
|
||||
const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
// Measured against this day only. A drag is a drag WITHIN a day: letting the nearest
|
||||
// day win, the way placing one does, would hand the via to a neighbouring day whose
|
||||
// road happens to pass closer, and it would vanish from the day it was dragged in.
|
||||
//
|
||||
// No distance guard either. Dragging a via well off the current road is the whole
|
||||
// point of dragging it, and refusing that would be refusing the gesture; the anchor
|
||||
// just says which leg gets bent, and the router answers the rest.
|
||||
const anchor = anchorFor(lat, lng, dayId)
|
||||
try {
|
||||
await roadtripVias.move(dayId, id, lat, lng)
|
||||
await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [roadtripVias, can, trip, toast, t])
|
||||
}, [anchorFor, roadtripVias, can, trip, toast, t])
|
||||
|
||||
/**
|
||||
* Somewhere to fill up before this tank runs out.
|
||||
*
|
||||
* Measured against the day's DRIVING line, the same one the dry point was placed on, so
|
||||
* a station's distance along the road is comparable with the distance the fuel lasts.
|
||||
* The day's own places go in as well: a pump already on the plan should not be offered
|
||||
* beside itself.
|
||||
*/
|
||||
const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
if (!day) return
|
||||
const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
if (line.length < 2) return
|
||||
void refuel.ask(
|
||||
`${dayId}:${dry.legIndex}`,
|
||||
{ lat: dry.lat, lng: dry.lng },
|
||||
line,
|
||||
dry.drivenMeters / 1000,
|
||||
day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
|
||||
)
|
||||
}, [roadtripRoutes.days, refuel])
|
||||
|
||||
/**
|
||||
* Accepting one hands it to the same popup a corridor hit goes through.
|
||||
*
|
||||
* Deliberately not a direct write: the popup is where the stop kind and the time spent
|
||||
* are decided, it defaults both from the category, and every step after it — the place,
|
||||
* the assignment at the right position, the via re-anchoring, the re-route — is already
|
||||
* correct there and pinned by tests. A second path to the same end would be a second
|
||||
* place for it to go wrong.
|
||||
*/
|
||||
const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
if (!day) return
|
||||
// Before the stop the tank would have run out on, which is the leg the dry point
|
||||
// names. A station reached after the day's last stop is tomorrow's problem, and
|
||||
// clamping it onto the final leg would re-route the arrival through it.
|
||||
const at = Math.min(dry.legIndex + 1, day.stops.length - 1)
|
||||
// That index counts along the CARD, and after a night drive a card is not one stored
|
||||
// day: its first stops belong to yesterday. The new stop goes in front of the one it
|
||||
// was measured against, so it is that stop's own day and position that place it —
|
||||
// written against the card's day it would land in the wrong list, at an index that
|
||||
// means something else there.
|
||||
const anchor = day.stops[at]
|
||||
if (!anchor) return
|
||||
refuel.close()
|
||||
setStopDraft({ poi, dayId: anchor.ownerDayId, position: anchor.ownerIndex, dayNumber: day.dayNumber })
|
||||
}, [roadtripRoutes.days, refuel, can, trip])
|
||||
|
||||
/** Removing a via lets the drive take the direct road again. */
|
||||
const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
|
||||
@@ -1800,7 +2025,7 @@ export function useTripPlanner() {
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
@@ -1815,9 +2040,11 @@ export function useTripPlanner() {
|
||||
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
refuel, askRefuel, acceptRefuel,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
|
||||
@@ -61,6 +61,18 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
map_tile_url: '',
|
||||
// Empty = the public routing hosts TREK ships with.
|
||||
routing_base_url: '',
|
||||
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
|
||||
// case no second engine is asked at all. See valhallaBase().
|
||||
valhalla_base_url: '',
|
||||
// Empty = not said, and then a stop of either kind fills up. See refuelsRange().
|
||||
roadtrip_vehicle: '',
|
||||
// Off = the rail routes a day at a time, the way the day plan stores it, and the road
|
||||
// between one day's last stop and the next day's first is simply not drawn. On = the
|
||||
// trip is one continuous drive and every one of those gaps is routed too.
|
||||
roadtrip_connect_days: false,
|
||||
// Off = one blue line for the whole trip. On = a colour per day, so a route drawn end
|
||||
// to end can still be read as days.
|
||||
roadtrip_day_colors: false,
|
||||
dark_mode: false,
|
||||
// Empty = no personal display currency, so Costs falls back to the trip's own.
|
||||
default_currency: '',
|
||||
|
||||
@@ -77,6 +77,34 @@ interface StudioState {
|
||||
canRedo: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an inner spread may go: after the cover and the first page, before
|
||||
* the last page and the back cover.
|
||||
*
|
||||
* Read from the document each time rather than assumed, because a book from
|
||||
* before the single first page existed has none, and a book with no covers at
|
||||
* all (the tests build those) has nothing to stay inside of. The lower bound
|
||||
* is not decoration either: a book with no inner spreads yet reports its last
|
||||
* inner one as -1, which asked for a page at 0 — in front of the cover, where
|
||||
* it could not be moved back from, because a move only ever swaps with
|
||||
* another inner spread.
|
||||
*/
|
||||
function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } {
|
||||
let first = 0
|
||||
for (let i = 0; i < spreads.length; i++) {
|
||||
const role = spreads[i].role
|
||||
if (role === 'cover' || role === 'first') first = i + 1
|
||||
else break
|
||||
}
|
||||
let limit = spreads.length
|
||||
for (let i = spreads.length - 1; i >= 0; i--) {
|
||||
const role = spreads[i].role
|
||||
if (role === 'back' || role === 'last') limit = i
|
||||
else break
|
||||
}
|
||||
return { first, limit: Math.max(first, limit) }
|
||||
}
|
||||
|
||||
function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
|
||||
const spreads = doc.spreads.slice()
|
||||
if (!spreads[index]) return doc
|
||||
@@ -197,10 +225,14 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
*
|
||||
* The cover and the back cover are fixed points: a book has exactly one of
|
||||
* each, they are single pages rather than spreads, and the layouts panel
|
||||
* already refuses to touch them. So everything here operates on the inner
|
||||
* spreads between them, and `canEditSpread` is the one place that decides
|
||||
* what counts as inner — the rail, the menu and the store all ask it rather
|
||||
* than each testing `role` for themselves.
|
||||
* already refuses to touch them. The single first and last pages inside
|
||||
* them are fixed the same way (#2317): a bound book opens onto one
|
||||
* right-hand page and closes on one left-hand page, and moving either into
|
||||
* the middle of the book would put a half-width page between two spreads.
|
||||
* So everything here operates on the inner spreads between them, and
|
||||
* `canEditSpread` is the one place that decides what counts as inner — the
|
||||
* rail, the menu and the store all ask it rather than each testing `role`
|
||||
* for themselves.
|
||||
*/
|
||||
canEditSpread: index => {
|
||||
const sp = get().doc?.spreads[index]
|
||||
@@ -219,10 +251,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
* move only ever swaps with another inner spread. The first page of an
|
||||
* empty book is the one this catches.
|
||||
*/
|
||||
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
|
||||
const back = doc.spreads.findIndex(sp => sp.role === 'back')
|
||||
const first = cover === -1 ? 0 : cover + 1
|
||||
const limit = back === -1 ? doc.spreads.length : back
|
||||
const { first, limit } = innerBounds(doc.spreads)
|
||||
const at = Math.min(Math.max(index + 1, first), limit)
|
||||
get().commit(d => ({
|
||||
...d,
|
||||
@@ -246,10 +275,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
const doc = get().doc
|
||||
if (!doc) return
|
||||
// Same bounds as addSpread: inside the covers at both ends.
|
||||
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
|
||||
const back = doc.spreads.findIndex(sp => sp.role === 'back')
|
||||
const first = cover === -1 ? 0 : cover + 1
|
||||
const limit = back === -1 ? doc.spreads.length : back
|
||||
const { first, limit } = innerBounds(doc.spreads)
|
||||
const at = Math.min(Math.max(index + 1, first), limit)
|
||||
get().commit(d => ({
|
||||
...d,
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface Settings {
|
||||
* FOSSGIS hosts, which allow about one request a second.
|
||||
*/
|
||||
routing_base_url?: string
|
||||
/**
|
||||
* Base URL of the Valhalla asked for the avoidance questions OSRM cannot answer.
|
||||
* Undefined means the public FOSSGIS instance TREK ships with; empty means an
|
||||
* operator turned the second engine off and "other ways" goes back to OSRM alone.
|
||||
*/
|
||||
valhalla_base_url?: string
|
||||
dark_mode: boolean | string
|
||||
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
|
||||
default_currency: string | null
|
||||
@@ -143,6 +149,61 @@ export interface Settings {
|
||||
roadtrip_leg_minutes?: number
|
||||
roadtrip_day_minutes?: number
|
||||
roadtrip_range_km?: number
|
||||
/**
|
||||
* Road classes the drive should leave out where it can, as a comma list
|
||||
* ("toll,ferry"). Absent means route normally, which is the right meaning for absent:
|
||||
* the settings row does not exist until somebody turns one of them on, and the first
|
||||
* paint of every session reads this before the settings have loaded.
|
||||
*
|
||||
* A list rather than three booleans because it is one decision with three parts, and
|
||||
* because it goes to the router as one request either way.
|
||||
*/
|
||||
roadtrip_avoid?: string
|
||||
/**
|
||||
* What the traveller drives: 'combustion', 'electric', or absent for "did not say".
|
||||
*
|
||||
* Absent has to mean BOTH kinds refill, which is what the range budget did before this
|
||||
* existed. Anything else would quietly change the warnings of every traveller who never
|
||||
* opened the dialog.
|
||||
*/
|
||||
roadtrip_vehicle?: string
|
||||
/** Route the gaps between days too, so the trip is one continuous drive. */
|
||||
roadtrip_connect_days?: boolean
|
||||
/** Draw each day of the trip in its own colour. */
|
||||
roadtrip_day_colors?: boolean
|
||||
/**
|
||||
* How full a fill-up goes, 1 to 100. Absent or 100 means all the way.
|
||||
*
|
||||
* Nobody charges to 100 % on the road, so an electric traveller who leaves this at full
|
||||
* gets a range figure a fifth too generous after every stop.
|
||||
*/
|
||||
roadtrip_fill_percent?: number
|
||||
/**
|
||||
* What the vehicle is made of, for travellers who would rather not do the division.
|
||||
*
|
||||
* Optional throughout, and stored METRIC throughout — litres and kilowatt-hours, per
|
||||
* 100 kilometres — exactly as roadtrip_range_km is always kilometres. The dialog does
|
||||
* the round trip into gallons, miles per gallon and kWh per 100 miles for an imperial
|
||||
* traveller; storage stays one system so the arithmetic never has to ask.
|
||||
*
|
||||
* When a pair is complete it WINS over roadtrip_range_km, because it is the more
|
||||
* specific answer. Half a pair computes nothing: a made-up range here would put the
|
||||
* fuel warnings at the wrong place while looking exact.
|
||||
*/
|
||||
roadtrip_tank_litres?: number
|
||||
roadtrip_litres_per_100?: number
|
||||
roadtrip_battery_kwh?: number
|
||||
roadtrip_kwh_per_100?: number
|
||||
/**
|
||||
* Percent of the battery the years have taken. Absent means none.
|
||||
*
|
||||
* The one figure of ABRP's list that changes the answer here. Plug type, reference
|
||||
* speed and drive style shape a consumption PREDICTION; this addon does not predict
|
||||
* one, it divides a capacity by a consumption the traveller states. Degradation is
|
||||
* different in kind: it shrinks the capacity itself, and on a five-year-old car by
|
||||
* enough to decide whether the last leg of a day arrives.
|
||||
*/
|
||||
roadtrip_battery_degradation?: number
|
||||
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
|
||||
carto_api_key?: string
|
||||
mapbox_access_token?: string
|
||||
@@ -228,8 +289,20 @@ export interface RouteWithLegs {
|
||||
vias?: RouteVia[]
|
||||
/** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */
|
||||
snapped?: SnappedWaypoint[]
|
||||
/**
|
||||
* What was asked to be left out, and what the road actually left out.
|
||||
*
|
||||
* The two differ, and that is the point. Valhalla weights a class away rather than
|
||||
* banning it, so a drive with no untolled connection comes back on a toll road and
|
||||
* says so. Present only on a route that was asked to avoid something, so `undefined`
|
||||
* means the question was never put rather than "avoided nothing".
|
||||
*/
|
||||
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[] }
|
||||
}
|
||||
|
||||
/** A road class a route can be asked to leave out. */
|
||||
export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'
|
||||
|
||||
export interface RouteResult {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Which index a place came from, as a short mark beside it.
|
||||
*
|
||||
* A result list can be two indexes interleaved, so the source belongs on the
|
||||
* row rather than above the list: with the TREK index and OpenStreetMap
|
||||
* answering together, "one of these came from somewhere" is not an answer
|
||||
* anyone can use.
|
||||
*
|
||||
* Here rather than beside one screen because both the desktop form and the
|
||||
* mobile sheet show the same lists, and a mark that says TREK on one and
|
||||
* nothing on the other is worse than either alone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three names are proper nouns, so they are not translated, and that is
|
||||
* also why there is no fourth: a source without a name people already know
|
||||
* would need a string in 23 languages to say less than nothing.
|
||||
*/
|
||||
export const SOURCE_LABELS: Record<string, string> = {
|
||||
'trek-places': 'TREK',
|
||||
openstreetmap: 'OpenStreetMap',
|
||||
nominatim: 'OpenStreetMap',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
/**
|
||||
* The label for one row.
|
||||
*
|
||||
* A place carries its own source when the index that produced it says so, which
|
||||
* is what makes an interleaved list readable. Everything else falls back to what
|
||||
* answered the call: Google never marks its places, and a merged list marks only
|
||||
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
|
||||
*/
|
||||
export function sourceLabelFor(place: unknown, listSource: string): string | null {
|
||||
const own = (place as { source?: unknown } | null)?.source
|
||||
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
|
||||
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
|
||||
return SOURCE_LABELS[listSource] ?? null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user