feat(roadtrip): bring the road trip addon to the phone

A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.

The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.

The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.

The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.

Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
This commit is contained in:
Maurice
2026-09-16 21:55:18 +02:00
parent bd44197974
commit aaff8f9db4
96 changed files with 7544 additions and 97 deletions
@@ -0,0 +1,57 @@
import { Info } from 'lucide-react'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { useTranslation } from '../../i18n'
interface MapAttributionToggleProps {
open: boolean
onToggle: () => void
/** Same band as the locate button, so the two sit on one line. */
bottomOffset: string
}
/**
* The little (i) that stands in for the credit on a phone.
*
* Both GL engines ship one of these: below 640px they collapse their attribution to a
* button and open it on a tap. Leaflet has no such mode, so on a screen the map fills,
* its credit is a white two-line block parked in the middle of the picture. This is the
* missing half of that pattern, and it is a React button rather than surgery on
* Leaflet's own container because Leaflet rewrites that container's markup from scratch
* whenever a layer with an attribution is added or removed.
*
* Nothing is hidden that cannot be read: one tap shows the full credit, every link in
* it included, which is the same bargain the GL engines strike.
*/
export default function MapAttributionToggle({ open, onToggle, bottomOffset }: MapAttributionToggleProps) {
const { t } = useTranslation()
const label = t('map.attribution')
return (
<button
type="button"
onClick={onToggle}
title={label}
aria-label={label}
aria-expanded={open}
style={{
position: 'absolute',
bottom: bottomOffset,
// Clear of the locate button at right: 12 plus its 42, with a gap.
right: 62,
zIndex: 1000,
width: 30,
height: 30,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: 'var(--bg-card, white)',
color: 'var(--text-muted, #6b7280)',
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Info size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
)
}
+21 -1
View File
@@ -34,6 +34,7 @@ function alternativeLabelIcon(label: string, note: string, background: string, a
})
}
import L from 'leaflet'
import MapAttributionToggle from './MapAttributionToggle'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
@@ -978,6 +979,16 @@ export const MapView = memo(function MapView({
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
/**
* Whether the credit is showing, on a phone where it starts as the (i).
*
* Held here rather than pushed into Leaflet's own container: Leaflet rewrites that
* container's markup from scratch whenever a layer with an attribution is added or
* removed, so anything injected into it dies on the next basemap switch. A class on
* the wrapper survives that, and the CSS lives with the rest of the mobile chrome.
*/
const [attribOpen, setAttribOpen] = useState(false)
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
@@ -996,7 +1007,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<div className={`w-full h-full relative${isMobile && attribOpen ? ' m-attrib-open' : ''}`}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1215,6 +1226,15 @@ export const MapView = memo(function MapView({
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
{/* The credit as the little (i), which is what both GL engines do on a narrow map
and Leaflet has no mode for. */}
{isMobile && (
<MapAttributionToggle
open={attribOpen}
onToggle={() => setAttribOpen(v => !v)}
bottomOffset={locationButtonBottom}
/>
)}
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
@@ -2066,3 +2066,48 @@ describe('MapViewGL', () => {
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
/** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */
function compactAttribution(container: HTMLElement): HTMLElement {
const el = document.createElement('details')
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show'
el.setAttribute('open', '')
// Inside the map container itself, where maplibre puts its controls.
container.querySelectorAll('div')[1]?.appendChild(el)
return el
}
it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const attrib = compactAttribution(container)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
expect(idle).toBeDefined()
act(() => { idle!() })
// Both engines open theirs on a phone and only tuck it away on the first drag, so
// the first thing on a screen the map fills is a two-line band across the bottom.
expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false)
expect(attrib.hasAttribute('open')).toBe(false)
// Still there, still one tap away: nothing about the credit is removed.
expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true)
})
it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const el = document.createElement('details')
// No `compact-show`, which is what a map over 640px reports.
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib'
el.setAttribute('open', '')
container.querySelectorAll('div')[1]?.appendChild(el)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
act(() => { idle!() })
expect(el.hasAttribute('open')).toBe(true)
})
})
+17
View File
@@ -1170,6 +1170,23 @@ export function MapViewGL({
maxWidth: '240px',
className: 'trek-map-popup',
})
/*
* The credit starts as the little (i), not as a ribbon across the map.
*
* Both engines collapse their attribution below 640px, and both then open it
* anyway and wait for a drag before tucking it away. On a phone, where the map IS
* the screen, that means the first thing anybody sees is a two-line grey band
* over the bottom of it. Dragging is exactly what maplibre does on its own
* `drag` handler; this only starts where that would have ended up, so the credit
* is one tap away and nothing about it is removed.
*/
map.once('idle', () => {
const attrib = containerRef.current?.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show')
?? containerRef.current?.querySelector('.mapboxgl-ctrl-attrib.mapboxgl-compact-show')
attrib?.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show')
attrib?.removeAttribute('open')
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
@@ -12,6 +12,7 @@ import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import { serviceColor } from './roadtripModel'
import { alongLabel, offRouteLabel } from './corridorSearchModel'
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
import { FS } from './typeScale'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
@@ -154,14 +155,11 @@ function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => vo
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) })}
</span>
{/* Written by the shared model rather than here: the phone says the same two
things in one line, and the wording of a hit should not depend on which
shell is reading it. */}
<span className={POI_CHIP}>{offRouteLabel(poi.offRouteKm, distanceUnit, t)}</span>
<span className={POI_CHIP}>{alongLabel(poi.alongKm, distanceUnit, t)}</span>
{/* What the charger offers, where OSM says. Socket names are proper nouns and
stay as they are; the numbers around them are what decides whether a car can
use it at all. A station that says nothing shows nothing rather than a row
@@ -455,7 +453,9 @@ export default function RoadtripCorridorPanel({
<div className="flex gap-2">
<button
type="button"
onClick={search.search}
// Wrapped, not handed over: `search` takes an optional stretch of the drive,
// and a click handler passed straight through would hand it the MouseEvent.
onClick={() => search.search()}
disabled={!canSearch}
aria-label={t('roadtrip.poi.search')}
title={narrow ? t('roadtrip.poi.search') : undefined}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
alongLabel,
anchorKmFor,
corridorHitLabel,
corridorWindow,
DESKTOP_CORRIDOR_BUDGET,
offRouteLabel,
PHONE_CORRIDOR_BUDGET,
PHONE_CORRIDOR_OPTIONS,
PHONE_REACH_KM,
} from './corridorSearchModel'
import type { TranslationFn } from '../../types'
// FE-ROADTRIP-CORWIN-001 to FE-ROADTRIP-CORWIN-012
/** Echoes the key and its parameters, so assertions stay off the copy. */
const t: TranslationFn = (key, params) =>
params ? `${key}:${Object.values(params).join(',')}` : key
describe('corridorWindow', () => {
it('FE-ROADTRIP-CORWIN-001: a stage search has no window, which is the whole day', () => {
expect(corridorWindow(120, 'stage')).toBeNull()
})
it('FE-ROADTRIP-CORWIN-002: an ahead search starts at the anchor and runs one reach', () => {
expect(corridorWindow(120, 'ahead')).toEqual({ fromKm: 120, toKm: 120 + PHONE_REACH_KM })
})
it('FE-ROADTRIP-CORWIN-003: never starts behind the beginning of the drive', () => {
// A negative anchor cannot come from `anchorKmFor`, but a window that started at
// -20 would ask the slicer for road that does not exist.
expect(corridorWindow(-20, 'ahead')).toEqual({ fromKm: 0, toKm: PHONE_REACH_KM })
})
it('FE-ROADTRIP-CORWIN-004: takes a reach of its own, so the figure is not baked in', () => {
expect(corridorWindow(10, 'ahead', 25)).toEqual({ fromKm: 10, toKm: 35 })
})
})
describe('anchorKmFor', () => {
const stops = [0, 40, 95, 180]
it('FE-ROADTRIP-CORWIN-005: without a next stop it is the start of the stage', () => {
// No position source and no clock reading: the honest answer is the beginning of
// the day, not a guess dressed up as a location.
expect(anchorKmFor(stops, null)).toBe(0)
})
it('FE-ROADTRIP-CORWIN-006: the first stop is also the start, not a step back', () => {
expect(anchorKmFor(stops, 0)).toBe(0)
})
it('FE-ROADTRIP-CORWIN-007: otherwise it is the stop already behind the traveller', () => {
// Heading for the third stop means the second is behind: that is where the road
// still to drive begins.
expect(anchorKmFor(stops, 2)).toBe(40)
})
it('FE-ROADTRIP-CORWIN-008: an index the stop list cannot answer falls back to the start', () => {
expect(anchorKmFor(stops, 99)).toBe(0)
expect(anchorKmFor([0, Number.NaN, 95], 2)).toBe(0)
})
})
describe('the labels a hit carries', () => {
it('FE-ROADTRIP-CORWIN-009: anything under half a kilometre in is "at the start"', () => {
expect(alongLabel(0.2, 'metric', t)).toBe('roadtrip.poi.atStart')
expect(alongLabel(0.5, 'metric', t)).toBe('roadtrip.poi.alongRoute:500 m')
})
it('FE-ROADTRIP-CORWIN-010: the detour is stated in the trip’s own unit', () => {
expect(offRouteLabel(1.5, 'metric', t)).toBe('roadtrip.poi.offRoute:1.5 km')
expect(offRouteLabel(1.5, 'imperial', t)).toBe('roadtrip.poi.offRoute:0.9 mi')
})
it('FE-ROADTRIP-CORWIN-011: the phone joins both into the one line a row has room for', () => {
expect(corridorHitLabel({ alongKm: 82, offRouteKm: 0.4 }, 'metric', t))
.toBe('roadtrip.poi.alongRoute:82 km · roadtrip.poi.offRoute:400 m')
})
})
describe('the budgets', () => {
it('FE-ROADTRIP-CORWIN-012: the desk keeps its old behaviour, the phone is capped', () => {
// The desktop budget IS today's behaviour written down: no deadline, every failed
// box retried. Loosening the phone's numbers is a product decision, not a tidy-up,
// so they are pinned.
expect(DESKTOP_CORRIDOR_BUDGET).toEqual({ maxTiles: 16, maxRetries: null, deadlineMs: null })
expect(PHONE_CORRIDOR_BUDGET).toEqual({ maxTiles: 10, maxRetries: 2, deadlineMs: 25_000 })
// Handed down by identity so the search callback underneath is not rebuilt.
expect(PHONE_CORRIDOR_OPTIONS.budget).toBe(PHONE_CORRIDOR_BUDGET)
})
})
@@ -0,0 +1,117 @@
import { formatDistance } from '../../utils/units'
import type { CorridorPoi, CorridorWindow } from './useCorridorPois'
import type { DistanceUnit } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types'
/**
* What a corridor search is allowed to cost, and how far along the drive it looks.
*
* The search is already scoped to one day. What it is NOT scoped to is a stretch of
* that day, and that is where the money is: at roughly one request per 20km of driving,
* a 300km day is 15 boxes, while the 50km ahead of the driver is three. A phone in a
* cell is not a desk, so it asks for the stretch it is about to drive and offers the
* whole stage as the second tap.
*
* Deliberately NOT narrowed: the categories. They all travel in one comma-separated
* query per box, so a second category costs zero extra requests. Restricting the phone
* to one at a time would have made three searches out of one, which is three times the
* traffic for less choice.
*/
export interface CorridorBudget {
/** Boxes one search may ask for. Past this it stops and says so. */
maxTiles: number
/** Boxes the follow-up pass may retry, or null for all of them (the desktop's way). */
maxRetries: number | null
/** No new box is started after this, or null for no limit. */
deadlineMs: number | null
}
/** Unchanged behaviour: this is what the panel has always done. */
export const DESKTOP_CORRIDOR_BUDGET: CorridorBudget = {
maxTiles: 16,
maxRetries: null,
deadlineMs: null,
}
/**
* The phone's ceiling.
*
* `deadlineMs` exists only here, and it answers a conflict the desktop lives with: the
* client gives a box 20 seconds, the server's Overpass fallback allows itself 25. A slow
* box therefore always times out on the client first and gets retried while the first
* attempt is still running. On a phone that is the difference between a search that
* ends and one that keeps the radio warm.
*/
export const PHONE_CORRIDOR_BUDGET: CorridorBudget = {
maxTiles: 10,
maxRetries: 2,
deadlineMs: 25_000,
}
/** Passed by identity, so the search callback below it is not rebuilt every render. */
export const PHONE_CORRIDOR_OPTIONS = { budget: PHONE_CORRIDOR_BUDGET }
/** How far ahead "ahead" means, in kilometres of driving. */
export const PHONE_REACH_KM = 50
export type CorridorReach = 'ahead' | 'stage'
/**
* The stretch of the drive a search covers, in kilometres along the day's line.
*
* Null means the whole stage, which is what the caller passes straight through to the
* existing behaviour. The window is applied to the tile line only: the spine keeps its
* full length, so `alongKm`, `offRouteKm` and the insert position of a hit stay measured
* against the day rather than against the window.
*/
export function corridorWindow(
anchorKm: number,
reach: CorridorReach,
reachKm: number = PHONE_REACH_KM,
): CorridorWindow | null {
if (reach === 'stage') return null
const from = Math.max(0, anchorKm)
return { fromKm: from, toKm: from + reachKm }
}
/**
* Where "ahead" starts.
*
* The stop the traveller is heading for, measured in kilometres along the day. Without
* one (a stage that is not today, or a day already driven) it is the start of the stage,
* which is the honest answer: there is no position source, and a guess dressed up as a
* location is worse than the beginning of the day.
*/
export function anchorKmFor(stopsAlongKm: readonly number[], upNextIndex: number | null): number {
if (upNextIndex == null || upNextIndex <= 0) return 0
const previous = stopsAlongKm[upNextIndex - 1]
return Number.isFinite(previous) ? Math.max(0, previous as number) : 0
}
/**
* How far into the drive a hit sits.
*
* Shared with the desktop panel rather than written twice. The two shells already mirror
* each other closely and the duplication budget is three percent of new code, so the
* phrases that describe a hit are written once and read in both: the desktop puts them
* in two chips, the phone joins them into one line, and neither owns the wording.
*/
export function alongLabel(alongKm: number, unit: DistanceUnit, t: TranslationFn): string {
return alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(alongKm, unit) })
}
/** How far off the road it sits. */
export function offRouteLabel(offRouteKm: number, unit: DistanceUnit, t: TranslationFn): string {
return t('roadtrip.poi.offRoute', { distance: formatDistance(offRouteKm, unit) })
}
/** Both of them, as the one line a phone row has room for. */
export function corridorHitLabel(
poi: Pick<CorridorPoi, 'alongKm' | 'offRouteKm'>,
unit: DistanceUnit,
t: TranslationFn,
): string {
return `${alongLabel(poi.alongKm, unit, t)} · ${offRouteLabel(poi.offRouteKm, unit, t)}`
}
@@ -0,0 +1,331 @@
import { describe, expect, it } from 'vitest'
import {
destinationCount,
pickWarning,
roadtripRows,
stageOf,
upNextStop,
type RoadtripRow,
type StopRow,
} from './roadtripRowModel'
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-028
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
assignmentId: name.length,
ownerDayId: 7,
ownerIndex: 0,
placeId: 100,
name,
lat: 48.1,
lng: 11.2,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
function entry(arrival: string | null, over: Partial<ScheduleEntry> = {}): ScheduleEntry {
return { arrival, departure: arrival, anchored: false, dayOffset: 0, ...over }
}
function seg(index: number): RouteSegment {
return {
mid: [48, 11],
from: [48, 11],
to: [49, 12],
distance: 1000 * (index + 1),
duration: 600,
walkingText: '2 h',
drivingText: '10 min',
distanceText: `${index + 1} km`,
}
}
function day(stops: RoadtripStop[], over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7,
dayNumber: 1,
date: '2026-06-01',
title: null,
stops,
legs: stops.slice(0, -1).map((_, i) => seg(i)),
schedule: { entries: stops.map(() => entry(null)), warnings: [] },
legVias: [],
geometry: [],
distance: 0,
duration: 0,
driveWarnings: [],
dayWarning: null,
...over,
}
}
const night = (phase: 'start' | 'end' = 'end') =>
stop('Nacht', { automaticNight: { phase, fromDayNumber: 1 }, dwellMinutes: 480 })
const stopRows = (rows: RoadtripRow[]) => rows.filter((r): r is StopRow => r.kind === 'stop')
/** Ziel, Tankstelle, Ziel, automatische Nacht, Ziel. */
const mixedDay = () =>
day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kassel'), night(), stop('Fulda')])
describe('roadtripRows numbering', () => {
it('FE-RTROW-001: only destinations take a number, a service stop and a night do not advance it', () => {
// Nummerierung ueber den Array-Index machte aus Kassel die drei und aus Fulda die fuenf.
const numbers = stopRows(roadtripRows(mixedDay())).map((r) => [r.stop.name, r.number])
expect(numbers).toEqual([
['Bremen', 1],
['Aral', null],
['Kassel', 2],
['Fulda', 3],
])
})
it('FE-RTROW-002: the service stop is a stop row, the automatic night is not', () => {
const rows = roadtripRows(mixedDay())
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'auto', 'leg', 'stop'])
expect(stopRows(rows).map((r) => r.stop.name)).not.toContain('Nacht')
})
it('FE-RTROW-003: the automatic night carries no dwell and takes its time from the schedule', () => {
// Die Nacht traegt dwellMinutes 480, aber keine Zeile, die eine Aufenthaltsdauer zeigen koennte.
const rows = roadtripRows(
day([stop('Kassel'), night(), stop('Fulda')], {
schedule: { entries: [entry('18:00'), entry('22:30'), entry('09:15')], warnings: [] },
}),
)
expect(rows.find((r) => r.kind === 'auto')).toEqual({ kind: 'auto', phase: 'end', time: '22:30' })
expect(JSON.stringify(rows)).not.toContain('480')
})
it('FE-RTROW-004: a night that starts a day resumes it, anything else ends it', () => {
expect(roadtripRows(day([night('start'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
phase: 'resume',
})
expect(roadtripRows(day([night('end'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
phase: 'end',
})
})
it('FE-RTROW-005: exactly one leg between two stops and none after the last', () => {
const rows = roadtripRows(day([stop('A'), stop('B'), stop('C')]))
expect(rows.filter((r) => r.kind === 'leg')).toEqual([
{ kind: 'leg', index: 0, seg: seg(0), mode: null },
{ kind: 'leg', index: 1, seg: seg(1), mode: null },
])
expect(rows[rows.length - 1].kind).toBe('stop')
})
it('FE-RTROW-006: a leg is driven in the next stop incoming mode, else in this one own', () => {
const rows = roadtripRows(
day([
stop('A', { legMode: 'car' }),
stop('B', { incomingLegMode: 'ferry', legMode: 'train' }),
stop('C'),
stop('D'),
]),
)
const modes = rows.flatMap((r) => (r.kind === 'leg' ? [r.mode] : []))
expect(modes).toEqual(['ferry', 'train', null])
})
})
describe('roadtripRows dry points and spills', () => {
it('FE-RTROW-007: a dry point makes one row, directly after the leg it falls on', () => {
const rows = roadtripRows(
day([stop('A'), stop('B'), stop('C')], {
dryPoints: [{ legIndex: 1, intoLegKm: 42, sinceKm: 610, drivenMeters: 42000, lat: 48, lng: 11 }],
}),
)
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'dry', 'stop'])
expect(rows[4]).toEqual({ kind: 'dry', legIndex: 1, intoLegKm: 42, sinceKm: 610 })
})
it('FE-RTROW-008: a dry point past the last leg is not drawn', () => {
// Nach dem letzten Stopp gibt es kein Bein, an dem die Warnung haengen koennte.
const rows = roadtripRows(
day([stop('A'), stop('B')], {
dryPoints: [{ legIndex: 1, intoLegKm: 5, sinceKm: 600, drivenMeters: 5000, lat: 48, lng: 11 }],
}),
)
expect(rows.some((r) => r.kind === 'dry')).toBe(false)
})
it('FE-RTROW-009: a spill sits before the stop it hangs on, with the day it came from', () => {
const rows = roadtripRows(
day([stop('A'), stop('B')], {
spills: [
{ at: 1, count: 1, fromDayNumber: 3, departure: '23:40', leg: undefined, fromStop: undefined, line: [] },
],
}),
)
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'spill', 'stop'])
expect(rows[2]).toEqual({ kind: 'spill', fromDayNumber: 3, departs: '23:40', stops: [] })
})
})
describe('roadtripRows stop detail', () => {
it('FE-RTROW-010: the clock and the pin come from the schedule entry', () => {
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B'), stop('C')], {
schedule: {
entries: [entry('08:00'), entry('09:40', { departure: '10:10', anchored: true }), entry(null)],
warnings: [],
},
}),
),
)
expect(rows.map((r) => [r.time, r.pinned])).toEqual([
['08:00', false],
['09:40', true],
[null, false],
])
expect(rows[1].entry?.departure).toBe('10:10')
})
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B')], {
driveWarnings: [
{ index: 0, code: 'overnight' },
{ index: 1, code: 'leg', overMinutes: 20 },
{ index: 1, code: 'late', minutes: 15 },
],
}),
),
)
expect(rows[0].warning).toEqual({ index: 0, code: 'overnight' })
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
})
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
expect([rows[1].dwellMinutes, rows[1].offRoadMeters]).toEqual([null, null])
})
})
describe('pickWarning', () => {
it('FE-RTROW-013: late beats range beats leg beats overnight', () => {
const all: ScheduleWarning[] = [
{ index: 0, code: 'overnight' },
{ index: 0, code: 'leg', overMinutes: 30 },
{ index: 0, code: 'range', sinceKm: 700 },
{ index: 0, code: 'late', minutes: 20 },
]
expect(pickWarning(all)?.code).toBe('late')
expect(pickWarning(all.slice(0, 3))?.code).toBe('range')
expect(pickWarning(all.slice(0, 2))?.code).toBe('leg')
expect(pickWarning(all.slice(0, 1))?.code).toBe('overnight')
})
it('FE-RTROW-028: the order the findings arrive in does not decide', () => {
// Sonst haengt die Marke davon ab, in welcher Reihenfolge deriveDriveWarnings sie anhaengt.
const all: ScheduleWarning[] = [
{ index: 0, code: 'late', minutes: 20 },
{ index: 0, code: 'range', sinceKm: 700 },
{ index: 0, code: 'overnight' },
]
expect(pickWarning(all)?.code).toBe('late')
expect(pickWarning([...all].reverse())?.code).toBe('late')
})
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
expect(pickWarning([])).toBeNull()
})
})
describe('stageOf', () => {
const days = [day([stop('A')], { dayId: 11, dayNumber: 1 }), day([stop('B')], { dayId: 22, dayNumber: 2 })]
it('FE-RTROW-015: finds the day by its id, not by its position', () => {
expect(stageOf(days, 22)?.dayNumber).toBe(2)
})
it('FE-RTROW-016: no selection and an id that is gone both give null', () => {
expect(stageOf(days, null)).toBeNull()
expect(stageOf(days, 999)).toBeNull()
expect(stageOf([], 11)).toBeNull()
})
})
describe('destinationCount', () => {
it('FE-RTROW-017: agrees with the highest number the rows handed out', () => {
const d = mixedDay()
const highest = stopRows(roadtripRows(d)).reduce((max, r) => Math.max(max, r.number ?? 0), 0)
expect(destinationCount(d)).toBe(3)
expect(destinationCount(d)).toBe(highest)
})
it('FE-RTROW-018: a day of nothing but services and nights counts none', () => {
expect(destinationCount(day([stop('Aral', { stopType: 'fuel' }), night()]))).toBe(0)
})
})
const timedDay = (arrivals: (string | null)[], stops: RoadtripStop[]) =>
day(stops, { schedule: { entries: arrivals.map((a) => entry(a)), warnings: [] } })
describe('upNextStop', () => {
const threeStops = () =>
timedDay(['09:00', '11:00', '13:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('C')])
it('FE-RTROW-019: says nothing on a day that is not today, and nothing without a day', () => {
expect(upNextStop(threeStops(), 10 * 60, false)).toBeNull()
expect(upNextStop(null, 10 * 60, true)).toBeNull()
})
it('FE-RTROW-020: passes over the petrol stop and names the next destination', () => {
// Die Tankstelle um 11:00 liegt vor dem Ziel um 13:00 und ist trotzdem nie das naechste.
const next = upNextStop(threeStops(), 10 * 60, true)
expect(next?.row.stop.name).toBe('C')
expect(next?.minutesUntil).toBe(180)
})
it('FE-RTROW-021: a stop without a readable time is passed over', () => {
const d = timedDay([null, 'irgendwann', '13:00'], [stop('A'), stop('B'), stop('C')])
expect(upNextStop(d, 8 * 60, true)?.row.stop.name).toBe('C')
})
it('FE-RTROW-022: a stop due this very minute is still ahead', () => {
expect(upNextStop(threeStops(), 13 * 60, true)?.minutesUntil).toBe(0)
})
it('FE-RTROW-023: a stop whose time has just passed comes back as a delay', () => {
// Es gibt keine Positionsquelle, also ist "der Plan sagte 13:00, es ist 13:20"
// die staerkste ehrliche Aussage. Sie kommt als negatives minutesUntil.
expect(upNextStop(threeStops(), 13 * 60 + 20, true)?.minutesUntil).toBe(-20)
})
it('FE-RTROW-024: an hour past the last stop the day is over and nothing is next', () => {
expect(upNextStop(threeStops(), 14 * 60 + 1, true)).toBeNull()
})
it('FE-RTROW-025: a delay is only ever reported for the last stop, never a passed one', () => {
// 09:00 ist lange vorbei, 13:00 auch, gemeldet wird der Verzug auf den letzten.
const late = upNextStop(threeStops(), 13 * 60 + 30, true)
expect(late?.row.stop.name).toBe('C')
expect(late?.minutesUntil).toBe(-30)
})
it('FE-RTROW-026: another day, no day and a day without any clock name nothing', () => {
expect(upNextStop(timedDay(['09:00'], [stop('A')]), 12 * 60, false)).toBeNull()
expect(upNextStop(null, 12 * 60, true)).toBeNull()
expect(upNextStop(timedDay([null], [stop('A')]), 12 * 60, true)).toBeNull()
expect(upNextStop(timedDay(['nachmittags'], [stop('A')]), 12 * 60, true)).toBeNull()
})
it('FE-RTROW-027: a service stop is never the one named, even when it is next in line', () => {
// Die Tankstelle um 10:00 laege vorne, genannt wird trotzdem das Ziel um 11:00.
const d = timedDay(['09:00', '10:00', '11:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('B')])
expect(upNextStop(d, 9 * 60 + 30, true)?.row.stop.name).toBe('B')
})
})
@@ -0,0 +1,199 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
/**
* A road trip day, flattened into the rows a screen draws, with no React in sight.
*
* The rail builds its rows inline while it renders, which is fine for a panel that only
* ever exists once. The phone draws the same chain in a second shell, and the two would
* drift the first time a rule changed, and the duplication budget does not stretch to
* the same forty lines of branching twice. So the branching lives here: what is a row,
* in which order, and which of a stop's findings survives the two marks a 375px line can
* carry.
*
* Deliberately free of React, translations and formatting. It answers "what is on this
* line", never "how does it read", so it can be unit tested on its own and so the same
* answer can be drawn twice in two different design languages.
*/
export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
export interface StopRow {
kind: 'stop'
stop: RoadtripStop
/** Position in the day's numbering, or null for a service stop and an automatic night. */
number: number | null
service: boolean
entry: ScheduleEntry | undefined
/** The clock this row shows on the right: when the traveller gets there. */
time: string | null
/** When they leave again, which is arrival plus the stay. Null without a schedule. */
departure: string | null
/** True when a person pinned that time, which is what the pin in front of it means. */
pinned: boolean
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
warning: ScheduleWarning | null
dwellMinutes: number | null
offRoadMeters: number | null
}
/**
* Which finding wins when a stop has several.
*
* The desktop stacks all of them, which is honest on a 420px rail and unreadable on a
* phone. The order is by what changes the next decision soonest: being late moves every
* arrival after it, running dry strands the car, and an over-long leg is a planning
* remark you can act on tonight.
*/
const WARNING_RANK: Record<ScheduleWarning['code'], number> = {
late: 0,
range: 1,
leg: 2,
overnight: 3,
}
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
let best: ScheduleWarning | null = null
for (const w of warnings) {
if (!best || WARNING_RANK[w.code] < WARNING_RANK[best.code]) best = w
}
return best
}
/** The one day a phone screen shows, picked from what the routing round produced. */
export function stageOf(days: readonly RoadtripDay[], dayId: number | null): RoadtripDay | null {
if (dayId == null) return null
return days.find(d => d.dayId === dayId) ?? null
}
function stopRow(
stop: RoadtripStop,
index: number,
number: number | null,
schedule: RoadtripDay['schedule'],
driveWarnings: readonly ScheduleWarning[],
): StopRow {
// Only ever called for a real stop: an automatic night is the shell's own marker
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
const entry = schedule.entries[index]
const mine = driveWarnings.filter(w => w.index === index)
return {
kind: 'stop',
stop,
number,
service: isServiceStopType(stop.stopType),
entry,
time: entry?.arrival ?? null,
departure: entry?.departure ?? null,
pinned: !!entry?.anchored,
warning: pickWarning(mine),
dwellMinutes: stop.dwellMinutes,
offRoadMeters: stop.offRoadMeters ?? null,
}
}
/**
* The rows of one day, in the order they are drawn.
*
* Two things here are easy to get wrong and both have a test:
*
* 1. Numbering counts destinations only. A petrol station between stop two and stop
* three does not make the next one four, and neither does the marker that ends the
* day. Numbering off the array index gets both wrong.
* 2. `ownerDayId` is the day a stop is STORED on, which after a night drive is not the
* card it is drawn on. The rows follow the card, because that is what the traveller
* reads, and anything writing back has to use `ownerDayId` instead.
*/
export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = []
const spills = day.spills ?? []
let number = 0
day.stops.forEach((stop, i) => {
const spill = spills.find(s => s.at === i)
if (spill) {
rows.push({
kind: 'spill',
fromDayNumber: spill.fromDayNumber,
departs: spill.departure,
stops: [],
})
}
const automatic = !!stop.automaticNight
if (automatic) {
rows.push({
kind: 'auto',
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
time: day.schedule.entries[i]?.arrival ?? null,
})
} else {
if (!isServiceStopType(stop.stopType)) number += 1
rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings))
}
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
// between two stops, so they are emitted here rather than in their own pass.
const seg = day.legs[i]
if (i < day.stops.length - 1) {
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) {
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
}
}
})
return rows
}
/** Stops that carry a number, for a count that agrees with the numbering above. */
export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length
}
/**
* The next destination the plan still owes you, measured against the clock.
*
* Only for today: a countdown on a day in March is noise. `minutesUntil` goes
* NEGATIVE once that stop's planned time has passed, and that negative number is
* the only honest statement about running late this screen can make. There is no
* position source, so the app cannot know whether the stop was reached; what it
* can say is that the plan said 10:45 and it is now 11:21.
*
* Null once the last stop's time is past: a screen still saying "next" after the
* driving is done is worse than one saying nothing.
*/
export function upNextStop(
day: RoadtripDay | null,
nowMinutes: number,
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = roadtripRows(day)
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null)
if (!timed.length) return null
const ahead = timed.find(x => x.at >= nowMinutes)
if (ahead) return { row: ahead.row, minutesUntil: ahead.at - nowMinutes }
// Everything is past. The last one is only still "next" while the day has not
// run out entirely, which we take as its planned time plus the hour after it.
const last = timed[timed.length - 1]
const overdue = nowMinutes - last.at
return overdue <= 60 ? { row: last.row, minutesUntil: -overdue } : null
}
function clockMinutes(time: string | null): number | null {
if (!time) return null
const [h, m] = time.split(':').map(Number)
if (!Number.isFinite(h) || !Number.isFinite(m)) return null
return h * 60 + m
}
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import { dayColor } from './dayColors'
import { stageMapData, stagePlaceIds, stagePoints } from './stageMap'
import type { AccessSpur, RoadtripDay, RoadtripRoutes, RoadtripStop } from '@trek/shared/roadtrip'
// FE-RTSTAGE-001 to FE-RTSTAGE-015
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
assignmentId: 1,
ownerDayId: 7,
ownerIndex: 0,
placeId: 100,
name,
lat: 48.1,
lng: 11.2,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
function day(stops: RoadtripStop[], over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7,
dayNumber: 2,
date: '2026-06-02',
title: null,
stops,
legs: [],
schedule: { entries: [], warnings: [] },
legVias: [],
geometry: [],
distance: 0,
duration: 0,
driveWarnings: [],
dayWarning: null,
...over,
}
}
const spur = (stopKey: string): AccessSpur => ({
line: [
[48, 11],
[48.001, 11.001],
],
meters: 120,
stopKey,
})
/** Der Schluessel, den assemble baut: Position auf fuenf Stellen plus beide Fahrtarten. */
const keyFor = (s: RoadtripStop) =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
const LINES: [number, number][][] = [
[
[48, 11],
[48.1, 11.1],
],
[
[48.1, 11.1],
[48.2, 11.2],
],
[
[48.2, 11.2],
[48.3, 11.3],
],
[
[48.3, 11.3],
[48.4, 11.4],
],
]
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [],
quietDays: [],
lines: LINES,
lineDays: [1, 2, 2, 3],
accessLines: [],
vias: [],
segments: [],
totalDistance: 0,
totalDuration: 0,
totalStops: 0,
loading: false,
...over,
}
}
describe('stageMapData without a stage', () => {
it('FE-RTSTAGE-001: draws every line of the drive, none filtered away', () => {
expect(stageMapData(routes(), null, true).lines).toEqual(LINES)
})
it('FE-RTSTAGE-002: gives each line the colour of the day it belongs to', () => {
const colors = stageMapData(routes(), null, true).lineColors
expect(colors).toEqual([dayColor(1), dayColor(2), dayColor(2), dayColor(3)])
expect(colors).toHaveLength(LINES.length)
})
it('FE-RTSTAGE-003: keeps every spur and filters no markers', () => {
// Ohne Auswahl gibt es nichts zu verstecken: leere placeIds heisst "kein Filter".
const data = stageMapData(routes({ accessLines: [spur('a'), spur('b')] }), null, true)
expect(data.accessLines).toHaveLength(2)
expect(data.placeIds.size).toBe(0)
expect(data.focusPoints).toEqual([])
})
it('FE-RTSTAGE-004: day colours off leaves the paint to the map', () => {
expect(stageMapData(routes(), null, false).lineColors).toBeUndefined()
})
})
describe('stageMapData with a stage', () => {
const stage = day([stop('Kassel', { placeId: 11 }), stop('Fulda', { placeId: 22, lat: 50.55, lng: 9.68 })], {
dayNumber: 2,
})
it('FE-RTSTAGE-005: keeps only the lines of that day', () => {
expect(stageMapData(routes(), stage, true).lines).toEqual([LINES[1], LINES[2]])
})
it('FE-RTSTAGE-006: the colours stay lined up with the lines that are left', () => {
const data = stageMapData(routes(), stage, true)
expect(data.lineColors).toHaveLength(data.lines.length)
expect(data.lineColors).toEqual([dayColor(2), dayColor(2)])
})
it('FE-RTSTAGE-007: a day the routing round drew nothing for keeps no lines', () => {
expect(stageMapData(routes(), day([stop('Kassel')], { dayNumber: 9 }), true).lines).toEqual([])
})
it('FE-RTSTAGE-008: day colours off leaves the paint to the map here too', () => {
expect(stageMapData(routes(), stage, false).lineColors).toBeUndefined()
})
it('FE-RTSTAGE-009: the stage names its places and its fit points', () => {
const data = stageMapData(routes(), stage, true)
expect([...data.placeIds]).toEqual([11, 22])
expect(data.focusPoints).toEqual([
[48.1, 11.2],
[50.55, 9.68],
])
})
it('FE-RTSTAGE-010: before the routing round has any line, the stage still gets places and fit', () => {
// Sonst springt die Karte beim ersten Oeffnen ins Nichts, bis die Route da ist.
const data = stageMapData(routes({ lines: [], lineDays: [] }), stage, true)
expect(data.lines).toEqual([])
expect(data.lineColors).toBeUndefined()
expect(data.accessLines).toEqual([])
expect(data.placeIds.size).toBe(2)
expect(data.focusPoints).toHaveLength(2)
})
})
describe('stageMapData access spurs', () => {
const onStage = stop('Kassel', { lat: 51.31627, lng: 9.49797, legMode: 'car', incomingLegMode: null })
const stage = day([onStage])
it('FE-RTSTAGE-011: a spur comes along exactly when its stop does', () => {
const mine = spur(keyFor(onStage))
const elsewhere = spur(keyFor(stop('Bremen', { lat: 53.07, lng: 8.8 })))
expect(stageMapData(routes({ accessLines: [mine, elsewhere] }), stage, true).accessLines).toEqual([mine])
})
it('FE-RTSTAGE-012: a stop with no mode on one side still finds its spur', () => {
// Der letzte Stopp des Tages faehrt nirgends mehr hin, der erste kommt von nirgends her.
const last = stop('Fulda', { lat: 50.55221, lng: 9.6752, legMode: null, incomingLegMode: 'car' })
const spurs = [spur(keyFor(onStage)), spur(keyFor(last))]
expect(stageMapData(routes({ accessLines: spurs }), day([onStage, last]), true).accessLines).toEqual(spurs)
})
it('FE-RTSTAGE-013: the key carries both leg modes, so the same spot in another mode is a different stop', () => {
// Zwei Stopps auf demselben Parkplatz unterscheiden sich nur hier.
const ferry = spur(`${onStage.lat.toFixed(5)},${onStage.lng.toFixed(5)},ferry,`)
expect(stageMapData(routes({ accessLines: [ferry] }), stage, true).accessLines).toEqual([])
})
})
describe('stagePlaceIds', () => {
it('FE-RTSTAGE-014: leaves the automatic night out, it has no place behind it', () => {
const stage = day([
stop('Kassel', { placeId: 11 }),
stop('Nacht', { placeId: 0, automaticNight: { phase: 'end', fromDayNumber: 1 } }),
stop('Fulda', { placeId: 22 }),
])
expect([...stagePlaceIds(stage)]).toEqual([11, 22])
})
})
describe('stagePoints', () => {
it('FE-RTSTAGE-015: keeps order and drops what cannot be put on a map', () => {
const stage = day([
stop('Kassel', { lat: 51.31, lng: 9.49 }),
stop('Ohne', { lat: Number.NaN, lng: 9.5 }),
stop('Auch ohne', { lat: 50.5, lng: Number.NaN }),
stop('Fulda', { lat: 50.55, lng: 9.68 }),
])
expect(stagePoints(stage)).toEqual([
[51.31, 9.49],
[50.55, 9.68],
])
})
})
@@ -0,0 +1,93 @@
import { dayColor } from './dayColors'
import type { AccessSpur, RoadtripDay, RoadtripRoutes } from '@trek/shared/roadtrip'
/**
* What the map draws for ONE stage of a drive.
*
* The rail hides a folded day by filtering the whole trip's lines; a phone shows one
* day and hides the rest, which is the same filter read from the other end. Rather
* than a second `collapsedRoadtripDays` set built backwards from the selection (a
* set that would then have to be kept out of the desktop's own folding), the
* selection picks its lines here, and the untouched whole-trip arrays stay available
* for the "all days" view.
*
* `lineDays` runs parallel to `lines`, so both are filtered in one pass and the
* colours stay lined up with what is left. Same rule the desktop follows.
*/
export interface StageMapData {
lines: [number, number][][]
lineColors: { line: string; casing: string }[] | undefined
accessLines: AccessSpur[]
/** Place ids on this stage, for the marker filter. Empty means "no filter". */
placeIds: Set<number>
focusPoints: [number, number][]
}
const EMPTY: StageMapData = {
lines: [],
lineColors: undefined,
accessLines: [],
placeIds: new Set(),
focusPoints: [],
}
export function stageMapData(
routes: RoadtripRoutes,
stage: RoadtripDay | null,
dayColorsOn: boolean,
): StageMapData {
// No stage selected is the "whole drive" view, which is every line in its own
// colour, the best overview the addon has on a phone, and it costs nothing
// because the routing round has already produced all of it.
if (!stage) {
return {
lines: routes.lines,
lineColors: dayColorsOn ? routes.lineDays.map(n => dayColor(n)) : undefined,
accessLines: routes.accessLines,
placeIds: new Set(),
focusPoints: [],
}
}
if (!routes.lines.length) return { ...EMPTY, placeIds: stagePlaceIds(stage), focusPoints: stagePoints(stage) }
const keep: number[] = []
routes.lineDays.forEach((n, i) => { if (n === stage.dayNumber) keep.push(i) })
const placeIds = stagePlaceIds(stage)
return {
lines: keep.map(i => routes.lines[i]),
lineColors: dayColorsOn ? keep.map(() => dayColor(stage.dayNumber)) : undefined,
// A spur belongs to a stop, so it comes along exactly when its stop does.
accessLines: routes.accessLines.filter(spur => spurOnStage(spur, stage)),
placeIds,
focusPoints: stagePoints(stage),
}
}
/**
* The places drawn on this stage.
*
* Automatic nights are left out: they are the shell's own marker for where a day
* ended, not somewhere anybody stops, and they have no place row behind them.
*/
export function stagePlaceIds(stage: RoadtripDay): Set<number> {
return new Set(stage.stops.filter(s => !s.automaticNight).map(s => s.placeId))
}
/** The stage's stops as coordinates, for the fit. */
export function stagePoints(stage: RoadtripDay): [number, number][] {
return stage.stops
.filter(s => Number.isFinite(s.lat) && Number.isFinite(s.lng))
.map(s => [s.lat, s.lng] as [number, number])
}
/**
* A spur's key is the one `assemble` builds: position to five decimals plus both
* leg modes. Rebuilt here rather than imported because it is assemble's private
* detail, and the five decimals are what make it exact enough to tell two stops in
* the same car park apart.
*/
function spurOnStage(spur: AccessSpur, stage: RoadtripDay): boolean {
return stage.stops.some(s =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}` === spur.stopKey)
}
@@ -30,6 +30,9 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
source: 'openstreetmap',
})
/** Long enough to tile into a handful of boxes, so a cap and a window can bite. */
const LONG: LatLng[] = Array.from({ length: 12 }, (_, i) => ({ lat: 53.55 - i * 0.05, lng: 9.99 - i * 0.2 }))
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false })
beforeEach(() => {
@@ -192,4 +195,70 @@ describe('useCorridorPois', () => {
unmount()
expect(seen!.aborted).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-013: a window cuts the boxes, not the line the hits are measured on', async () => {
const full = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
act(() => { full.result.current.search() })
await waitFor(() => expect(full.result.current.loading).toBe(false))
const whole = pois.mock.calls.length
const spine = full.result.current.spine.length
pois.mockClear()
const part = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
act(() => { part.result.current.search({ fromKm: 0, toKm: 20 }) })
await waitFor(() => expect(part.result.current.loading).toBe(false))
// Fewer requests, which is the whole point of asking about a stretch...
expect(pois.mock.calls.length).toBeLessThan(whole)
// ...and the same spine, which is what keeps `alongKm` meaning the same thing. A
// cut spine would renumber every hit and every stop the moment the reach moved.
expect(part.result.current.spine.length).toBe(spine)
})
it('FE-ROADTRIP-CORRIDOR-014: the window belongs to the search, so the last answer survives the next question', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search({ fromKm: 0, toKm: 20 }) })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
// Re-rendering with a different reach in mind must not empty the list: only
// asking again does. The window is an argument, never a dependency.
expect(result.current.results.map(r => r.osm_id)).toEqual(['a'])
})
it('FE-ROADTRIP-CORRIDOR-015: a budget caps the boxes and says the tail was left out', async () => {
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 2, maxRetries: null, deadlineMs: null } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(pois.mock.calls.length).toBe(2)
expect(result.current.capped).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-016: a capped retry pass reports the boxes it did not ask again', async () => {
pois.mockRejectedValue(new Error('mirror down'))
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 4, maxRetries: 1, deadlineMs: null } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
// Four boxes, four failures, one retried: the three never asked again are still
// failures, because "we did not look" is the honest reading either way.
expect(pois.mock.calls.length).toBe(5)
expect(result.current.failedAreas).toBe(4)
expect(result.current.error).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-017: a deadline stops starting boxes and files the rest as unsearched', async () => {
// Every box answers, slowly. The deadline is what ends the run, not the answers.
pois.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(answer()), 40)))
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 16, maxRetries: 0, deadlineMs: 1 } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 })
expect(result.current.failedAreas).toBeGreaterThan(0)
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1)
})
})
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories'
/** A POI that is actually on the way, with the two numbers that make it one. */
@@ -30,16 +31,24 @@ export interface CorridorSearch {
error: boolean
/** The thinned line every `alongKm` above is measured along. */
spine: LatLng[]
search: () => void
/**
* Asks, optionally only about a stretch of the line.
*
* The window is an argument rather than hook state because it belongs to the question:
* one traveller asks about the fifty kilometres ahead and the next about the whole day,
* off the same route and the same categories, and neither should make the other's
* answer disappear before it was read.
*/
search: (window?: CorridorWindow | null) => void
clear: () => void
}
/**
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
* would otherwise be dozens of requests against a shared public mirror; past this the
* search stops and says so rather than quietly covering half the route.
*/
const MAX_TILES = 16
/** A stretch of the drive, in kilometres along the spine. */
export interface CorridorWindow {
fromKm: number
toKm: number
}
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
const REQUEST_SPACING_MS = 250
/** How many boxes are in flight at once. */
@@ -64,7 +73,21 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
* every route change, because each run is several Overpass requests and the mirrors are
* shared. Results stay until the categories or the route change.
*/
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
export function useCorridorPois(
line: LatLng[],
categories: string[],
widthKm: number,
/**
* What one search may cost.
*
* Absent means the desktop's behaviour, unchanged: every box of the whole day, every
* failed box retried, no deadline. The phone passes a smaller ceiling, because a cell
* is not a desk and a search that keeps the radio warm for a minute is a search that
* costs battery somebody is navigating on.
*/
options?: { budget?: CorridorBudget },
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -104,15 +127,22 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const catKey = categories.join(',')
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
const search = useCallback(() => {
const search = useCallback((window?: CorridorWindow | null) => {
abortRef.current?.abort()
if (spine.length < 2 || categories.length === 0) return
const controller = new AbortController()
abortRef.current = controller
const runId = ++runIdRef.current
const allTiles = corridorTiles(spine, widthKm)
const tiles = allTiles.slice(0, MAX_TILES)
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
const allTiles = corridorTiles(tileLine, widthKm)
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
setCapped(allTiles.length > tiles.length)
setFailedSources([])
@@ -180,6 +210,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const worker = async (): Promise<void> => {
while (next < jobs.length) {
if (controller.signal.aborted || runId !== runIdRef.current) return
// Out of time stops STARTING boxes; whatever answered stays on screen and the
// rest reports itself as unsearched, the same way a failed box does.
if (outOfTime()) { failures += jobs.length - next; setFailedAreas(failures); return }
const job = jobs[next++]
if (!(await collect(job))) retryable.push(job)
done++
@@ -195,8 +228,11 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
// One more pass over what did not answer. Overpass mirrors time out under load far
// more often than they are truly empty, and reporting "4 stretches went unsearched"
// when a second ask would have answered them is the wrong end of the trade.
for (const job of retryable) {
const retries = budget.maxRetries == null ? retryable : retryable.slice(0, budget.maxRetries)
failures += retryable.length - retries.length
for (const job of retries) {
if (controller.signal.aborted || runId !== runIdRef.current) return
if (outOfTime()) { failures += retries.length - retries.indexOf(job); break }
if (await collect(job)) continue
failures++
setFailedAreas(failures)
@@ -205,7 +241,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale])
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
import type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
@@ -74,7 +75,16 @@ export interface RoadtripCorridor {
* answer if you cannot see which one is on your side of the road — so the results have to
* live above both the panel and the map rather than inside the panel.
*/
export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | string | null): RoadtripCorridor {
export function useRoadtripCorridor(
routes: RoadtripRoutes,
tripId?: number | string | null,
/**
* What one search may cost. Absent is the desktop's ceiling; the phone hands down a
* smaller one. Not a window: which stretch of the day to ask about is decided per
* search, by whoever presses the button, and travels as an argument to `search`.
*/
options?: { budget?: CorridorBudget },
): RoadtripCorridor {
const [dayId, setDayId] = useState<string>('')
const [categories, setCategories] = useState<string[]>(['fuel'])
const [widthKm, setWidthKm] = useState<number>(5)
@@ -109,7 +119,7 @@ export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | st
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
const search = useCorridorPois(line, categories, widthKm)
const search = useCorridorPois(line, categories, widthKm, options)
/**
* Whether what the panel looks for has been decided yet.