feat(roadtrip): other ways to drive a leg, on the phone

The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
This commit is contained in:
Maurice
2026-09-17 00:10:51 +00:00
parent 9b415c8ab9
commit 6a6c7f1882
25 changed files with 1572 additions and 57 deletions
@@ -2196,6 +2196,62 @@ describe('MapViewGL', () => {
)
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-085: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', async () => {
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
// the bottom. The flat 40px margin put the ends of a framed leg under either of them.
const original = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
try {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
expect(glMap.fitBounds.mock.calls[0][1]).toMatchObject({ padding: chrome, maxZoom: 15 })
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
const places = [buildMapPlace({ id: 88, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 89, lat: 48.2, lng: 2.2 })]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
// And without one, the focus fit falls back to that margin too.
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={stage} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(3)
expect(glMap.fitBounds.mock.calls[2][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: original })
}
})
it('FE-COMP-MAPVIEWGL-086: the caller padding is read by value, so only new numbers refit the points on screen', async () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// A parent that builds the object inline hands a new one on every render. The camera
// belongs to the traveller between fits, so that alone must not take it back.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// New numbers mean the chrome moved, so the same points are framed again around it.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 120, right: 16, bottom: 98, left: 16 } })
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })