diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0af947b93..5d17af5f6 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -606,6 +606,9 @@ 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 = [], selectedPlaceId = null, hoverDisabled = false, @@ -1032,7 +1035,7 @@ export const MapView = memo(function MapView({ { @@ -1046,7 +1049,7 @@ export const MapView = memo(function MapView({ , ] : [])} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 015e9d604..0e830a51f 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -123,6 +123,11 @@ 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[] selectedPlaceId?: number | null onMarkerClick?: (id: number) => void @@ -606,6 +611,7 @@ export function MapViewGL({ routeVias = NO_ROUTE_VIAS, accessLines = NO_ACCESS_LINES, route = null, + routeColors = null, routeSegments = NO_ROUTE_SEGMENTS, selectedPlaceId = null, hoverDisabled = false, @@ -762,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' @@ -850,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. * @@ -1178,7 +1219,9 @@ export function MapViewGL({ id: 'trip-route-casing', type: 'line', source: 'trip-route', - paint: { 'line-color': '#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 @@ -1195,7 +1238,7 @@ export function MapViewGL({ id: 'trip-route-line', type: 'line', source: 'trip-route', - paint: { 'line-color': '#0a84ff', 'line-width': 5 }, + paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) } @@ -1885,13 +1928,18 @@ export function MapViewGL({ if (!map) return const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined if (!src) return - const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({ - type: 'Feature' as const, - properties: {}, - 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, mapReady]) + }, [route, routeColors, mapReady]) // Update access-spur geojson useEffect(() => { diff --git a/client/src/components/Map/RoadtripViaMarkers.tsx b/client/src/components/Map/RoadtripViaMarkers.tsx index fb6cab5ff..484258cbb 100644 --- a/client/src/components/Map/RoadtripViaMarkers.tsx +++ b/client/src/components/Map/RoadtripViaMarkers.tsx @@ -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 ( <> diff --git a/client/src/components/Roadtrip/AnchoredPopover.test.tsx b/client/src/components/Roadtrip/AnchoredPopover.test.tsx new file mode 100644 index 000000000..0507a1272 --- /dev/null +++ b/client/src/components/Roadtrip/AnchoredPopover.test.tsx @@ -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( + + + , + ) + 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( + + + , + ) + + 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( + + + , + ) + + // 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() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.test.tsx b/client/src/components/Roadtrip/RangeStrip.test.tsx new file mode 100644 index 000000000..25138fc77 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.test.tsx @@ -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> = {}) => ( + +) + +/** + * 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('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() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.tsx b/client/src/components/Roadtrip/RangeStrip.tsx new file mode 100644 index 000000000..b7c143e28 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.tsx @@ -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 ( +
+ {/* 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. */} +
+ + + {shown ?? '—'} + + {shown !== null ? ( + + {getDistanceUnitLabel(unit)} + + ) : null} + + + {shown === null + ? t('roadtrip.limit.rangeEmpty') + : t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')} + +
+ +
+
+
+
+
+
+
+ + {/* 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. */} +
+ {note} + {mask ? ( + {t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })} + ) : 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 ? ( + + + {t('roadtrip.limit.wearNote', { percent: wear })} + + ) : null} +
+
+ ) +} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index be56236b4..59dcc33b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render({ui} ({ 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() - // "Fuel" also names an option in the kind picker; the group header is the one - // inside a
. - 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() - 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', () => { diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index b4dd2ac26..c5b69f5b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -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
{poi.name}
- {/* 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. */} -
- {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - + {/* 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. */} +
+ + {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} + + {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 ( -
-
+ // 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. +
+ {/* 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. */} +
-
    + {/* 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. */} + + +
+ {/* 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. */} + 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. */} + 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 ? ( + <> + + {/* `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. */} + + + ) : null} + +
) : null} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index aef384da9..dfd206cfe 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -12,6 +12,11 @@ const wrap = (ui: React.ReactElement) => render({ui} & { 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 +36,12 @@ function day(over: Partial = {}): 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 +49,13 @@ function day(over: Partial = {}): 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 +63,7 @@ function routes(over: Partial = {}): RoadtripRoutes { return { days: [day()], lines: [], + lineDays: [], segments: [], accessLines: [], vias: [], @@ -201,11 +214,15 @@ describe('RoadtripSidebar', () => { } wrap() - // 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 +241,9 @@ describe('RoadtripSidebar', () => { } wrap() - 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() }) @@ -557,6 +576,73 @@ describe('RoadtripSidebar', () => { wrap() 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() + expect(screen.getByText('From day 1')).toBeTruthy() + }) + + it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => { + wrap() + // 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() + + // 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() + expect(screen.queryByText(/From day/)).toBeNull() + }) + }) }) describe('RoadtripModeSwitch', () => { diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 984398a33..22e9d271d 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X, BatteryCharging, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' -import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange, type VehicleKind} from './roadtripModel' +import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { STOP_KIND_BY_KEY } from './stopKinds' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' @@ -21,6 +21,8 @@ import StopFillPicker from './StopFillPicker' import { useVehicleRange } from './useVehicleRange' import type { RoadtripStopType } from '@trek/shared' import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' +import type { SpillMark } from './nightSpill' +import { dayColor } from './dayColors' import type { RouteVia } from '../../types' import { FS } from './typeScale' import type { RouteSegment } from '../../types' @@ -83,6 +85,16 @@ interface RoadtripSidebarProps { viaCounts?: Record /** The name of the track each day follows, so the badge can say which road it is. */ trackNames?: Record + /** + * Days folded down to their header, by id. + * + * A long trip is a long rail, and reading the shape of one day means scrolling past the + * four before it. Folding is also what takes that day OFF the map: a card with nothing + * under its header would otherwise still be drawing its road. + */ + collapsedDayIds?: Set + /** Absent leaves every card open and its header unclickable. */ + onToggleDay?: (dayId: number) => void } const MODE_ICON: Record = { @@ -109,6 +121,15 @@ const RAIL_DASH: React.CSSProperties = { backgroundImage: 'repeating-linear-gradient(var(--border-primary) 0 4px, transparent 4px 8px)', } +/** + * A figure in the two-part shell the rail uses everywhere: what it is on the left, the + * number on the right, one hairline between them. + * + * Named because more than one place builds it now — the walk from the road, and the two + * figures on a refuel offer — and a second copy is how the two drift apart. + */ +const FIGURE_BADGE = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge' + /** A 24px disc — a stop's number, or a service stop's icon. */ const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full' @@ -414,60 +435,115 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // What the traveller drives, so the band says the right word and offers the right + // thing. An electric car does not run out of TANK, and a lamp shaped like a pump on a + // band about a battery is the kind of detail that makes the rest look careless. + const { vehicleKind } = useVehicleRange() + const electric = vehicleKind === 'electric' + const DryIcon = electric ? Zap : Fuel const key = `${dayId}:${dry.legIndex}` const open = refuel.openFor === key const settled = open && !refuel.loading && refuel.outcome return ( -
- - - -
- {/* Built as a drive band, because that is what it belongs to: the leg the fuel - runs out on. Same height, same type, same rail. The warning ring is the only - difference, and it is the whole message — this is the band where the tank - ends. */} -
-
- - - {t('roadtrip.refuel.dry')} - - {/* How far INTO this leg, where a drive band keeps its figures. Not the range - that was crossed: that is the traveller's own setting, says nothing about - where, and made every band on a day read the same number. */} - - {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} - - {/* An icon, the size of the shuffle mark a drive band carries, because the - band has to stay one line high beside its neighbours. What it does is in - the tooltip and in the label a screen reader gets. */} +
+ {/* Across the whole rail, marker column included, the way the day change used to + be drawn. It belongs to no stop: the tank runs out between two of them, and + hanging it off the one after would put it where somebody finds out rather than + where it happens. Full width is also what makes it the ONLY thing saying this — + the per-stop "so far on this tank" badge stands down wherever a search can be + run, because after the band has said where the fuel ends, every later stop + repeating it with a bigger number is the same message again. */} +
+
+ {/* Out of fuel is a thing that happens to the DRIVE, so the mascot is the one + with the vehicle, and it is not enjoying it. */} + +
+
+ {t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')} +
+ {/* How far INTO this leg, where a drive band keeps its figures. Not the + range that was crossed: that is the traveller's own setting, says + nothing about where, and made every band on a day read the same + number. */} +
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} +
+
+ {/* The low-fuel lamp IS the button. + It was a lamp beside a magnifier, which is the same thing said twice: the + lamp says the tank is empty and the magnifier offers to do something + about it, and nobody reads a dashboard warning as decoration. So the lamp + glows, and pressing it goes looking. Larger than the 18px marks a drive + band carries, because unlike those it is the one thing in the band worth + pressing. What it does is in the tooltip and in the screen reader label. + + Once a search is open the lamp steps aside for the state that matters: + the way to close the answer, or the way to ask again. An answer that + found nothing leaves a button rather than a dead end — the place search + is a shared public service that does time out, and "it did not answer" + with no way to retry reads as broken rather than as busy. */} {open && (refuel.loading || refuel.results.length) ? ( ) : ( - /* An answer that found nothing leaves the button, not a dead end. The place - search is a shared public service that does time out, and "it did not - answer" with no way to ask again reads as broken rather than as busy. */ - + )} @@ -479,31 +555,94 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { {settled ? ( refuel.results.length ? ( -
    +
      {/* Three at most. This is an offer beside a plan, not a list to browse; - the corridor panel is where somebody goes to see all of them. */} - {refuel.results.slice(0, 3).map(poi => ( -
    • - - {poi.name} - - - {/* What is left when the car draws level, the figure that decides - whether this one is any use. The detour is already in it. */} - {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} - - {onAccept ? ( - - ) : null} -
    • - ))} + the corridor panel is where somebody goes to see all of them. + + Each one on its own surface rather than as bare text in the band: the + band is the problem and these are the answers, and a row somebody is + meant to press has to look like it can be. Two lines, because one was + not enough to tell them apart — measured on a real day the top three + came back as "Vattenfall InCharge" three times over, identical but for + a number nobody could see the meaning of. */} + {refuel.results.slice(0, 3).map(poi => { + const kind = STOP_KIND_BY_KEY[poi.category] + const KindIcon = kind?.Icon ?? Fuel + return ( +
    • + {/* Logical padding, not left/right: the rail runs the other way in Arabic and + the disc has to keep its distance from the reading edge either way. */} +
      + + + + + + {poi.name} + + {/* The two figures that decide it, in the order they decide it, + and as two badges rather than one line with a middot in it. + No value in this rail is ever joined to another that way: + they are separate facts, and a separator invites them to be + read as one number about one thing. + + The detour is what the list is SORTED by — everything here + is reachable already, so what separates them is what the + stop costs — and it is also the only thing telling three + branches of one chain apart. What is left in the tank when + the car draws level comes second, with the detour already + counted into it. The badge carries the figure and the + tooltip says which one it is, which is how two numbers stay + legible in a column this narrow. */} + + + + + + + + {formatDistance(poi.offRouteKm, distanceUnit)} + + + + + + + + + + {formatDistance(Math.round(poi.spareKm), distanceUnit)} + + + + + + {onAccept ? ( + + + + ) : null} +
      +
    • + ) + })}
    ) : ( /* Three different sentences for three different facts. "Nothing on this @@ -518,7 +657,6 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { ) ) : null} -
) @@ -809,53 +947,6 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea ) } -/** - * Midnight, marked where it happens — in the chain, between the two stops the drive runs - * between, rather than as a fourth badge hanging off one of them. - * - * The schedule has carried this since it was written and nothing ever drew it: a day - * running past midnight showed "02:30" as if it were tonight. - */ -/** - * Where the chain crosses midnight. - * - * The one band in the rail that breaks the line instead of hanging off it, and the only - * thing here that moves: a night is not a place and not a drive, it is the day running - * out, and the rail should look like it stops. So it spans the marker column too, the - * dashes end above it and start again below, and the mascot dozes in the gap with three - * z's drifting off it. - * - * The ground is mixed rather than the soft info token: that token is translucent in the - * dark theme, and the mascot cuts its eyes out in the colour behind it, which would show - * the body through them. Mixing against the card keeps it opaque in both themes. - */ -function OvernightBreak(): React.ReactElement { - const { t } = useTranslation() - const night = 'color-mix(in srgb, var(--info) 13%, var(--bg-card))' - return ( -
- - - {t('roadtrip.warn.overnight')} - - {/* Three z's on one baseline, each starting a third of the loop after the last, so - one is always on its way up. Purely decorative: the word beside it already says - what this band is. */} - - z - z - z - -
- ) -} - /** * The arrival — the one value in the rail the user may have decided themselves. * @@ -1064,15 +1155,150 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React ) } +/** + * What a night drive leaves on the next morning's card. + * + * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 + * and arriving at 01:23 arrives tomorrow, and the rail draws it there — under tomorrow's + * date, with the night it came through kept in front of it so the kilometres reach the + * stops rather than being left on a day nobody drives them on. + * + * Nothing has been written to say so. The stops still belong to the day they were planned + * on and the day plan still shows them there; this is the arrangement the arrival times + * imply, worked out again every time they change. Shorten the stay before the night drive + * and the first stop crosses back on its own. + * + * The ground fades from night at the head to the card's own surface at the foot, because + * that is what the block is: the end of one day handed to the beginning of the next. + */ +function SpillBlock({ spill, children }: { + spill: SpillMark + children: React.ReactNode +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const leg = spill.leg + const departure = spill.departure + // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes + // out in the colour behind it, and a translucent ground would show its body through + // them. The same reason the old overnight band mixed instead of tinting. + const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' + const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' + const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' + return ( +
  • + {/* Moonlight from the left, falling on the mascot. A radial wash rather than a + second background colour: it has no edge of its own, so the head reads as lit + rather than as another box stacked on the first. */} +
    +
    + + + {t('roadtrip.spill.title', { number: spill.fromDayNumber })} + + {/* Three z's on one baseline, each starting a third of the loop after the last, + so one is always on its way up. Decorative: the line beside it already says + what the block is. */} + + z + z + z + +
    + {/* The drive itself, in the band the rail uses everywhere else — this is the + reason the block exists, and the kilometres on it are the ones the card's + header now counts. Not clickable: alternatives are asked for on the day the + leg is stored on, and offering the same leg twice would be two answers. */} +
    + + + +
    + {/* Shaped like every other drive in the rail: what it cost on the left, the + clock on the right, in the column every arrival in this card already + reads down. The time used to sit inside the sentence, which put a + reading in the one place the eye does not look for one — and gave the + row two competing figures with no order between them. */} +
    + + + {leg + ? t('roadtrip.leg.driveText', { + distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), + time: formatDurationShort(leg.duration ?? 0), + }) + : t('roadtrip.leg.pending')} + + {/* Departure, not arrival, so it says so in words: everything else in this + column is a time of arrival, and a bare 23:57 there would read as one. + The stop it leaves from is not named — it is drawn on yesterday's card + directly above, and a second row for it would read as a stop made + twice. */} + {departure ? ( + + {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} + + ) : null} +
    +
    +
    +
    + {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the + inside edge lands one pixel in unless the padding gives that pixel back. It is + the difference between the numbers in here standing on the same line as the ones + below and standing almost on it. */} +
      {children}
    +
  • + ) +} + /** * One day, one card: its own numbers in its own head, its stops chained below. * * Stops count from one inside the day because that is what the map badges on its markers * (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across * the trip would put "17" beside a pin the map calls "3". + * + * A card is no longer one stored day. It is one DATE, and it draws whatever is reached on + * it — which on the morning after a night drive means a stretch that is stored on + * yesterday. Every stop keeps the day it is stored on in `block.source`, so a reorder, a + * move, a stay edit or a refuel offer still names the day the server knows it by. See + * `nightSpill.ts`. */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: { day: RoadtripDay + /** Folded down to the header, and off the map with it. */ + collapsed?: boolean + onToggle?: () => void selectedAssignmentId?: number | null onSelectStop?: (placeId: number, assignmentId: number) => void onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void @@ -1094,13 +1320,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on loading?: boolean }): React.ReactElement { const { from, setFrom, dropAt, setDropAt } = drag - const dragging = from?.dayId === day.dayId ? from.index : null // Which disc the picker hangs under, and for which stop. One at a time: two open // popovers over the same rail is two answers to one question. const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // The colour the map draws this day in, or none at all while the map is drawing one + // blue line for the whole trip. + const dayColorsOn = useSettingsStore(s => !!s.settings.roadtrip_day_colors) + const tint = dayColorsOn ? dayColor(day.dayNumber).line : null const last = day.stops.length - 1 /** * The findings a stop wears, minus the one the refuel band already says better. @@ -1110,12 +1339,147 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * at once says the same thing twice, in the wrong order — the answer above the problem * — so the warning stands down wherever the band is showing. */ - const showingBand = !!refuel && !loading && !!day.dryPoints?.length + const showingBand = !!refuel && !loading const findingsFor = (i: number) => day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 + + /** + * One row of the chain: the stop, the drive leaving it, whatever hangs off that drive. + * + * The day's own index `i` addresses everything that belongs to this DATE — the leg, the + * schedule entry, the dry points. Everything that WRITES uses `stop.ownerDayId` and + * `stop.ownerIndex` instead, because a stop driven onto this date through the night is + * still stored on the day it set off from (`nightSpill.ts`). + */ + const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => { + const service = isServiceStopType(stop.stopType) + if (!service) counted += 1 + // Every finding at this index is read on its own. Taking the first match let an + // overnight crossing swallow the "you arrive late" flag without a trace. + const marks = day.schedule.warnings.filter(w => w.index === i) + const ownDay = stop.ownerDayId + const ownIndex = stop.ownerIndex + return ( +
  • { + setFrom({ dayId: ownDay, index: ownIndex, assignmentId: stop.assignmentId }) + e.dataTransfer.effectAllowed = 'move' + // Firefox refuses to start a drag without payload, even an unused one. + e.dataTransfer.setData('text/plain', String(stop.assignmentId)) + } : undefined} + onDragEnd={() => { setFrom(null); setDropAt(null) }} + onDragOver={onReorderStop && from ? e => { + e.preventDefault() + if (dropAt?.dayId !== ownDay || dropAt.index !== ownIndex) setDropAt({ dayId: ownDay, index: ownIndex }) + } : undefined} + onDrop={onReorderStop && from ? e => { + e.preventDefault() + const src = from + setFrom(null) + setDropAt(null) + if (src.dayId === ownDay) { + if (src.index !== ownIndex) onReorderStop(ownDay, src.assignmentId, ownIndex) + } else { + onMoveStopToDay?.(src.dayId, src.assignmentId, ownDay, ownIndex) + } + } : undefined} + className={`rounded-lg transition-opacity ${from?.dayId === ownDay && from.index === ownIndex ? 'opacity-40' : ''} ${ + dropAt?.dayId === ownDay && dropAt.index === ownIndex && from && !(from.dayId === ownDay && from.index === ownIndex) + ? 'ring-2 ring-inset ring-accent' + : '' + }`} + > + {service ? ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + ) : ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + continues={i < last} + starts={i === 0} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined} + canMove={{ up: i > 0, down: i < last }} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + )} + {i < last ? ( + onAskAlternatives(day.dayId, i) : undefined} + alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} + /> + ) : null} + {/* Only on the leg the fuel actually runs out on, and only once the day has + finished routing: the warnings are republished after every routing task + and the early ones are wrong, so an offer that appears and moves while + the trip loads reads as a fault. */} + {refuel && !loading + ? (day.dryPoints ?? []) + .filter(dry => dry.legIndex === i) + .map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} + /> + )) + : null} + {/* After the band, because a plugin halt happens on the drive it describes + rather than before setting off. */} + {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) + } + + /** + * The chain in runs: the stretches driven onto this date through the night, and the + * day's own stops between and after them. + * + * Built rather than mapped straight through, because a spill is drawn inside a block of + * its own and a block cannot be opened halfway down a `map`. + */ + const runs: { spill: SpillMark | null; from: number; to: number }[] = [] + { + let at = 0 + for (const spill of day.spills ?? []) { + if (spill.at > at) runs.push({ spill: null, from: at, to: spill.at }) + runs.push({ spill, from: spill.at, to: spill.at + spill.count }) + at = spill.at + spill.count + } + if (at < day.stops.length) runs.push({ spill: null, from: at, to: day.stops.length }) + } + return (
    {/* Centred, with the day's facts as badges beneath its name: at this width a row @@ -1125,7 +1489,38 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on and the list below holds its stops, and `--bg-hover` at 40% is 1% black in light mode — a separation nobody could see. `--bg-secondary` lifts off the card in both themes while staying a step under the badges sitting on it. */} -
    +
    { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } + }, + } : {})} + // The margin and the bottom rule belong to the header only while there is a list + // under them. Folded, they left a white strip of card below the tinted header — + // and once the day is tinted, that strip is the one thing on the card that is not. + className={`border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3 ${collapsed ? '' : 'mb-1 border-b'} ${ + onToggle ? 'cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : '' + }`} + // Washed with the colour this day is drawn in, and only while the map is drawing + // days in colour: on the map the colour is what tells one day from the next, and + // a rail that ignored it would leave the reader matching a green line to a card + // that gives no sign of being the green one. A tenth of the hue is enough to + // recognise and quiet enough that the day's own facts still read first. + style={tint ? { + backgroundImage: `linear-gradient(180deg, color-mix(in srgb, ${tint} 13%, var(--bg-secondary)), color-mix(in srgb, ${tint} 5%, var(--bg-secondary)))`, + borderBottomColor: `color-mix(in srgb, ${tint} 30%, transparent)`, + } : undefined} + >

    {day.date ? ( -