mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be argued with rather than only read. Reshaping. A via records which stop it follows by position, and that position is resolved fresh every time the day is routed — so inserting, reordering or removing a stop moved the ground under every via after it, and the drive silently snapped back to the road it had been steered away from while the handle still sat on the map where it was left. The correction is computed against the day as it stands and written with the same change, in one transaction: a half-applied re-anchoring mixes two numbering schemes in one day. The one judgement that needs geometry — which half of a split leg a via belongs to when a stop lands in the middle of it — is measured on the road actually driven, so a fuel stop added to a bent leg keeps the bend on the side it belongs to. Alternatives. OSRM answers most long legs with exactly one route, which read as "there is no other way" when a perfectly good slower road existed. When it offers nothing, the same drive is asked for again without the motorway, and failing that without the tolls — one question at a time, stopping as soon as one lands. The offer says what it is rather than how much slower. Stops along the way. Everything the corridor search can find is a pause, not a destination: fuel, charging, rest areas, campsites, food and sights all sit inside the leg they were found on, carry their own colour and icon, and are left out of every stop count. That colour is defined once and read by the rail, the corridor list, the map pin and the picker — and it replaced the chains' logos, which turned a corridor full of petrol stations into a row of advertisements and left the brands with no logo on file looking like something else entirely. How long a stop takes was readable in the rail and writable nowhere; new places were given an invented hour that nobody had entered. It is now set from the rail, cleared as a zero because the update statement folds a null into "leave it alone", and new places start with nothing. Both columns are rebuilt on one type scale, anchored to the day plan's own sizes so switching tabs does not change the size of the text beside it. The rail reads as trip totals, day cards and a chain of stops with the drives between them; the corridor column as what to look for, how the search is getting on, and what it found. Six category pills over three lines became one dropdown, which is the space the results wanted. Also: the offered routes are framed on the map when the picker opens (`fitKey` carries no coordinates and each renderer reads it as "the selected day"), road trip mode does not cluster at all so the whole drive stays readable zoomed out, and the drive-time labels finally render in the app's own typeface instead of Leaflet's Helvetica. All of it in both renderers.
This commit is contained in:
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
|
||||
/**
|
||||
* The drive-time pill for one offered route.
|
||||
*
|
||||
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
|
||||
* road, and it is the same shape the GL renderers build by hand — one look across all
|
||||
* three maps.
|
||||
*/
|
||||
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
|
||||
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
|
||||
const second = note
|
||||
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
|
||||
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
|
||||
// one piece of TREK chrome on the map that is not in the app's typeface.
|
||||
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
}
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// A stop that interrupts the drive is drawn as its own small disc, before the photo
|
||||
// branch below ever gets a look at it — the brand logo a fuel search comes back with
|
||||
// is exactly what this replaces. No number badge either, for the same reason the rail
|
||||
// gives it none: it is part of the drive, not one of the day's stops.
|
||||
const service = serviceMarkerHtml(place.stop_type, isSelected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(isSelected)
|
||||
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
|
||||
iconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
const size = isSelected ? 44 : 36
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
@@ -160,30 +199,25 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// A chain shows its logo instead of the category icon — on a corridor full of petrol
|
||||
// stations the brand is what the eye looks for. The bytes come from TREK, which
|
||||
// proxies them, so the browser never tells Wikimedia what is on screen.
|
||||
const brand = brandWikidata && /^Q[1-9][0-9]{0,11}$/.test(brandWikidata) ? brandWikidata : null
|
||||
const cacheKey = brand ? `${category}:${brand}` : category
|
||||
const cached = poiIconCache.get(cacheKey)
|
||||
// One flat disc in the category's colour with its icon, and never the chain's logo.
|
||||
// The brands turned a corridor full of petrol stations into a row of advertisements,
|
||||
// they were unreadable at pin size, and a brand with no logo on file fell back to a
|
||||
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
|
||||
// the signature because the callers still have it; it simply no longer changes anything.
|
||||
void brandWikidata
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
// The logo lies over the category pin and only becomes visible once it has loaded, so
|
||||
// a brand Wikidata has no logo for (the route answers 204), a failed request and one
|
||||
// still in flight all leave the pin exactly as it would be without a brand.
|
||||
const logo = brand
|
||||
? `<img src="/api/maps/brand-logo/${brand}" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;" onload="this.style.opacity=1" onerror="this.remove()" />`
|
||||
: ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}${logo}</div>`,
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
})
|
||||
poiIconCache.set(cacheKey, icon)
|
||||
poiIconCache.set(category, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
@@ -204,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -288,9 +357,17 @@ interface BoundsControllerProps {
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -347,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
if (!focusPoints?.length) return
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints)
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -549,6 +635,17 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
alternativeRoutes,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
}: any) {
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
@@ -560,11 +657,19 @@ export const MapView = memo(function MapView({
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
eventHandlers={{
|
||||
click: () => onPoiClick?.(poi),
|
||||
// Wired on add rather than in a layout effect: Leaflet builds the icon element
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
@@ -883,43 +988,113 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* The offered ways of driving one leg, over the route they replace. Clicking one
|
||||
takes it, which is the same choice the bar above the map offers. */}
|
||||
{/* Every option in blue over a white casing, after Apple Maps: they are all real
|
||||
roads, so the difference is emphasis, not category. Grey was the first attempt
|
||||
and it disappeared on Positron, which is almost entirely greys. */}
|
||||
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
|
||||
const active = activeAlternative === alt.index
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
opacity: active ? 1 : 0.9,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: () => onChooseAlternative(alt.index),
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
]
|
||||
})}
|
||||
|
||||
{/* The handles that shape the drive. After the route so they sit on top of it. */}
|
||||
{roadtripVias ? (
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -121,6 +125,39 @@ interface Props {
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
/**
|
||||
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
|
||||
* The caller decides whether that point is near enough to the drive to mean anything.
|
||||
*/
|
||||
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
|
||||
/** A click on the drawn route, for putting a via point there (#1797). */
|
||||
onRouteClick?: (lat: number, lng: number) => void
|
||||
/** The ways of driving one leg, drawn while the picker is open. */
|
||||
alternativeRoutes?: AlternativeOverlay[]
|
||||
/** Which option is being considered, so it can be lit up in its own colour. */
|
||||
activeAlternative?: number | null
|
||||
onChooseAlternative?: (index: number) => void
|
||||
/** Reports which option the pointer is over, so the list and the map agree. */
|
||||
onHighlightAlternative?: (index: number | null) => void
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/**
|
||||
* Let markers stay apart longer than usual.
|
||||
*
|
||||
* A road trip is read along a line: two stops fifty kilometres apart on the same
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -134,7 +171,76 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
// No number badge either, for the same reason the rail gives it none.
|
||||
const service = serviceMarkerHtml(place.stop_type, selected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(selected)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
|
||||
wrap.innerHTML = service
|
||||
return wrap
|
||||
}
|
||||
|
||||
const size = selected ? 44 : 36
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
@@ -423,16 +529,15 @@ function createPoiMarkerElement(category: string, brandWikidata?: string | null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The <img> for a brand logo, laid over the category pin, or '' when there is no brand.
|
||||
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
|
||||
*
|
||||
* It starts invisible and is only shown once it has loaded, so the pin underneath stays
|
||||
* exactly as it would be without a brand until there is really something to show. That
|
||||
* covers all three ways this can go: a brand Wikidata has no logo for (the route answers
|
||||
* 204), a request that fails, and one that simply has not arrived yet.
|
||||
* The logos turned a corridor full of petrol stations into a row of advertisements, were
|
||||
* unreadable at pin size, and a brand with no logo on file fell back to a different
|
||||
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
|
||||
* with its icon is what the rail, the corridor list and the map all show now.
|
||||
*/
|
||||
export function brandLogoMarkup(brandWikidata?: string | null): string {
|
||||
if (!brandWikidata || !/^Q[1-9][0-9]{0,11}$/.test(brandWikidata)) return ''
|
||||
return `<img src="/api/maps/brand-logo/${brandWikidata}" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;" onload="this.style.opacity=1" onerror="this.remove()" />`
|
||||
export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
@@ -450,6 +555,8 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -464,6 +571,15 @@ export function MapViewGL({
|
||||
onReservationClick,
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
alternativeRoutes,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
gl,
|
||||
@@ -517,12 +633,20 @@ export function MapViewGL({
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<PlacePin[]>([])
|
||||
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
|
||||
const poiCleanupRef = useRef<(() => void)[]>([])
|
||||
const onPoiDropRef = useRef(onPoiDropOnRoute)
|
||||
onPoiDropRef.current = onPoiDropOnRoute
|
||||
// Plugin map contributions — data fetched per trip, elements owned imperatively
|
||||
// like the POI markers so they survive the React render cycle.
|
||||
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
|
||||
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
|
||||
const pluginMarkersRef = useRef<PlacePin[]>([])
|
||||
const routeViaMarkersRef = useRef<PlacePin[]>([])
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
@@ -530,6 +654,8 @@ export function MapViewGL({
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
viaPinsRef.current.forEach(pin => pin.reposition())
|
||||
altLabelsRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
// Single reusable hover popup for POI markers. Planned places use the
|
||||
// cursor-following React tooltip below so they match the Leaflet map.
|
||||
@@ -537,6 +663,242 @@ export function MapViewGL({
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
|
||||
/**
|
||||
* The via handles.
|
||||
*
|
||||
* Built as plain elements and attached with the same `attachPin` the place pins use,
|
||||
* rather than the library's own marker: under MapLibre those float above the map during
|
||||
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
viaCleanupRef.current.forEach(off => off())
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
for (const via of Object.values(roadtripVias ?? {}).flat()) {
|
||||
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'
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
|
||||
viaPinsRef.current.push(pin)
|
||||
if (!onMoveVia) continue
|
||||
|
||||
let dragging = false
|
||||
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
|
||||
// sits on that surface. Stopping only pointerdown left both running at once: the
|
||||
// handle moved AND the map slid out from under it.
|
||||
const swallow = (e: Event) => e.stopPropagation()
|
||||
const onDown = (e: PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
dragging = true
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
pin.setLngLat([at.lng, at.lat])
|
||||
}
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
el.style.cursor = 'grab'
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onMoveVia(via.day_id, via.id, at.lat, at.lng)
|
||||
}
|
||||
const onContext = (e: MouseEvent) => { e.preventDefault(); onRemoveVia?.(via.day_id, via.id) }
|
||||
el.addEventListener('pointerdown', onDown)
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
el.addEventListener('pointercancel', onUp)
|
||||
el.addEventListener('contextmenu', onContext)
|
||||
el.addEventListener('mousedown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('dblclick', swallow)
|
||||
viaCleanupRef.current.push(() => {
|
||||
el.removeEventListener('pointerdown', onDown)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
el.removeEventListener('pointercancel', onUp)
|
||||
el.removeEventListener('contextmenu', onContext)
|
||||
el.removeEventListener('mousedown', swallow)
|
||||
el.removeEventListener('touchstart', swallow)
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
}, [roadtripVias, mapReady, glProvider, onMoveVia, onRemoveVia])
|
||||
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
|
||||
* route, drawn in blue on top, and three more solid colours next to it would read as
|
||||
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
|
||||
* which is the only moment a second colour helps.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const source = map.getSource?.('route-alternatives')
|
||||
if (!source?.setData) return
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (alternativeRoutes ?? []).map((alt, i) => ({
|
||||
type: 'Feature',
|
||||
properties: { index: i, active: activeAlternative === i, color: alt.color },
|
||||
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The drive time on each offered route, the way Apple Maps labels them.
|
||||
*
|
||||
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
|
||||
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
|
||||
* and the pill has a shape a text layer cannot draw. They join the render clock like
|
||||
* every other pin here, so they stay on their road while the map moves.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
for (const alt of alternativeRoutes ?? []) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
const active = activeAlternative === alt.index
|
||||
el.style.cssText = [
|
||||
// A rounded rectangle rather than a pill, and roomy enough for a second line —
|
||||
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
|
||||
// label instead of two stacked chips.
|
||||
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
|
||||
`background:${alt.labelBg}`,
|
||||
`color:${ALT_LABEL_TEXT}`,
|
||||
'border:none;border-radius:11px',
|
||||
// A <button> does not inherit the page's font, so without this the label reads in
|
||||
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
|
||||
'font-family:var(--font-system)',
|
||||
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||
'cursor:pointer;pointer-events:auto',
|
||||
// The pin is positioned by writing `transform` every frame. Any inherited
|
||||
// transition on it makes the label chase the map instead of sticking to the road,
|
||||
// and `transform` is exactly what a button picks up from the app's own styles.
|
||||
'transition:none !important;animation:none !important',
|
||||
'will-change:transform;backface-visibility:hidden',
|
||||
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
|
||||
].filter(Boolean).join(';')
|
||||
const time = document.createElement('span')
|
||||
time.textContent = alt.label
|
||||
el.appendChild(time)
|
||||
if (alt.note) {
|
||||
const note = document.createElement('span')
|
||||
note.textContent = alt.note
|
||||
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
|
||||
el.appendChild(note)
|
||||
}
|
||||
// Same reason as the via handles: without this the map pans as the label is pressed.
|
||||
el.addEventListener('mousedown', e => e.stopPropagation())
|
||||
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
|
||||
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
|
||||
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
|
||||
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
|
||||
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
|
||||
}
|
||||
return () => {
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
}
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onChooseAlternative) return
|
||||
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
|
||||
const index = e.features?.[0]?.properties?.index
|
||||
if (typeof index === 'number') onChooseAlternative(index)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
if (!map.getLayer?.('route-alt-hit')) return
|
||||
map.on('click', 'route-alt-hit', onClick)
|
||||
map.on('mouseenter', 'route-alt-hit', enter)
|
||||
map.on('mouseleave', 'route-alt-hit', leave)
|
||||
return () => {
|
||||
map.off('click', 'route-alt-hit', onClick)
|
||||
map.off('mouseenter', 'route-alt-hit', enter)
|
||||
map.off('mouseleave', 'route-alt-hit', leave)
|
||||
}
|
||||
}, [mapReady, onChooseAlternative])
|
||||
|
||||
/**
|
||||
* A click on the route line, which in a GL renderer is a layer rather than an element.
|
||||
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
|
||||
* click, so the handler can keep the add-place menu from opening underneath the via.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onRouteClick) return
|
||||
const onClick = (e: { lngLat: { lat: number; lng: number }; preventDefault?: () => void }) => {
|
||||
e.preventDefault?.()
|
||||
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
if (!map.getLayer?.(layer)) continue
|
||||
map.on('click', layer, onClick)
|
||||
map.on('mouseenter', layer, enter)
|
||||
map.on('mouseleave', layer, leave)
|
||||
}
|
||||
return () => {
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
map.off('click', layer, onClick)
|
||||
map.off('mouseenter', layer, enter)
|
||||
map.off('mouseleave', layer, leave)
|
||||
}
|
||||
}
|
||||
}, [mapReady, onRouteClick])
|
||||
|
||||
/**
|
||||
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
||||
* much once it is projected onto the routed geometry, and it works the same in all
|
||||
* three renderers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const container = map?.getContainer?.()
|
||||
if (!container || !mapReady || !onPoiDropOnRoute) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [mapReady, onPoiDropOnRoute])
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
@@ -548,6 +910,13 @@ export function MapViewGL({
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
const hoverDisabledRef = useRef(hoverDisabled)
|
||||
hoverDisabledRef.current = hoverDisabled
|
||||
// Read inside the map's own click handler, which is registered once at setup.
|
||||
const onRouteClickRef = useRef(onRouteClick)
|
||||
onRouteClickRef.current = onRouteClick
|
||||
const onChooseAlternativeRef = useRef(onChooseAlternative)
|
||||
onChooseAlternativeRef.current = onChooseAlternative
|
||||
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
|
||||
onHighlightAlternativeRef.current = onHighlightAlternative
|
||||
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
|
||||
// day plan the marker would be dropped on is not on screen on a phone anyway.
|
||||
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
|
||||
@@ -631,6 +1000,49 @@ export function MapViewGL({
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// The ways of driving one leg, offered while the picker is open (#1797). Added
|
||||
// BEFORE the route source so its layers sit underneath: the current route stays the
|
||||
// brightest thing on the map, and the options read as what they are — suggestions.
|
||||
if (!map.getSource('route-alternatives')) {
|
||||
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// A wide invisible band per option, so a pointer can pick one without hitting
|
||||
// the 3px line exactly.
|
||||
map.addLayer({
|
||||
id: 'route-alt-hit',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// A white casing under the option lines, the same trick the main route uses.
|
||||
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
|
||||
// grey dashed line on it is invisible. The casing gives every option an edge, so
|
||||
// it reads on a pale map, a dark one and a satellite tile alike.
|
||||
map.addLayer({
|
||||
id: 'route-alt-casing',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route-alt-line',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
// Dark and dashed by default so it stays subordinate to the blue route it is an
|
||||
// alternative to; the one being considered takes its own colour and thickens.
|
||||
// Every option in blue, after Apple Maps: they are all real roads, so the
|
||||
// difference between them is emphasis rather than category. The one being
|
||||
// considered thickens; nothing turns grey, because grey vanishes on Positron.
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
|
||||
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
|
||||
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -643,6 +1055,16 @@ export function MapViewGL({
|
||||
paint: { 'line-color': '#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
|
||||
// and a pointer is not that accurate, so aiming at the road was most of why
|
||||
// putting a via there felt like it did not work.
|
||||
map.addLayer({
|
||||
id: 'trip-route-hit',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
@@ -713,53 +1135,7 @@ export function MapViewGL({
|
||||
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
|
||||
}
|
||||
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: 30,
|
||||
clusterMaxZoom: 10,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
addPlaceClusterLayers(map)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const zoomToCluster = (e: any) => {
|
||||
const features = typeof map.queryRenderedFeatures === 'function'
|
||||
@@ -877,6 +1253,15 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
|
||||
) return
|
||||
// And for the drive itself while it can be reshaped: that click just put a via
|
||||
// there, and it must not also drop a place on top of it (#1797).
|
||||
if (
|
||||
onRouteClickRef.current
|
||||
&& typeof map.getLayer === 'function'
|
||||
&& map.getLayer('trip-route-hit')
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -1212,6 +1597,18 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1250,7 +1647,7 @@ export function MapViewGL({
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
@@ -1260,6 +1657,8 @@ export function MapViewGL({
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
@@ -1267,6 +1666,9 @@ export function MapViewGL({
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
@@ -1477,6 +1879,25 @@ export function MapViewGL({
|
||||
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
|
||||
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !focusPoints?.length) return
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The handles that shape a drive.
|
||||
*
|
||||
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
|
||||
* like one would read as a place on the itinerary. Dragging it redraws the route through
|
||||
* the new spot, which is the whole interaction — the same one a paper map gets when you
|
||||
* put a finger on the road you actually want to take.
|
||||
*/
|
||||
const VIA_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="
|
||||
display:block;width:12px;height:12px;border-radius:9999px;
|
||||
background:#0a84ff;border:2.5px solid #ffffff;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
|
||||
"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
interface RoadtripViaMarkersProps {
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own in `RoadtripViaMarkersGL`. */
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const vias = useMemo(() => Object.values(viasByDay).flat(), [viasByDay])
|
||||
if (!vias.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{vias.map(via => (
|
||||
<Marker
|
||||
key={`via-${via.id}`}
|
||||
position={[via.lat, via.lng]}
|
||||
icon={VIA_ICON}
|
||||
draggable={!!onMoveVia}
|
||||
zIndexOffset={400}
|
||||
eventHandlers={{
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
// and the same gesture removes things elsewhere on the map.
|
||||
contextmenu: (e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent.preventDefault()
|
||||
onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{t('roadtrip.via.hint')}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
} from './RouteCalculator'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
const FOSSGIS = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
|
||||
)
|
||||
)
|
||||
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(800, 360))
|
||||
)
|
||||
)
|
||||
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
|
||||
const distance = 5000
|
||||
const osrmDuration = 999
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/walking/:coords`, () =>
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
|
||||
)
|
||||
)
|
||||
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
)
|
||||
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
)
|
||||
)
|
||||
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
|
||||
describe('calculateRoute profiles', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
|
||||
http.get(`${FOSSGIS.cycling}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
|
||||
)
|
||||
)
|
||||
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
|
||||
|
||||
describe('calculateSegments failures', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
|
||||
})
|
||||
})
|
||||
@@ -613,3 +614,181 @@ describe('generateCoMapsUrl', () => {
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a self-hosted routing engine', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
const useOwnRouter = (base: string) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('osrm.example.org')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
|
||||
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
|
||||
|
||||
expect(asked).toContain('/route/v1/foot/')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
|
||||
useOwnRouter('https://osrm.example.org///')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).not.toContain('//route')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
|
||||
useOwnRouter(' ')
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('routing.openstreetmap.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAlternatives', () => {
|
||||
const twoRoutes = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
// The direct one, straight down the middle.
|
||||
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
|
||||
// A detour that swings well to the east before rejoining.
|
||||
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
|
||||
],
|
||||
}
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(twoRoutes)
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toContain('alternatives=3')
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].distance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
// The first is what the router gives anyway, so it needs no pinning point.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
// The second diverges at its eastern swing — saving that as a via forces this road.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
/** The router's own answer, and whatever it says when a road class is left out. */
|
||||
const withExclusions = (byExclude: Record<string, unknown>) =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
const answer = byExclude[exclude]
|
||||
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
|
||||
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
|
||||
// the identical line a second time would be a choice that is not a choice.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
|
||||
// OSRM answers most long legs with exactly one route, which used to mean the picker
|
||||
// said there was no other way — while a perfectly good slower road existed.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
// Pinnable like any other offer: the point where it leaves the direct line.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toEqual(['motorway', 'toll'])
|
||||
expect(routes[1].avoids).toBe('toll')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
|
||||
// The foot profile has no excludable classes; asking earns an InvalidOptions for
|
||||
// nothing, and a walk has no motorway to avoid in the first place.
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
|
||||
|
||||
expect(asked).toEqual([])
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
|
||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,6 @@ import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
// the matching profile so walking routes follow footpaths, not the road network.
|
||||
@@ -14,6 +12,61 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
|
||||
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'driving',
|
||||
walking: 'foot',
|
||||
cycling: 'bike',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to ask for a route.
|
||||
*
|
||||
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
|
||||
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
|
||||
* `routing_base_url` and everything routes against that instead; the server has to name
|
||||
* the same origin in its connect-src, or the browser blocks the requests silently.
|
||||
*
|
||||
* A configured base is expected to serve the standard OSRM layout,
|
||||
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
|
||||
*/
|
||||
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
if (!configured) return OSRM_PROFILE_BASE[profile]
|
||||
return `${configured.replace(/\/+$/, '')}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
* The public OSRM instances allow roughly one request a second and answer 429 above that.
|
||||
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
|
||||
* try again" from "these two points are not connected by road" — and a road trip, which
|
||||
* asks for every leg of every day, hits the first case constantly while the second is
|
||||
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
|
||||
*/
|
||||
export class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
|
||||
/** True when waiting and asking again is the right response. */
|
||||
get isRateLimit(): boolean {
|
||||
return this.status === 429 || this.status === 503
|
||||
}
|
||||
}
|
||||
|
||||
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
|
||||
function retryAfterMs(response: Response): number | null {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
if (!raw) return null
|
||||
const seconds = Number(raw)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
||||
const at = Date.parse(raw)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
@@ -45,11 +98,11 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Route could not be calculated')
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -252,10 +305,10 @@ export async function calculateSegments(
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -344,10 +397,13 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
|
||||
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -403,3 +459,146 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
}
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* One route with a road class left out, or null when the router will not or cannot.
|
||||
*
|
||||
* Every failure is a null: an instance built without excludable classes answers
|
||||
* `InvalidOptions`, a rate limit answers 429, and a leg with no way round the motorway
|
||||
* answers `NoRoute`. None of those is worth an error on screen — they all just mean this
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
try {
|
||||
const url = `${routeBaseFor(profile)}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
|
||||
if (!route?.geometry?.coordinates?.length) return null
|
||||
return {
|
||||
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
divergence: null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, not one day.
|
||||
*
|
||||
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
|
||||
* asking for a whole day would mean splitting it into one request per leg — which is the
|
||||
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
|
||||
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
|
||||
*
|
||||
* `divergence` is the point on each alternative that lies furthest from the default route.
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
|
||||
|
||||
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
|
||||
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
}))
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three, and the public hosts' one-per-second limit is
|
||||
// not spent on questions already answered.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
|
||||
/**
|
||||
* jsdom has no drag implementation, so these drive the events the browser would
|
||||
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePoiDraggable', () => {
|
||||
afterEach(() => {
|
||||
window.__dragData = null
|
||||
document.body.classList.remove('dragging-poi')
|
||||
})
|
||||
|
||||
const dragEvent = (type: string) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(e, 'dataTransfer', {
|
||||
value: {
|
||||
setData: (k: string, v: string) => { store[k] = v },
|
||||
getData: (k: string) => store[k] ?? '',
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
expect(el.getAttribute('draggable')).toBe('true')
|
||||
|
||||
const e = dragEvent('dragstart')
|
||||
el.dispatchEvent(e)
|
||||
|
||||
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
|
||||
// Mirrored on window because dataTransfer is unreadable during dragover, and the
|
||||
// map has to know what is coming while it is still being hovered.
|
||||
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(true)
|
||||
|
||||
el.dispatchEvent(dragEvent('dragend'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(false)
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const seen = vi.fn()
|
||||
document.body.appendChild(el)
|
||||
document.body.addEventListener('mousedown', seen)
|
||||
|
||||
el.dispatchEvent(down)
|
||||
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
document.body.removeEventListener('mousedown', seen)
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
|
||||
const el = document.createElement('div')
|
||||
const off = makePoiDraggable(el, 'node:9')
|
||||
off()
|
||||
|
||||
expect(el.hasAttribute('draggable')).toBe(false)
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const start = dragEvent('dragstart')
|
||||
el.dispatchEvent(start)
|
||||
|
||||
expect(draggedPoiId(start)).toBe('node:9')
|
||||
|
||||
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBe('node:9')
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
|
||||
*
|
||||
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
|
||||
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
|
||||
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
|
||||
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
|
||||
* just as well once it is projected onto the routed geometry.
|
||||
*/
|
||||
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('poiOsmId', osmId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { poiOsmId: osmId }
|
||||
el.classList.add('marker-dragging')
|
||||
document.body.classList.add('dragging-poi')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
el.classList.remove('marker-dragging')
|
||||
document.body.classList.remove('dragging-poi')
|
||||
}
|
||||
// Same reason as above: without this the map pans out from under the pointer as the
|
||||
// drag begins, and the route is impossible to aim at.
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('dragend', onDragEnd)
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
|
||||
return () => {
|
||||
el.removeAttribute('draggable')
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('dragend', onDragEnd)
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/** The OSM id being dragged, when a corridor hit is in flight. */
|
||||
export function draggedPoiId(e: DragEvent): string | null {
|
||||
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
|
||||
if (fromTransfer) return fromTransfer
|
||||
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
|
||||
return window.__dragData?.poiOsmId ?? null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
@@ -29,10 +32,10 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
* instead of the nameless grey dot an unknown category falls back to.
|
||||
*/
|
||||
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: '#0EA5E9' },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: '#10B981' },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: '#64748B' },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: '#65A30D' },
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite },
|
||||
]
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
|
||||
@@ -1219,6 +1219,47 @@ describe('TransportModal', () => {
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
// The order of the stops is the order they are driven, and `sequence` is derived
|
||||
// from it on save — so without this the only way to swap two was to retype both.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const up = screen.getAllByRole('button', { name: /Move up/i });
|
||||
const down = screen.getAllByRole('button', { name: /Move down/i });
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[down.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -216,6 +216,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// A car keeps its pick-up and return as the frame of the rental and gains the stops
|
||||
// in between (#1797): one booking, one continuous drive, several places along it.
|
||||
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
||||
|
||||
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
|
||||
const moveCarStop = (index: number, delta: number): void => {
|
||||
setCarStops(prev => {
|
||||
const to = index + delta
|
||||
if (to < 0 || to >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
;[next[index], next[to]] = [next[to], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -998,6 +1009,35 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
|
||||
{carStops.map((stop, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
{/* The order of these stops IS the route: `sequence` is the array index
|
||||
at save time, so the only way to change which one is driven first
|
||||
was to delete both and re-enter them. Buttons rather than dragging,
|
||||
because the row already carries a location picker and a time picker
|
||||
and there is nothing left to grab. */}
|
||||
{carStops.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, 1)}
|
||||
disabled={i === carStops.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect
|
||||
value={stop.location}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import { Shuffle, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
/** The very rows the map draws, so the two can never disagree about which is fastest. */
|
||||
overlays: AlternativeOverlay[]
|
||||
onChoose: (index: number) => void
|
||||
onClose: () => void
|
||||
/** Reports which option the pointer is on, so the map can light that road up. */
|
||||
onHighlight?: (index: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, offered over the map.
|
||||
*
|
||||
* A bar rather than a dialog: the answer is on the map behind it — which road each option
|
||||
* takes — so covering the map to ask about it would hide the only thing worth looking at.
|
||||
*
|
||||
* Every value here comes from the same overlay the map draws. Working them out separately
|
||||
* is what made the list label the driven route "Fastest" while offering another one
|
||||
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
|
||||
* being true the moment the road currently driven was put at the top.
|
||||
*/
|
||||
export default function RoadtripAlternativesBar({
|
||||
open, overlays, onChoose, onClose, onHighlight,
|
||||
}: RoadtripAlternativesBarProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.alt.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open.loading ? (
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
|
||||
) : open.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.alt.failed')}
|
||||
</p>
|
||||
) : overlays.length < 2 ? (
|
||||
// One route back means there is genuinely only one sensible way to drive it.
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{overlays.map(alt => (
|
||||
<button
|
||||
key={alt.index}
|
||||
type="button"
|
||||
onClick={() => onChoose(alt.index)}
|
||||
onMouseEnter={() => onHighlight?.(alt.index)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
onFocus={() => onHighlight?.(alt.index)}
|
||||
onBlur={() => onHighlight?.(null)}
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-caption font-medium tabular-nums text-content">
|
||||
{formatDistance(alt.distance / 1000, distanceUnit)}
|
||||
</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{alt.note
|
||||
? alt.note
|
||||
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
interface RoadtripCategoryPickerProps {
|
||||
/** Every kind the corridor can look for, in the order they are offered. */
|
||||
keys: readonly string[]
|
||||
meta: Record<string, { labelKey: string; Icon: LucideIcon }>
|
||||
selected: readonly string[]
|
||||
onToggle: (key: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What to look for, as one full-width control.
|
||||
*
|
||||
* Six pills wrapped onto three lines and took the height of the whole card for a set of
|
||||
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
|
||||
* to the results — which is what the column is actually for.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
|
||||
* rather than two round trips through a menu. Each row leads with its own coloured icon,
|
||||
* the same one the rail puts on its dashed line and the map puts on the route.
|
||||
*/
|
||||
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onPointer)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
|
||||
// is a riddle. Truncated when it runs long, which is what the icons in front are for.
|
||||
const summary = selected.length
|
||||
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
|
||||
: t('roadtrip.poi.looking')
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
|
||||
const Icon = meta[k].Icon
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="grid h-[18px] w-[18px] place-items-center rounded-md"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`.
|
||||
style={{ background: serviceColor(k), color: '#fff' }}
|
||||
>
|
||||
<Icon size={10} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
|
||||
>
|
||||
{keys.map(key => {
|
||||
const { labelKey, Icon } = meta[key]
|
||||
const on = selected.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
// Stays open: picking two kinds is one gesture, not two.
|
||||
onClick={() => onToggle(key)}
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see above.
|
||||
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const stop = (id: number, name: string): RoadtripStop => ({
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
})
|
||||
|
||||
const day = (dayId: number, dayNumber: number): RoadtripDay => ({
|
||||
@@ -41,6 +42,7 @@ const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
totalStops: days.length * 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
@@ -64,6 +66,19 @@ function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): Cor
|
||||
|
||||
function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<RoadtripCorridor['search']> = {}): RoadtripCorridor {
|
||||
const days = over.day ? [over.day] : [day(1, 1)]
|
||||
const searchState: RoadtripCorridor['search'] = {
|
||||
results: [],
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
spine: [],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
...search,
|
||||
}
|
||||
return {
|
||||
dayId: String(days[0].dayId),
|
||||
setDayId: vi.fn(),
|
||||
@@ -72,18 +87,14 @@ function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<Roadtrip
|
||||
toggleCategory: vi.fn(),
|
||||
widthKm: 5,
|
||||
setWidthKm: vi.fn(),
|
||||
nameFilter: '',
|
||||
setNameFilter: vi.fn(),
|
||||
// Same as the hook with an empty filter: everything found is on show.
|
||||
visible: searchState.results,
|
||||
insertIndexFor: vi.fn(() => 1),
|
||||
stopsAlongKm: [0, 100],
|
||||
...over,
|
||||
search: {
|
||||
results: [],
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
error: false,
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
...search,
|
||||
},
|
||||
search: searchState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +127,13 @@ describe('RoadtripCorridorPanel', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /campsite/i }))
|
||||
// Six kinds used to be six pills wrapped over three lines; they are one dropdown now,
|
||||
// so the options only exist once it is open.
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /campsite/i }))
|
||||
expect(c.toggleCategory).toHaveBeenCalledWith('campsite')
|
||||
// What is already selected reads as pressed rather than only looking different.
|
||||
expect(screen.getByRole('button', { name: /fuel/i })).toHaveAttribute('aria-pressed', 'true')
|
||||
// What is already selected reads as selected rather than only looking different.
|
||||
expect(screen.getByRole('option', { name: /fuel/i })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-005: the corridor width is a choice, and the current one is marked', () => {
|
||||
@@ -152,9 +166,10 @@ describe('RoadtripCorridorPanel', () => {
|
||||
})
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// "Fuel" is also a filter chip; the group header is the one inside a <header>.
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one
|
||||
// inside a <header>.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
|
||||
expect(within(fuelGroup).getByText('2')).toBeInTheDocument()
|
||||
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
|
||||
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -174,23 +189,17 @@ describe('RoadtripCorridorPanel', () => {
|
||||
expect(screen.getByText('Aral').closest('li')!.textContent).not.toContain('0 km along')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-010: a chain shows its own logo ahead of its name', () => {
|
||||
it('FE-ROADTRIP-PANEL-010: a chain gets its category icon, never its own logo', () => {
|
||||
// A corridor is mostly chains, and their marks turned a list of petrol stations into
|
||||
// an advertisement — while a brand with no logo on file fell back to a different
|
||||
// picture, so no two rows looked alike. The category's icon is also what the rail and
|
||||
// the map draw for the same stop.
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: 'Q565734' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const row = screen.getByText('Aral').closest('li')!
|
||||
const logo = row.querySelector('img')!
|
||||
expect(logo).toHaveAttribute('src', '/api/maps/brand-logo/Q565734')
|
||||
// Hidden until it has loaded, so a brand without one shows the category icon
|
||||
// rather than a hole in the row.
|
||||
expect(logo.className).toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-011: a wikidata id that is not one is never put in a URL', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: '../../etc/passwd' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Aral').closest('li')!.querySelector('img')).toBeNull()
|
||||
expect(row.querySelector('img')).toBeNull()
|
||||
expect(row.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-012: adding a hit puts it on the day that was searched', () => {
|
||||
@@ -199,7 +208,20 @@ describe('RoadtripCorridorPanel', () => {
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1)
|
||||
// Day and position both: a fuel stop belongs where it is driven past, not at the end.
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-023: the position offered is the one worked out for that very hit', () => {
|
||||
const onAddPoi = vi.fn()
|
||||
const hit = poi({ osm_id: 'node:9', name: 'Aral', alongKm: 120 })
|
||||
const insertIndexFor = vi.fn(() => 3)
|
||||
const c = corridor({ insertIndexFor }, { results: [hit] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
expect(insertIndexFor).toHaveBeenCalledWith(hit)
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 3)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
|
||||
@@ -232,4 +254,60 @@ 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', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
|
||||
const empty = corridor()
|
||||
const { unmount } = wrap(<RoadtripCorridorPanel corridor={empty} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.queryByLabelText('Filter by name')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
const found = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={found} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.getByLabelText('Filter by name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-019: typing in the filter reports the term rather than searching again', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Filter by name'), { target: { value: 'shell' } })
|
||||
expect(c.setNameFilter).toHaveBeenCalledWith('shell')
|
||||
// The filter must never trigger another round of Overpass requests.
|
||||
expect(c.search.search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-020: the list shows what is visible, and counts it against the whole', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
const c = corridor({ nameFilter: 'shell', visible: [hits[1]] }, { results: hits })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Shell')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Aral')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 of 2 on the way')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-021: filtering everything away says so instead of looking unsearched', () => {
|
||||
const c = corridor(
|
||||
{ nameFilter: 'esso', visible: [] },
|
||||
{ results: [poi({ osm_id: 'a', name: 'Aral' })] },
|
||||
)
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/Nothing on the way matches/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.queryByText('Add')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Search, Plus, RotateCw,
|
||||
AlertTriangle, MapPin, type LucideIcon,
|
||||
AlertTriangle, MapPin, X, type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { FS } from './typeScale'
|
||||
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
@@ -14,10 +17,11 @@ import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
interface RoadtripCorridorPanelProps {
|
||||
corridor: RoadtripCorridor
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, optionally on a day. */
|
||||
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
|
||||
onAddPoi?: (
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
|
||||
dayId?: number | null,
|
||||
position?: number | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -30,31 +34,33 @@ const CATEGORY_META: Record<string, { labelKey: string; Icon: LucideIcon }> = {
|
||||
sights: { labelKey: 'roadtrip.poi.sights', Icon: Camera },
|
||||
}
|
||||
|
||||
const LABEL = 'text-caption font-medium text-content-faint uppercase tracking-wide'
|
||||
/** The small capitalised word over a group of controls, matching the rail's own captions. */
|
||||
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
|
||||
|
||||
/** A card in this column: the same corner, hairline and surface the rail's cards use. */
|
||||
const CARD = 'rounded-2xl border border-edge-faint bg-surface-card'
|
||||
|
||||
/**
|
||||
* The brand's logo ahead of its name, falling back to the category icon.
|
||||
* The tile in front of a result, in its category's own colour.
|
||||
*
|
||||
* Same picture as the map pin: a corridor is mostly chains, and the logo is what the eye
|
||||
* finds first. The image lies over the icon and only appears once it has loaded, so a
|
||||
* brand without a logo — or a slow one — shows the icon rather than a hole in the row.
|
||||
* Never the brand's logo. A corridor is mostly chains, and putting their marks here made
|
||||
* a list of petrol stations read as an advertisement — while the ones with no logo on
|
||||
* file fell back to a different picture entirely, so no two rows looked alike. The
|
||||
* category's icon in its own tint is the same thing the rail draws on its dashed line and
|
||||
* the map draws on the route, which is what makes a row, a pin and a stop recognisably
|
||||
* one place.
|
||||
*/
|
||||
function ResultBadge({ poi }: { poi: CorridorPoi }): React.ReactElement {
|
||||
const meta = CATEGORY_META[poi.category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const brand = poi.brand_wikidata && /^Q[1-9][0-9]{0,11}$/.test(poi.brand_wikidata) ? poi.brand_wikidata : null
|
||||
function ResultBadge({ category }: { category: string }): React.ReactElement {
|
||||
const Icon = CATEGORY_META[category]?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<span className="relative grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-lg bg-surface-tertiary">
|
||||
<Icon size={14} className="text-content-faint" aria-hidden />
|
||||
{brand ? (
|
||||
<img
|
||||
src={`/api/maps/brand-logo/${brand}`}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity"
|
||||
onLoad={e => { e.currentTarget.style.opacity = '1' }}
|
||||
onError={e => e.currentTarget.remove()}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-[10px]"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`, the same colour
|
||||
// this kind of stop carries in the rail and on the map.
|
||||
style={{ background: `${color}1f`, color }}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.9} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -63,26 +69,39 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
return (
|
||||
<li className="group flex items-center gap-2.5 rounded-xl px-2.5 py-2 hover:bg-surface-hover">
|
||||
<ResultBadge poi={poi} />
|
||||
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
|
||||
<ResultBadge category={poi.category} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-body text-content">{poi.name}</div>
|
||||
<div className="text-caption text-content-faint tabular-nums">
|
||||
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
<span className="mx-1">·</span>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{/* Two facts, two elements. Both are translated phrases rather than short fixed
|
||||
values, so they stay text and the gap does the separating a middot used to —
|
||||
which also lets them wrap onto their own lines instead of breaking around a
|
||||
dot left stranded at the end of a line. */}
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
|
||||
<span>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Always there, quiet until the row is under the pointer: a button that only
|
||||
exists on hover is one a keyboard user has to find by faith. */}
|
||||
{onAdd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg border border-edge bg-surface-card px-2 py-1 text-caption font-medium text-content-secondary opacity-0 transition-opacity hover:text-content focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t('roadtrip.poi.add')}
|
||||
title={t('roadtrip.poi.add')}
|
||||
className="grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-content-faint transition-colors hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text focus-visible:outline-none"
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t('roadtrip.poi.add')}
|
||||
<Plus size={15} strokeWidth={2.2} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
@@ -90,25 +109,42 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
}
|
||||
|
||||
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
|
||||
function ResultGroup({ category, pois, dayId, onAddPoi }: {
|
||||
function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
category: string
|
||||
pois: CorridorPoi[]
|
||||
dayId: number | null
|
||||
insertIndexFor: RoadtripCorridor['insertIndexFor']
|
||||
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2 px-4 py-1.5">
|
||||
<Icon size={13} className="text-content-faint" aria-hidden />
|
||||
<span className={LABEL}>{meta ? t(meta.labelKey) : category}</span>
|
||||
<span className="ml-auto text-caption text-content-faint tabular-nums">{pois.length}</span>
|
||||
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see ResultBadge.
|
||||
style={{ background: color, color: '#fff' }}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
|
||||
{meta ? t(meta.labelKey) : category}
|
||||
</span>
|
||||
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
|
||||
{t('roadtrip.poi.found', { count: pois.length })}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="px-1.5">
|
||||
<ul>
|
||||
{pois.map(poi => (
|
||||
<ResultRow key={poi.osm_id} poi={poi} onAdd={onAddPoi ? () => onAddPoi(poi, dayId) : undefined} />
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
// Added where it will be driven past, not at the end of the day.
|
||||
onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -123,6 +159,11 @@ function ResultGroup({ category, pois, dayId, onAddPoi }: {
|
||||
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
|
||||
* the day whose route it was found along. Hits are drawn on the map at the same time, so
|
||||
* the list answers "which of these" and the map answers "which side of the road".
|
||||
*
|
||||
* Three cards down one column, the same shapes the rail opposite uses: what to look for,
|
||||
* how the search is getting on, and what it found. The day it searches sits in the
|
||||
* header rather than among the filters — it is the question's subject, not one of its
|
||||
* conditions.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -135,74 +176,75 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
}))
|
||||
|
||||
const grouped = CORRIDOR_CATEGORY_KEYS
|
||||
.map(key => ({ key, pois: search.results.filter(p => p.category === key) }))
|
||||
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
|
||||
.filter(g => g.pois.length > 0)
|
||||
|
||||
// Something was found, the filter just hides it — a different state from "not searched
|
||||
// yet" and from "the drive really has none of these".
|
||||
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
|
||||
|
||||
const canSearch = !search.loading && corridor.categories.length > 0 && (corridor.day?.stops.length ?? 0) > 1
|
||||
const progressPct = search.progress.total
|
||||
? Math.round((search.progress.done / search.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header — the rhythm of the places sidebar this replaces. */}
|
||||
<div className="flex-shrink-0 border-b border-edge-faint px-4 pb-2.5 pt-3.5">
|
||||
<h2 className="text-subtitle font-semibold text-content">{t('roadtrip.poi.title')}</h2>
|
||||
</div>
|
||||
const warnings: [string, string][] = []
|
||||
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
|
||||
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
// no fuel on this stretch" and "nobody looked at this stretch".
|
||||
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
|
||||
// The stretch was searched and the answer came back short. Without this line a filter
|
||||
// finding nothing looks like proof there is nothing.
|
||||
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="flex flex-shrink-0 flex-col gap-3 border-b border-edge-faint px-4 py-3">
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
{/* Header — the panel's name, and the day the question is about. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
|
||||
<h2 className="font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
|
||||
{t('roadtrip.poi.title')}
|
||||
</h2>
|
||||
{dayOptions.length > 1 ? (
|
||||
<div>
|
||||
<span className={LABEL}>{t('roadtrip.poi.scope')}</span>
|
||||
<div className="mt-1">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="ms-auto min-w-0">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={LABEL}>{t('roadtrip.poi.looking')}</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{CORRIDOR_CATEGORY_KEYS.map(key => {
|
||||
const { labelKey, Icon } = CATEGORY_META[key]
|
||||
const on = corridor.categories.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => corridor.toggleCategory(key)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium transition-colors ${
|
||||
on
|
||||
? 'border-transparent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:border-edge-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* What to look for. */}
|
||||
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={LABEL}>{t('roadtrip.poi.within')}</span>
|
||||
<div className="ml-auto flex gap-0.5 rounded-lg border border-edge-faint bg-surface-tertiary p-0.5">
|
||||
<span className="h-px bg-edge-faint" aria-hidden />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.within')}</span>
|
||||
<div className="flex gap-1 rounded-xl bg-surface-tertiary p-1">
|
||||
{CORRIDOR_WIDTHS_KM.map(km => (
|
||||
<button
|
||||
key={km}
|
||||
type="button"
|
||||
aria-pressed={corridor.widthKm === km}
|
||||
onClick={() => corridor.setWidthKm(km)}
|
||||
className={`rounded-md px-2.5 py-1 text-caption tabular-nums transition-colors ${
|
||||
corridor.widthKm === km ? 'bg-surface-card font-medium text-content' : 'text-content-muted hover:text-content'
|
||||
style={{ fontSize: FS.control }}
|
||||
className={`flex h-[26px] flex-1 items-center justify-center rounded-lg tabular-nums transition-colors ${
|
||||
corridor.widthKm === km
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{formatDistance(km, distanceUnit)}
|
||||
@@ -215,68 +257,121 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={!canSearch}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-xl bg-accent px-3 py-2 text-body font-medium text-accent-text transition-opacity disabled:opacity-50"
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex h-[34px] w-full items-center justify-center gap-1.5 rounded-xl bg-accent font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading
|
||||
? <RotateCw size={14} className="animate-spin" aria-hidden />
|
||||
: <Search size={14} aria-hidden />}
|
||||
? <RotateCw size={13} className="animate-spin" aria-hidden />
|
||||
: <Search size={13} strokeWidth={2} aria-hidden />}
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
|
||||
: t('roadtrip.poi.search')}
|
||||
</button>
|
||||
|
||||
{search.loading ? (
|
||||
<div className="h-0.5 overflow-hidden rounded-full bg-surface-tertiary" role="progressbar" aria-valuenow={progressPct}>
|
||||
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{search.capped ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.poi.capped')}
|
||||
</p>
|
||||
) : null}
|
||||
{search.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-danger">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.poi.failed')}
|
||||
</p>
|
||||
) : search.failedAreas > 0 ? (
|
||||
// Some boxes answered and some did not. Saying so is the difference between
|
||||
// "there is no fuel on this stretch" and "nobody looked at this stretch".
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.poi.partial', { count: search.failedAreas })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-secondary">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="px-6 py-10 text-center">
|
||||
<MapPin size={20} className="mx-auto mb-2.5 text-content-faint" aria-hidden />
|
||||
<p className="text-caption text-content-muted">
|
||||
{search.loading ? t('roadtrip.poi.searchingHint') : t('roadtrip.poi.empty')}
|
||||
{/* How the run is getting on — a row of its own while it lasts, with the share
|
||||
done as a figure rather than only as a bar: a corridor search is many requests
|
||||
and "58%" answers "is this worth waiting for" that a bar only hints at. */}
|
||||
{search.loading ? (
|
||||
<div
|
||||
className="flex h-[32px] flex-shrink-0 items-center gap-2 rounded-xl border border-edge bg-surface-card pe-1.5 ps-3 text-content-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={progressPct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<RotateCw size={13} className="animate-spin" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium" style={{ fontSize: FS.control }}>
|
||||
{t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })}
|
||||
</span>
|
||||
<span
|
||||
className="ms-auto inline-flex h-[22px] shrink-0 items-center rounded-full bg-inverse px-2 font-geist font-semibold tabular-nums tracking-[0.08em] text-inverse-text"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{`${progressPct}%`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="flex flex-shrink-0 flex-col gap-1.5">
|
||||
{warnings.map(([key, text]) => (
|
||||
<p
|
||||
key={key}
|
||||
className={`flex items-start gap-1.5 ${key === 'failed' ? 'text-danger' : 'text-warning'}`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<AlertTriangle size={12} className="mt-px shrink-0" aria-hidden />
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-4 pb-1 pt-2.5 text-caption text-content-faint">
|
||||
{t('roadtrip.poi.found', { count: search.results.length })}
|
||||
</p>
|
||||
{grouped.map(g => (
|
||||
<ResultGroup
|
||||
key={g.key}
|
||||
category={g.key}
|
||||
pois={g.pois}
|
||||
dayId={corridor.day?.dayId ?? null}
|
||||
onAddPoi={onAddPoi}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* What was found. */}
|
||||
<div className={`flex min-h-0 flex-1 flex-col overflow-hidden ${CARD}`}>
|
||||
{/* Narrowing what was found. Deliberately not called "search": the button above
|
||||
asks the place search for new results, this only hides rows already in. It
|
||||
appears once there is something to narrow. */}
|
||||
{search.results.length > 0 ? (
|
||||
<div className="flex-shrink-0 border-b border-edge-faint p-3">
|
||||
<div className="relative">
|
||||
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
|
||||
<input
|
||||
type="text"
|
||||
value={corridor.nameFilter}
|
||||
onChange={e => corridor.setNameFilter(e.target.value)}
|
||||
placeholder={t('roadtrip.poi.filter')}
|
||||
aria-label={t('roadtrip.poi.filter')}
|
||||
style={{ fontSize: FS.time }}
|
||||
className="h-[32px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{corridor.nameFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => corridor.setNameFilter('')}
|
||||
aria-label={t('common.clear')}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<MapPin size={18} className="mx-auto mb-2 text-content-faint" aria-hidden />
|
||||
<p className="text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searchingHint')
|
||||
: filteredToNothing
|
||||
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
|
||||
: t('roadtrip.poi.empty')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{corridor.nameFilter.trim() ? (
|
||||
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
|
||||
</p>
|
||||
) : null}
|
||||
{grouped.map(g => (
|
||||
<ResultGroup
|
||||
key={g.key}
|
||||
category={g.key}
|
||||
pois={g.pois}
|
||||
dayId={corridor.day?.dayId ?? null}
|
||||
insertIndexFor={corridor.insertIndexFor}
|
||||
onAddPoi={onAddPoi}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwi
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('roadtrip.mode.label')}
|
||||
className="flex gap-1 mx-3 mt-3 mb-1 p-1 rounded-xl bg-surface-tertiary border border-edge-faint"
|
||||
className="mx-3.5 mb-0 mt-3 flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1"
|
||||
>
|
||||
{options.map(([value, label, Icon]) => {
|
||||
const selected = active === value
|
||||
@@ -33,11 +33,16 @@ export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwi
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(value)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-caption font-medium transition-colors ${
|
||||
selected ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'
|
||||
// Sized off the day plan's own type, like the road trip rail below it: the
|
||||
// switcher sits above both, so it cannot be the largest thing in the column.
|
||||
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
|
||||
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
|
||||
selected
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} aria-hidden />
|
||||
<Icon size={13} strokeWidth={1.8} aria-hidden />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
@@ -53,15 +54,25 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
totalDistance: 100000,
|
||||
totalDuration: 3600,
|
||||
totalStops: 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripSidebar', () => {
|
||||
/** The value shown in the totals card with this label. */
|
||||
const total = (label: string): string =>
|
||||
within(screen.getByText(label).closest('div.rounded-xl')!).getAllByText(/./)[1].textContent!
|
||||
/**
|
||||
* The value the summary head pairs with this label. The same words also name the chips
|
||||
* further down for screen readers, so the lookup has to hold on to the `dt` — otherwise
|
||||
* it matches "Distance" three times over.
|
||||
*/
|
||||
const total = (label: string): string => {
|
||||
// The summary is three columns; the caption and its number are siblings in one cell.
|
||||
// The same words also name the badges further down, so the lookup keeps to the cell
|
||||
// whose first child is the caption itself.
|
||||
const caption = screen.getAllByText(label).find(el => el.parentElement?.firstElementChild === el)!
|
||||
return caption.parentElement!.lastElementChild!.textContent!.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-001: leads with the totals for the whole drive', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
|
||||
@@ -72,19 +83,30 @@ describe('RoadtripSidebar', () => {
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
|
||||
// Day totals deliberately differ from the single leg, so the leg is unambiguous.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ distance: 250000, duration: 9000 })] })} />)
|
||||
// Trip and day totals deliberately differ from the single leg, so the leg's own two
|
||||
// values are the only place '100 km' and '1 h' can come from.
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ distance: 250000, duration: 9000 })],
|
||||
totalDistance: 250000,
|
||||
totalDuration: 9000,
|
||||
})} />)
|
||||
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// The leg reads as one line between the two stops it connects.
|
||||
expect(screen.getByText('100 km · 1 h')).toBeInTheDocument()
|
||||
// One sentence, not two glued values: the middot is gone and so is the pair of chips.
|
||||
expect(screen.getByText('100 km in 1 h')).toBeInTheDocument()
|
||||
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-003: says a leg is still coming rather than showing a blank', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [undefined], distance: 0, duration: 0 })] })} />)
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ legs: [undefined], distance: 0, duration: 0 })],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
})} />)
|
||||
|
||||
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
|
||||
// An absent value says so rather than reading zero.
|
||||
expect(screen.queryByText(/100 km in/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -99,8 +121,15 @@ describe('RoadtripSidebar', () => {
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByText('09:00').className).toContain('font-semibold')
|
||||
expect(screen.getByText('10:30').className).toContain('text-content-muted')
|
||||
// Both are plain text at the row's edge — no pill, no icon. What separates the time
|
||||
// somebody chose from the one the drive worked out is weight and ink, and the reason
|
||||
// is named for a screen reader either way.
|
||||
const pinned = screen.getByText('09:00')
|
||||
const computed = screen.getByText('10:30')
|
||||
expect(pinned.className).toContain('font-semibold')
|
||||
expect(pinned).toHaveAttribute('title', 'Time you set')
|
||||
expect(computed.className).not.toContain('font-semibold')
|
||||
expect(computed).toHaveAttribute('title', 'Calculated from the drive')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-005: flags a stop the drive cannot reach in time', () => {
|
||||
@@ -155,6 +184,327 @@ describe('RoadtripSidebar', () => {
|
||||
expect(headings).toHaveLength(2)
|
||||
expect(within(headings[1].parentElement!).getByText(/50 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-011: draws the midnight crossing where it happens', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: false, dayOffset: 1 },
|
||||
],
|
||||
warnings: [{ index: 1, code: 'overnight' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Said twice on purpose: once as the break in the chain, once on the arrival after
|
||||
// it, where "01:30" would otherwise read as tonight.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
expect(screen.getAllByTitle('Calculated from the drive')[1].textContent).toContain('+1')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-012: a late arrival past midnight keeps both findings', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: true, dayOffset: 1 },
|
||||
],
|
||||
// Both belong to the same stop. Reading only the first one dropped the late flag.
|
||||
warnings: [
|
||||
{ index: 1, code: 'overnight' as const },
|
||||
{ index: 1, code: 'late' as const, minutes: 45 },
|
||||
],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-013: says the totals are still a partial sum while legs arrive', () => {
|
||||
const { unmount } = wrap(<RoadtripSidebar routes={routes({ loading: true })} />)
|
||||
expect(screen.getByText('Still working out the rest of the drive')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripSidebar routes={routes({ loading: false })} />)
|
||||
expect(screen.queryByText('Still working out the rest of the drive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-014: shows the name a day was given', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ title: 'Along the coast' })] })} />)
|
||||
|
||||
expect(screen.getByText('Along the coast')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-015: dates the day machine-readably, whatever the locale prints', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ date: '2026-08-31' })] })} />)
|
||||
|
||||
const stamp = container.querySelector('time')!
|
||||
expect(stamp).toHaveAttribute('datetime', '2026-08-31')
|
||||
expect(stamp.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-016: a leg a plugin routed carries its own mark and its note', () => {
|
||||
const charged = leg({ mode: 'plugin:charge', noteText: '25 min charge' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [charged] })] })} />)
|
||||
|
||||
// Free text, so it gets a line rather than being squeezed into a pill.
|
||||
expect(screen.getByText('25 min charge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a fuel stop breaks the drive instead of taking a number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
|
||||
|
||||
// The kind is what you look for on a drive; the running number is on the map pin.
|
||||
expect(screen.getByLabelText('Fuel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// How long the pause takes is what the row is for.
|
||||
expect(screen.getByText('15 min')).toBeInTheDocument()
|
||||
// The two places the trip is actually for keep counting one, two — the tank stop
|
||||
// between them is part of the drive, not a third destination. Scoped to the chain:
|
||||
// the summary above counts stops too, in the same digits.
|
||||
const chain = screen.getByRole('list')
|
||||
expect(within(chain).getByText('1')).toBeInTheDocument()
|
||||
expect(within(chain).getByText('2')).toBeInTheDocument()
|
||||
expect(within(chain).queryByText('3')).not.toBeInTheDocument()
|
||||
// Two drive bands, because the stop splits the leg it falls on. Scoped to the chain
|
||||
// again: the day's header badge phrases its own total the same way.
|
||||
expect(within(chain).getAllByText('100 km in 1 h')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-032: every stop offers a stay, whether it has one or not', () => {
|
||||
// The value has never been editable anywhere in TREK, so a stop without one needs a
|
||||
// way in before it can get one — the plus sits in the slot the number will occupy.
|
||||
const onEditStay = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onEditStay={onEditStay} />)
|
||||
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
const chain = screen.getByRole('list')
|
||||
const add = within(chain).getByText('+')
|
||||
|
||||
fireEvent.click(add)
|
||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
// A label, not an invitation that leads nowhere.
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-030: a day counts the places it visits, not the pauses on the way', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Ionity Horst', stopType: 'charging' }),
|
||||
stop({ assignmentId: 3, name: 'Rasthof Fläming', stopType: 'rest_area' }),
|
||||
stop({ assignmentId: 4, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-031: every kind the corridor finds is a pause, not a destination', () => {
|
||||
// Anything picked off "along the route" was come across on the way, so all six kinds
|
||||
// sit inside the leg with their own icon and none of them counts as a stop.
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Camping Seeblick', stopType: 'campsite' }),
|
||||
stop({ assignmentId: 3, name: 'Bäckerei Junge', stopType: 'restaurant' }),
|
||||
stop({ assignmentId: 4, name: 'Holstentor', stopType: 'sights' }),
|
||||
stop({ assignmentId: 5, name: 'Lübeck' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByLabelText('Campsite')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Food')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Sights')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-020: a stop kind nobody knows falls back to the running number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Etwas Neues', stopType: 'helipad' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
expect(within(screen.getByRole('list')).getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-021: without the right to edit days the chain cannot be dragged', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
expect(container.querySelector('li[draggable="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-022: dropping a stop on another reports where it should go', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
expect(rows).toHaveLength(3)
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[2], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
// The last stop dropped on the first: day, the assignment moved, and its new index.
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 3, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-023: dropping a stop back on itself changes nothing', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[0], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-024: the chain can be reordered from the keyboard too', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />)
|
||||
|
||||
// Dragging is a gesture; without this the chain is unreachable without a mouse.
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 2)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-025: the ends of a day cannot be pushed past themselves', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
fireEvent.keyDown(screen.getByText('Berlin').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-026: an arrow without Alt still belongs to the list, not to us', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowDown' })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-027: a day without a drive appears only while something is dragged', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Nothing in flight: an empty day is not worth a row, the rail is about the drive.
|
||||
expect(screen.queryByText(/drop one here/i)).not.toBeInTheDocument()
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
expect(screen.getByText(/drop one here/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-028: dropping on another day reports both days', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
const target = screen.getByText(/drop one here/i).closest('section')!
|
||||
fireEvent.dragOver(target)
|
||||
fireEvent.drop(target)
|
||||
|
||||
// From day 1, the second stop (assignment 2), onto day 9, at its end.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 9, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-029: a stop dropped on a row of another day lands at that row', () => {
|
||||
const second = day({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
stops: [stop({ assignmentId: 3, name: 'Dresden' }), stop({ assignmentId: 4, name: 'Prague' })],
|
||||
})
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [day(), second], totalStops: 4 })}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
fireEvent.dragOver(rows[3])
|
||||
fireEvent.drop(rows[3])
|
||||
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 1, 2, 1)
|
||||
// Crossing days is a different call; the same-day reorder must not also fire.
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
|
||||
const ferry = leg({ mode: 'ferry' })
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
|
||||
|
||||
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React from 'react'
|
||||
import { MapPin, Clock, Car, Footprints, Bike, Milestone, Flag, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
|
||||
Fuel, Coffee, Tent, Utensils, Camera, Shuffle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
interface RoadtripSidebarProps {
|
||||
@@ -13,134 +18,733 @@ interface RoadtripSidebarProps {
|
||||
routes: RoadtripRoutes
|
||||
selectedAssignmentId?: number | null
|
||||
onSelectStop?: (placeId: number, assignmentId: number) => void
|
||||
/**
|
||||
* Moves a stop within its day. Absent means the chain is read-only, which is also how
|
||||
* a viewer sees it — no handles, no drop targets.
|
||||
*/
|
||||
onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void
|
||||
/** Moves a stop onto a different day. Absent means moves stay inside their own day. */
|
||||
onMoveStopToDay?: (fromDayId: number, assignmentId: number, toDayId: number, toIndex: number) => void
|
||||
/** Asks for other ways of driving one leg (#1797). */
|
||||
onAskAlternatives?: (dayId: number, legIndex: number) => void
|
||||
/** Which leg's alternatives are on show, so the rail can mark it. */
|
||||
openAlternatives?: { dayId: number; index: number } | null
|
||||
/**
|
||||
* Opens the dialog for how long a stop takes. Absent leaves every stay read-only —
|
||||
* which is also what a viewer sees.
|
||||
*/
|
||||
onEditStay?: (stop: { placeId: number; name: string; minutes: number | null; arrival: string | null }) => void
|
||||
}
|
||||
|
||||
const MODE_ICON: Record<string, typeof Car> = {
|
||||
driving: Car,
|
||||
const MODE_ICON: Record<string, LucideIcon> = {
|
||||
driving: CarFront,
|
||||
walking: Footprints,
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
function Totals({ distance, duration, stops }: { distance: number; duration: number; stops: number }): React.ReactElement {
|
||||
/** What each kind of pause looks like. The icon is what tells the teal discs apart. */
|
||||
const SERVICE_ICON: Record<string, LucideIcon> = {
|
||||
fuel: Fuel,
|
||||
charging: Zap,
|
||||
rest_area: Coffee,
|
||||
campsite: Tent,
|
||||
restaurant: Utensils,
|
||||
sights: Camera,
|
||||
}
|
||||
|
||||
/**
|
||||
* What each kind is called. Spelled out rather than derived from the key, because two of
|
||||
* them do not match: the corridor calls a rest area `rest_area` but names it under
|
||||
* `rest`, and a restaurant `restaurant` but names it under `food`.
|
||||
*/
|
||||
const SERVICE_LABEL_KEY: Record<string, string> = {
|
||||
fuel: 'roadtrip.poi.fuel',
|
||||
charging: 'roadtrip.poi.charging',
|
||||
rest_area: 'roadtrip.poi.rest',
|
||||
campsite: 'roadtrip.poi.campsite',
|
||||
restaurant: 'roadtrip.poi.food',
|
||||
sights: 'roadtrip.poi.sights',
|
||||
}
|
||||
|
||||
|
||||
/** The column the markers and the line share, and the gap to the content beside it. */
|
||||
const RAIL_GRID: React.CSSProperties = { gridTemplateColumns: '24px 1fr', columnGap: 10 }
|
||||
|
||||
/**
|
||||
* The line between two stops.
|
||||
*
|
||||
* A repeating gradient rather than a dashed border: a 1px dashed border renders as a
|
||||
* smear at this width, and the gradient keeps the dash length exact.
|
||||
*/
|
||||
const RAIL_DASH: React.CSSProperties = {
|
||||
width: 1.5,
|
||||
backgroundImage: 'repeating-linear-gradient(var(--border-primary) 0 4px, transparent 4px 8px)',
|
||||
}
|
||||
|
||||
/** 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'
|
||||
|
||||
/**
|
||||
* The small capitalised caption over a number, and the badges in a day's header.
|
||||
*
|
||||
* Wide letter-spacing and uppercase rather than a size change: the labels have to stay
|
||||
* legible at a third of the column's width in 23 languages, and shrinking them further
|
||||
* was what made "Driving time" unreadable before it was ever clipped.
|
||||
*/
|
||||
const STAT_LABEL = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
|
||||
|
||||
/**
|
||||
* A day-header badge: the date, the drive, the count.
|
||||
*
|
||||
* Medium weight in the quiet ink, not semibold in the strong one. Three uppercase badges
|
||||
* with wide tracking already carry as much emphasis as a line can take; adding weight and
|
||||
* contrast on top made the day's supporting facts shout louder than the day's own name.
|
||||
*/
|
||||
const DAY_BADGE = 'inline-flex h-[20px] items-center rounded-lg px-2 font-geist font-medium uppercase tracking-[0.09em] text-content-muted'
|
||||
|
||||
/**
|
||||
* How long the traveller stays here, and the way to change it.
|
||||
*
|
||||
* On every stop, not only the ones that already carry a time: the value has never been
|
||||
* editable anywhere in TREK, so a stop that has none needs somewhere to say so before it
|
||||
* can get one. Unset it reads as a plus in the slot the number will occupy, which keeps
|
||||
* the two states the same shape and the same width — a row does not jump when a stay is
|
||||
* added to it.
|
||||
*
|
||||
* Read-only for someone who cannot edit the trip: then it is a label, and a stop without
|
||||
* a stay shows nothing at all rather than an invitation that leads nowhere.
|
||||
*/
|
||||
function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const text = minutes ? formatDurationShort(minutes * 60) : null
|
||||
if (!text && !onEdit) return null
|
||||
|
||||
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
||||
const label = (
|
||||
<span
|
||||
className="flex items-center bg-surface-tertiary px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{t('roadtrip.stop.stayShort')}
|
||||
</span>
|
||||
)
|
||||
const value = (
|
||||
<span
|
||||
className={`flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums ${
|
||||
text ? 'text-content-secondary' : 'text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text ?? '+'}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!onEdit) return <span className={shell}>{label}{value}</span>
|
||||
// A span carrying the button role, not a <button>: the whole stop row is already one,
|
||||
// and a button inside a button is invalid HTML that React warns about and that browsers
|
||||
// resolve by dropping the inner element.
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
// Stops the click reaching the row, which would select the stop and move the map
|
||||
// out from under the dialog that is about to open.
|
||||
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title={t('roadtrip.stop.stay')}
|
||||
aria-label={text ? `${t('roadtrip.stop.stay')}: ${text}` : t('roadtrip.stay.add')}
|
||||
className={`${shell} cursor-pointer transition-colors hover:border-content-faint`}
|
||||
>
|
||||
{label}{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The quiet half of a measurement — the unit, and anything after the decimal point. */
|
||||
function Unit({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <span className="font-medium text-content-muted" style={{ fontSize: FS.totalUnit }}>{children}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the whole numbers of a measurement apart from everything else in it.
|
||||
*
|
||||
* "691.6 km" reads as six-hundred-and-ninety-one, roughly; "9 h 4 min" as nine and four.
|
||||
* Those are the digits worth the size, and the decimal tail belongs with the unit rather
|
||||
* than with them. Purely presentational and deliberately forgiving: a bare count comes
|
||||
* back as one big number, and a language that puts its unit first still splits correctly
|
||||
* because the split is driven by the digits, not by position.
|
||||
*/
|
||||
function splitValue(value: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = []
|
||||
const re = /(\d+)([.,]\d+)?/g
|
||||
let last = 0
|
||||
let key = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(value)) !== null) {
|
||||
if (m.index > last) parts.push(<Unit key={key++}>{value.slice(last, m.index)}</Unit>)
|
||||
parts.push(<React.Fragment key={key++}>{m[1]}</React.Fragment>)
|
||||
if (m[2]) parts.push(<Unit key={key++}>{m[2]}</Unit>)
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < value.length) parts.push(<Unit key={key}>{value.slice(last)}</Unit>)
|
||||
return parts.length ? parts : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance, driving time and stops for the whole trip — the head the left column never
|
||||
* had, above the day cards and reading as one card with them.
|
||||
*
|
||||
* Three equal centred columns with hairlines between them, rather than three labelled
|
||||
* rows: the labels are the quiet part and the numbers are what the head exists for, so
|
||||
* the numbers get the size and the labels get the letter-spacing.
|
||||
*/
|
||||
function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const cells: [typeof Milestone, string, string][] = [
|
||||
[Milestone, t('roadtrip.summary.distance'), formatDistance(distance / 1000, distanceUnit)],
|
||||
[Clock, t('roadtrip.summary.driving'), formatDurationShort(duration)],
|
||||
[Flag, t('roadtrip.summary.stops'), String(stops)],
|
||||
const cells: [string, string][] = [
|
||||
// `totalStops` already leaves the service stops out, so the head and the day badges
|
||||
// below count the same thing without either of them recounting the other's stops.
|
||||
[t('roadtrip.summary.distance'), formatDistance(routes.totalDistance / 1000, distanceUnit)],
|
||||
[t('roadtrip.summary.driving'), formatDurationShort(routes.totalDuration)],
|
||||
[t('roadtrip.summary.stops'), String(routes.totalStops)],
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5 px-3 pb-3">
|
||||
{cells.map(([Icon, label, value]) => (
|
||||
<div key={label} className="rounded-xl bg-surface-card border border-edge-faint px-2.5 py-2">
|
||||
<div className="flex items-start gap-1.5 text-content-faint">
|
||||
<Icon size={12} className="shrink-0 mt-[3px]" aria-hidden />
|
||||
{/* Wraps rather than truncates: "Driving time" and its translations do not
|
||||
fit one line in a third of the rail, and a clipped label reads as a bug. */}
|
||||
<span className="text-caption leading-tight">{label}</span>
|
||||
<header className="mx-3.5 rounded-2xl border border-edge-faint bg-surface-card px-3 pb-3 pt-3.5">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
{cells.map(([label, value], i) => (
|
||||
<React.Fragment key={label}>
|
||||
{i > 0 ? <span className="h-[28px] w-px shrink-0 bg-edge-faint" aria-hidden /> : null}
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5 px-1">
|
||||
<span className={STAT_LABEL} style={{ fontSize: FS.label }}>{label}</span>
|
||||
<span
|
||||
className="font-semibold leading-none tracking-[-0.03em] tabular-nums text-content"
|
||||
style={{ fontSize: FS.total }}
|
||||
>
|
||||
{splitValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* Legs land one request at a time, so until the last is in, every number above is
|
||||
a partial sum. A total that looks final and is not is worse than a slow one. */}
|
||||
{routes.loading ? (
|
||||
<p className="mt-2 break-words text-center text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.summary.partial')}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive between two stops — or between a stop and the charger halfway along it.
|
||||
*
|
||||
* Both numbers are rebuilt from the raw metres and seconds instead of the router's
|
||||
* pre-formatted strings: those spell a full hour "1 h 0 min", and they carry whichever
|
||||
* unit the leg was fetched with, so a km/mi switch showed stale text until the refetch
|
||||
* landed. One sentence rather than two values — "152 km in 1 h 38 min" is how the
|
||||
* distance and the time belong together, and it leaves the row's right edge for the
|
||||
* button instead of a second number.
|
||||
*/
|
||||
function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
leg: RouteSegment | undefined
|
||||
/** Asks for other ways of driving this leg. Absent means the route is not editable. */
|
||||
onAskAlternatives?: () => void
|
||||
alternativesOpen?: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const mode = leg?.mode ?? 'driving'
|
||||
const Icon = mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront
|
||||
// The band's contents, shared by the clickable and the read-only shape so the two can
|
||||
// never drift apart in what they say.
|
||||
const band = (
|
||||
<>
|
||||
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{leg
|
||||
? t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(leg.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(leg.duration),
|
||||
})
|
||||
: t('roadtrip.leg.pending')}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
{/* Dashed between stops, solid at them: the eye reads the gap as travel. */}
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{/* The whole band is the target, not the 18px square at its end. Asking for other
|
||||
ways of driving a leg is what the band is for, and a click anywhere on it is
|
||||
the gesture people try first — the shuffle mark stays as the sign that it can
|
||||
be clicked, and as where the open state shows. */}
|
||||
{onAskAlternatives && leg ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAskAlternatives}
|
||||
aria-pressed={alternativesOpen}
|
||||
title={t('roadtrip.alt.ask')}
|
||||
className={`group/leg my-1.5 flex w-full items-center gap-1.5 rounded-lg py-1 pe-1 ps-2 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
|
||||
alternativesOpen
|
||||
? 'bg-surface-selected text-content'
|
||||
: 'bg-surface-tertiary text-content-muted hover:bg-surface-selected'
|
||||
}`}
|
||||
>
|
||||
{band}
|
||||
{/* The open leg darkens its ink, not its box. A filled accent square here put
|
||||
a black chip in the middle of a rail whose only other filled thing is
|
||||
nothing at all — it read as a button that had been pressed and stuck. */}
|
||||
<span
|
||||
className={`ms-auto grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md transition-colors ${
|
||||
alternativesOpen ? 'text-content' : 'text-content-faint group-hover/leg:text-content'
|
||||
}`}
|
||||
>
|
||||
<Shuffle size={12} strokeWidth={1.9} aria-label={t('roadtrip.alt.ask')} />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="my-1.5 flex items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-content-muted">
|
||||
{band}
|
||||
</div>
|
||||
<div className="text-body font-semibold text-content tabular-nums mt-0.5 truncate">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{/* What a plugin route attached to this leg ("25 min charge"): free text, so it
|
||||
takes a line of its own rather than being forced into the row above. */}
|
||||
{leg?.noteText ? (
|
||||
<p className="-mt-0.5 mb-1.5 break-words px-1 text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{leg.noteText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Leg({ leg }: { leg: RouteSegment | undefined }): React.ReactElement {
|
||||
/**
|
||||
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
|
||||
*
|
||||
* It sits inside the leg with the dashed line running through it, carries no number and
|
||||
* is left out of every count, because that is the difference between it and the places
|
||||
* the trip is actually for. Its own icon on one flat disc: three kinds of pause that all
|
||||
* mean "we are still driving", and the icon is what tells them apart.
|
||||
*/
|
||||
function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
selected: boolean
|
||||
onSelect?: () => void
|
||||
/** Opens the dialog for how long this pause takes. Absent means the rail is read-only. */
|
||||
onEditStay?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const Icon = MODE_ICON[leg?.mode ?? 'driving'] ?? Car
|
||||
const Icon = SERVICE_ICON[stop.stopType ?? ''] ?? Coffee
|
||||
const label = t(SERVICE_LABEL_KEY[stop.stopType ?? ''] ?? 'roadtrip.poi.rest')
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-[10px] py-1">
|
||||
<span className="w-px self-stretch bg-edge-secondary ml-[5px]" aria-hidden />
|
||||
<Icon size={12} className="text-content-faint shrink-0 ml-1.5" aria-hidden />
|
||||
<span className="text-caption text-content-muted tabular-nums truncate">
|
||||
{leg ? `${leg.distanceText} · ${leg.durationText}` : t('roadtrip.leg.pending')}
|
||||
<div className="grid items-stretch" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center">
|
||||
<span className="flex-1" style={RAIL_DASH} aria-hidden />
|
||||
<span
|
||||
className={DISC}
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`, shared with
|
||||
// the corridor list and the map pin so one kind of stop looks like itself
|
||||
// wherever it turns up.
|
||||
style={{ background: serviceColor(stop.stopType), color: '#fff' }}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2.1} aria-label={label} />
|
||||
</span>
|
||||
<span className="flex-1" style={RAIL_DASH} aria-hidden />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
// The same inset as a numbered stop's content, so the two kinds of name start on
|
||||
// one vertical line instead of the service one sitting a few pixels nearer the rail.
|
||||
className={`flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
|
||||
selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Laid out exactly like a numbered stop: how long the pause takes is a stay like
|
||||
any other and sits under the name, and the right edge stays the arrival column
|
||||
all the way down the rail. Only the disc says this one is a pause. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{/* Truncated, not wrapped: a service stop is a waypoint, and its full name lives
|
||||
on the map pin — where a place's name is the row's whole reason to exist. */}
|
||||
<span
|
||||
className="min-w-0 truncate font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
{stop.name}
|
||||
</span>
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function OvernightBreak(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="grid items-center" style={RAIL_GRID}>
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
<span className="h-full" style={RAIL_DASH} />
|
||||
</span>
|
||||
<span
|
||||
className="my-1 inline-flex w-fit items-center gap-1 rounded-full bg-info-soft px-1.5 py-0.5 font-medium text-info"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
<Moon size={10} className="shrink-0" aria-hidden />
|
||||
{t('roadtrip.warn.overnight')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stop({ stop, index, entry, warning, selected, onSelect }: {
|
||||
/**
|
||||
* The arrival — the one value in the rail the user may have decided themselves.
|
||||
*
|
||||
* A pinned time is filled, pilled and carries the clock; a computed one is bare text.
|
||||
* Fill, shape and icon are three signals where a font weight used to be one, and this is
|
||||
* the state the whole view gets opened for. It is also the only filled accent left in the
|
||||
* rail: selection moved to the row's own background, so nothing else competes with it.
|
||||
*/
|
||||
function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const text = formatClockTime(entry.arrival, is12h)
|
||||
// The timetable convention: past midnight the clock keeps reading small numbers, so the
|
||||
// day it belongs to travels with it instead of sitting a line away as a separate note.
|
||||
const carry = entry.dayOffset > 0 ? (
|
||||
<span className="ms-0.5">
|
||||
{`+${entry.dayOffset}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
) : null
|
||||
|
||||
// Every arrival is plain text at the row's right edge, pinned or not. The pinned one
|
||||
// used to be a filled pill with a clock, which made a column of quiet clock readings
|
||||
// look like it had a button in it. What is left of the distinction is weight and ink —
|
||||
// enough to see which time somebody chose, without a second shape in the rail.
|
||||
return (
|
||||
<span
|
||||
dir="ltr"
|
||||
title={entry.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
|
||||
// The same line box as the stop name beside it, so the two sit on one line however
|
||||
// far apart their sizes are — the row reads across, not in two staggered halves.
|
||||
className={`shrink-0 whitespace-nowrap leading-6 tabular-nums ${
|
||||
entry.anchored ? 'font-semibold text-content-secondary' : 'font-medium text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
{text}
|
||||
{carry}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Stop({ stop, number, entry, late, selected, continues, onSelect, onMove, canMove, onEditStay }: {
|
||||
stop: RoadtripStop
|
||||
index: number
|
||||
/** Position within the day — the same count the map badges its markers with. */
|
||||
number: number
|
||||
entry: ScheduleEntry | undefined
|
||||
warning: ScheduleWarning | undefined
|
||||
late: ScheduleWarning | undefined
|
||||
selected: boolean
|
||||
/** Whether the chain goes on below, so the marker keeps hold of the line. */
|
||||
continues: boolean
|
||||
onSelect?: () => void
|
||||
/** Moves this stop by one place. Absent means the chain is read-only. */
|
||||
onMove?: (delta: number) => void
|
||||
/** Whether there is anywhere to move in each direction, so the ends say so. */
|
||||
canMove?: { up: boolean; down: boolean }
|
||||
/** Opens the dialog for how long this stop takes. Absent means the rail is read-only. */
|
||||
onEditStay?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const late = warning?.code === 'late'
|
||||
const lateText = late ? t('roadtrip.warn.late', { minutes: late.minutes ?? 0 }) : null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full flex items-center gap-2.5 text-left rounded-lg px-1.5 py-1.5 transition-colors ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
// Alt plus an arrow moves the stop. Dragging is the obvious gesture but it is only
|
||||
// a gesture: without this the chain could not be reordered from a keyboard at all,
|
||||
// and the row is already a button, so it is focusable anyway.
|
||||
onKeyDown={onMove ? e => {
|
||||
if (!e.altKey) return
|
||||
if (e.key === 'ArrowUp' && canMove?.up) { e.preventDefault(); onMove(-1) }
|
||||
if (e.key === 'ArrowDown' && canMove?.down) { e.preventDefault(); onMove(1) }
|
||||
} : undefined}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 w-[20px] h-[20px] rounded-full grid place-items-center text-caption font-semibold tabular-nums"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent-on)' }}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-body text-content">{stop.name}</span>
|
||||
{late ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-warning"
|
||||
aria-label={t('roadtrip.warn.late', { minutes: warning?.minutes ?? 0 })}
|
||||
/>
|
||||
) : null}
|
||||
{stop.dwellMinutes ? (
|
||||
<span className="shrink-0 text-caption text-content-faint tabular-nums" title={t('roadtrip.stop.stay')}>
|
||||
{formatDurationShort(stop.dwellMinutes * 60)}
|
||||
</span>
|
||||
) : null}
|
||||
{entry?.arrival ? (
|
||||
// A pinned time is what the user decided; a computed one is what the drive implies.
|
||||
// Same slot, different weight, so the chain reads as one column of times.
|
||||
{/* The marker and the line below it share one column, so the chain runs unbroken
|
||||
from stop to stop rather than restarting under whatever the row happens to hold. */}
|
||||
<span className="flex flex-col items-center">
|
||||
<span
|
||||
className={`shrink-0 text-caption tabular-nums ${entry.anchored ? 'font-semibold text-content' : 'text-content-muted'}`}
|
||||
title={entry.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
|
||||
className={`${DISC} bg-surface-tertiary font-geist font-semibold tabular-nums text-content-secondary`}
|
||||
style={{ fontSize: FS.marker }}
|
||||
>
|
||||
{entry.arrival}
|
||||
{number}
|
||||
</span>
|
||||
) : null}
|
||||
{continues ? <span className="mt-1 w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
|
||||
{/* The fill stops at the rail: the number is part of the chain, not part of the row
|
||||
you picked, and tinting it made the marker look selected too. */}
|
||||
<span
|
||||
className={`flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors ${
|
||||
selected ? 'bg-surface-selected' : 'group-hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{/* Wraps rather than truncates: the name is what the row is for, and thirty of
|
||||
them cut off mid-word is a list nobody reads. */}
|
||||
<span
|
||||
className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
{stop.name}
|
||||
</span>
|
||||
{/* Two halves under one border: the word says what the number means, so the
|
||||
number needs no unit of explanation beside it. */}
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
{lateText ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
title={lateText}
|
||||
className="mt-0.5 inline-flex w-fit items-center gap-1 self-start rounded-full bg-warning-soft px-1.5 py-0.5 font-semibold tabular-nums text-warning"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
<AlertTriangle size={10} className="shrink-0" aria-label={lateText} />
|
||||
{`+${formatDurationShort((late?.minutes ?? 0) * 60)}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop }: {
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay }: {
|
||||
day: RoadtripDay
|
||||
selectedAssignmentId?: number | null
|
||||
onSelectStop?: (placeId: number, assignmentId: number) => void
|
||||
onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void
|
||||
onMoveStopToDay?: RoadtripSidebarProps['onMoveStopToDay']
|
||||
/** What is being dragged right now, shared across days so a stop can leave its own. */
|
||||
drag: DragState
|
||||
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
}): React.ReactElement {
|
||||
const { from, setFrom, dropAt, setDropAt } = drag
|
||||
const dragging = from?.dayId === day.dayId ? from.index : null
|
||||
const { t, language } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const last = day.stops.length - 1
|
||||
// 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
|
||||
return (
|
||||
<section className="px-2 pb-2">
|
||||
<header className="flex items-baseline gap-2 px-1.5 py-1.5">
|
||||
<h3 className="text-body font-semibold text-content">{t('roadtrip.day', { number: day.dayNumber })}</h3>
|
||||
{day.date ? <span className="text-caption text-content-faint truncate">{formatDate(day.date, language)}</span> : null}
|
||||
<span className="ml-auto text-caption text-content-muted tabular-nums shrink-0">
|
||||
{formatDistance(day.distance / 1000, distanceUnit)} · {formatDurationShort(day.duration)}
|
||||
</span>
|
||||
<section className="mx-3.5 shrink-0 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
|
||||
{/* Centred, with the day's facts as badges beneath its name: at this width a row
|
||||
of label-and-value pairs breaks awkwardly, while three short badges wrap
|
||||
gracefully and stay readable in every language. */}
|
||||
{/* A tint of its own, not just a hairline: the header holds the day's summed facts
|
||||
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. */}
|
||||
<header className="mb-1 border-b border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3">
|
||||
<h3
|
||||
className="text-center font-semibold tracking-[-0.015em] text-content"
|
||||
style={{ fontSize: FS.dayTitle }}
|
||||
>
|
||||
{t('roadtrip.day', { number: day.dayNumber })}
|
||||
</h3>
|
||||
{/* Read by the hook since it was written, never drawn until now. */}
|
||||
{day.title ? (
|
||||
<p className="mt-0.5 break-words text-center text-content-secondary" style={{ fontSize: FS.meta }}>
|
||||
{day.title}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
|
||||
{day.date ? (
|
||||
<time dateTime={day.date} className={`${DAY_BADGE} border border-edge`} style={{ fontSize: FS.label }}>
|
||||
{formatDate(day.date, language)}
|
||||
</time>
|
||||
) : null}
|
||||
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.leg.driveText', {
|
||||
distance: formatDistance(day.distance / 1000, distanceUnit),
|
||||
time: formatDurationShort(day.duration),
|
||||
})}
|
||||
</span>
|
||||
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="rounded-xl bg-surface-card border border-edge-faint px-1.5 py-1.5">
|
||||
{day.stops.map((stop, i) => (
|
||||
<React.Fragment key={stop.assignmentId}>
|
||||
<Stop
|
||||
stop={stop}
|
||||
index={i}
|
||||
entry={day.schedule.entries[i]}
|
||||
warning={day.schedule.warnings.find(w => w.index === i)}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||
/>
|
||||
{i < day.stops.length - 1 ? <Leg leg={day.legs[i]} /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* One list item per stop, with the drive that follows it inside — the chain is a
|
||||
list of places, not of alternating places and connectors. A service stop owns
|
||||
the drive leaving it in exactly the same way, which is what splits its leg into
|
||||
band, marker, band without the list needing a second shape. */}
|
||||
<ol className="px-3.5 pb-3 pt-3">
|
||||
{day.stops.map((stop, i) => {
|
||||
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)
|
||||
return (
|
||||
<li
|
||||
key={stop.assignmentId}
|
||||
draggable={!!onReorderStop}
|
||||
onDragStart={onReorderStop ? e => {
|
||||
setFrom({ dayId: day.dayId, index: i, 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 !== day.dayId || dropAt.index !== i) setDropAt({ dayId: day.dayId, index: i })
|
||||
} : undefined}
|
||||
onDrop={onReorderStop && from ? e => {
|
||||
e.preventDefault()
|
||||
const src = from
|
||||
setFrom(null)
|
||||
setDropAt(null)
|
||||
if (src.dayId === day.dayId) {
|
||||
if (src.index !== i) onReorderStop(day.dayId, src.assignmentId, i)
|
||||
} else {
|
||||
onMoveStopToDay?.(src.dayId, src.assignmentId, day.dayId, i)
|
||||
}
|
||||
} : undefined}
|
||||
className={`rounded-lg transition-opacity ${dragging === i ? 'opacity-40' : ''} ${
|
||||
dropAt?.dayId === day.dayId && dropAt.index === i && from && !(from.dayId === day.dayId && from.index === i)
|
||||
? 'ring-2 ring-inset ring-accent'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{marks.some(w => w.code === 'overnight') ? <OvernightBreak /> : null}
|
||||
{service ? (
|
||||
<ServiceStop
|
||||
stop={stop}
|
||||
entry={day.schedule.entries[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}
|
||||
/>
|
||||
) : (
|
||||
<Stop
|
||||
stop={stop}
|
||||
number={counted}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={marks.find(w => w.code === 'late')}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
continues={i < last}
|
||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||
onMove={onReorderStop ? delta => onReorderStop(day.dayId, stop.assignmentId, i + 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}
|
||||
/>
|
||||
)}
|
||||
{i < last ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
onAskAlternatives={onAskAlternatives ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The stop in flight and the row it is hovering, shared by every day in the rail. */
|
||||
interface DragState {
|
||||
from: { dayId: number; index: number; assignmentId: number } | null
|
||||
setFrom: (v: DragState['from']) => void
|
||||
dropAt: { dayId: number; index: number } | null
|
||||
setDropAt: (v: DragState['dropAt']) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A day the rail draws no drive for, shown only so a stop can be moved onto it.
|
||||
*
|
||||
* Without it a one-stop day is invisible, and "this leg is too long, push the last stop
|
||||
* to tomorrow" has nowhere to land — which is the move a road trip needs most.
|
||||
*/
|
||||
function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
day: QuietDay
|
||||
onMoveStopToDay?: RoadtripSidebarProps['onMoveStopToDay']
|
||||
drag: DragState
|
||||
}): React.ReactElement | null {
|
||||
const { t, language } = useTranslation()
|
||||
const { from, dropAt, setFrom, setDropAt } = drag
|
||||
// Only while something is in flight: an empty day is not worth a row of its own
|
||||
// otherwise, and the rail is about the drive.
|
||||
if (!from || !onMoveStopToDay) return null
|
||||
const over = dropAt?.dayId === day.dayId
|
||||
return (
|
||||
<section
|
||||
onDragOver={e => { e.preventDefault(); if (!over) setDropAt({ dayId: day.dayId, index: day.stops.length }) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const src = from
|
||||
setFrom(null)
|
||||
setDropAt(null)
|
||||
if (src.dayId !== day.dayId) onMoveStopToDay(src.dayId, src.assignmentId, day.dayId, day.stops.length)
|
||||
}}
|
||||
className={`mx-3.5 shrink-0 rounded-2xl border border-dashed px-3.5 py-3 transition-colors ${
|
||||
over ? 'border-accent bg-accent-subtle' : 'border-edge-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<h3 className="font-semibold tracking-[-0.015em] text-content" style={{ fontSize: FS.dayTitle }}>
|
||||
{t('roadtrip.day', { number: day.dayNumber })}
|
||||
</h3>
|
||||
{day.date ? (
|
||||
<time dateTime={day.date} className={`${DAY_BADGE} ms-auto border border-edge`} style={{ fontSize: FS.label }}>
|
||||
{formatDate(day.date, language)}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{day.stops.length === 1
|
||||
? t('roadtrip.quietDay.one', { name: day.stops[0].name })
|
||||
: t('roadtrip.quietDay.empty')}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -153,31 +757,61 @@ function DaySection({ day, selectedAssignmentId, onSelectStop }: {
|
||||
* road trip is read across days — which day a stop sits on matters less than how far
|
||||
* apart the stops are (#1797, #435). Legs come from the same routing cache the map
|
||||
* uses, so switching modes costs no extra requests.
|
||||
*
|
||||
* Three levels, three surfaces: the trip is a card at the head, a day is a card, a stop
|
||||
* is a row inside it. The stop's name and its arrival carry the weight and everything
|
||||
* else is quiet support — no value is ever joined to another with a middot, and none of
|
||||
* them is a pill just for being a number.
|
||||
*/
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop,
|
||||
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// One drag state for the whole rail rather than one per day: a stop that cannot leave
|
||||
// its own day is exactly the move a road trip needs when a leg turns out too long.
|
||||
const [from, setFrom] = React.useState<DragState['from']>(null)
|
||||
const [dropAt, setDropAt] = React.useState<DragState['dropAt']>(null)
|
||||
const drag: DragState = { from, setFrom, dropAt, setDropAt }
|
||||
|
||||
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
|
||||
if (routes.days.length === 0) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="px-5 py-8 text-center">
|
||||
<MapPin size={18} className="mx-auto mb-2 text-content-faint" aria-hidden />
|
||||
<h3 className="font-semibold text-content" style={{ fontSize: FS.name }}>{t('roadtrip.empty.title')}</h3>
|
||||
<p className="mt-1 text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.empty.body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<Totals distance={routes.totalDistance} duration={routes.totalDuration} stops={routes.totalStops} />
|
||||
{routes.days.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center">
|
||||
<MapPin size={20} className="mx-auto mb-2.5 text-content-faint" aria-hidden />
|
||||
<h3 className="text-body font-semibold text-content">{t('roadtrip.empty.title')}</h3>
|
||||
<p className="text-caption text-content-muted mt-1">{t('roadtrip.empty.body')}</p>
|
||||
</div>
|
||||
) : (
|
||||
routes.days.map(day => (
|
||||
// The totals hold still while the days move under them: they are the answer to "how
|
||||
// long is this trip", and an answer that scrolls away is one you have to go back for.
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
|
||||
<div className="shrink-0">
|
||||
<TripSummary routes={routes} />
|
||||
</div>
|
||||
<div className="roadtrip-rail-scroll flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pb-3.5">
|
||||
{routes.days.map(day => (
|
||||
<DaySection
|
||||
key={day.dayId}
|
||||
day={day}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={onSelectStop}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
drag={drag}
|
||||
onAskAlternatives={onAskAlternatives}
|
||||
openAlternatives={openAlternatives}
|
||||
onEditStay={onEditStay}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
{routes.quietDays.map(day => (
|
||||
<QuietDaySection key={`quiet-${day.dayId}`} day={day} onMoveStopToDay={onMoveStopToDay} drag={drag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The lengths a stop usually takes, so the common answer is one tap.
|
||||
*
|
||||
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
|
||||
* the slider is for.
|
||||
*/
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120]
|
||||
|
||||
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
|
||||
const MAX = 240
|
||||
/** The step the slider and the two buttons move in. */
|
||||
const STEP = 5
|
||||
|
||||
/** The stop being given a length. */
|
||||
export interface StayDraft {
|
||||
placeId: number
|
||||
name: string
|
||||
minutes: number | null
|
||||
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
||||
arrival?: string | null
|
||||
}
|
||||
|
||||
interface RoadtripStayModalProps {
|
||||
stop: StayDraft | null
|
||||
onClose: () => void
|
||||
/** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
|
||||
onSave: (placeId: number, minutes: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
|
||||
const SLIDER = [
|
||||
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
|
||||
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
|
||||
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
|
||||
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
|
||||
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* How long the traveller stays at one stop.
|
||||
*
|
||||
* A road-trip idea rather than a property of the place: it is what pushes every later
|
||||
* arrival back, so it belongs beside the drive times rather than in the place form, which
|
||||
* is why this opens from the rail and nowhere else.
|
||||
*
|
||||
* Built around the number instead of around a field. The chosen length is the largest
|
||||
* thing on screen and updates as the slider moves, and under it the dialog answers the
|
||||
* question the number is actually being chosen for: given when the drive arrives, when
|
||||
* does it leave again. Typing minutes into a box could never show that.
|
||||
*
|
||||
* The value is `places.duration_minutes`, which the schedule has read for years and
|
||||
* nothing has ever been able to write. Cleared as a zero rather than a null, because the
|
||||
* update statement folds a null into "leave it alone" — a null could give a stop a stay
|
||||
* but never take one away.
|
||||
*/
|
||||
export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [minutes, setMinutes] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reopened on a different stop, so it starts from that stop's own value rather than
|
||||
// from whatever the last one was left on.
|
||||
useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
|
||||
|
||||
// What the stay does to the day, worked out live: the arrival is fixed by the drive,
|
||||
// the departure is the only thing this dialog moves.
|
||||
const times = useMemo(() => {
|
||||
const arrival = parseClock(stop?.arrival)
|
||||
if (arrival === null) return null
|
||||
return {
|
||||
arrive: formatClockTime(formatClock(arrival), is12h),
|
||||
leave: formatClockTime(formatClock(arrival + minutes), is12h),
|
||||
}
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
if (!stop) return null
|
||||
|
||||
const commit = async (value: number) => {
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(stop.placeId, value)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} size="sm" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hourglass size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
|
||||
|
||||
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
|
||||
be nudged without hunting for the slider's thumb. */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(-STEP)}
|
||||
disabled={minutes === 0}
|
||||
aria-label={`-${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Minus size={15} aria-hidden />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
|
||||
>
|
||||
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(STEP)}
|
||||
disabled={minutes >= MAX}
|
||||
aria-label={`+${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Plus size={15} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX}
|
||||
step={STEP}
|
||||
value={minutes}
|
||||
onChange={e => setMinutes(Number(e.target.value))}
|
||||
aria-label={t('roadtrip.stay.custom')}
|
||||
className={SLIDER}
|
||||
/>
|
||||
|
||||
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
|
||||
decides it — so the arrow shows the one end this dialog does move. */}
|
||||
{times ? (
|
||||
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.arrive')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
|
||||
</span>
|
||||
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.leave')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESETS.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMinutes(value)}
|
||||
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
|
||||
minutes === value
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
{formatDurationShort(value * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{/* Removing is the same write as setting one, so it sits with the other actions
|
||||
rather than behind a confirmation. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !stop.minutes}
|
||||
onClick={() => void commit(0)}
|
||||
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.stay.none')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void commit(minutes)}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
|
||||
return {
|
||||
osm_id: 'node:9',
|
||||
name: 'Aral Autohof',
|
||||
lat: 53.1,
|
||||
lng: 10.2,
|
||||
category: 'fuel',
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
offRouteKm: 1.2,
|
||||
alongKm: 120,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
const draft = (over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft => ({
|
||||
poi: poi(),
|
||||
dayId: 4,
|
||||
position: 2,
|
||||
dayNumber: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const noop = {
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onMoreDetails: vi.fn(),
|
||||
}
|
||||
|
||||
describe('RoadtripStopPopup', () => {
|
||||
it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
|
||||
const { container } = wrap(<RoadtripStopPopup draft={null} {...noop} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ position: 2, dayNumber: 3 })} {...noop} />)
|
||||
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// Position 2 in the array is the third stop for a reader.
|
||||
expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
// Fuel suggests ten minutes; nobody plans an hour for it.
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
// Charging is half an hour, not the ten minutes a tank takes.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Every category the corridor can search for, with the name its button carries.
|
||||
*
|
||||
* The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
|
||||
* each hit with the category its own OSM tag matched. A key the popup does not know
|
||||
* preselects nothing, and the stop is saved as an ordinary place — which is exactly
|
||||
* how food and sights used to end up as numbered destinations in the middle of a drive.
|
||||
*/
|
||||
const CORRIDOR_KINDS: [string, string][] = [
|
||||
['fuel', 'Fuel'],
|
||||
['charging', 'Charging'],
|
||||
['rest_area', 'Rest area'],
|
||||
['campsite', 'Campsite'],
|
||||
['restaurant', 'Food'],
|
||||
['sights', 'Sights'],
|
||||
]
|
||||
|
||||
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
|
||||
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
|
||||
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
|
||||
for (const [, other] of CORRIDOR_KINDS) {
|
||||
if (other === label) continue
|
||||
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'museum', name: 'Albertinum' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft()} {...noop} duplicateName="Aral Autohof" />)
|
||||
expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
|
||||
const onMoreDetails = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onMoreDetails={onMoreDetails} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More details' }))
|
||||
expect(onMoreDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
|
||||
let release: () => void = () => {}
|
||||
const onSave = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
const add = screen.getByRole('button', { name: 'Add' })
|
||||
fireEvent.click(add)
|
||||
await waitFor(() => expect(add).toBeDisabled())
|
||||
fireEvent.click(add)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Settle it inside act, or React re-enables the button after the test has finished.
|
||||
await act(async () => { release() })
|
||||
expect(add).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Hourglass, AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The four kinds of stop, with the icon and colour they already carry in the corridor
|
||||
* search, and how long each one usually takes.
|
||||
*
|
||||
* Deliberately not TREK's place categories: those are the traveller's own editable list,
|
||||
* shared across the whole instance. Refuelling is not a taste, it is a fact about the
|
||||
* place, so it lives in `places.stop_type` and keeps the palette the search gave it.
|
||||
*
|
||||
* The colours are the road-trip palette from `poiCategories.ts` and are the same in both
|
||||
* themes on purpose — a brand-neutral fuel blue reads as fuel either way.
|
||||
*/
|
||||
const STOP_KINDS: { key: RoadtripStopType; labelKey: string; Icon: LucideIcon; color: string; minutes: number }[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, minutes: 10 },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, minutes: 30 },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, minutes: 20 },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, minutes: 60 },
|
||||
// The corridor can find these two as well, and anything picked off "along the route"
|
||||
// is a pause on the way rather than a destination — so they get a kind like the rest.
|
||||
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, minutes: 45 },
|
||||
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, minutes: 30 },
|
||||
]
|
||||
|
||||
/** How long to stand still, offered as the few answers anyone actually gives. */
|
||||
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
|
||||
|
||||
export interface RoadtripStopDraft {
|
||||
poi: CorridorPoi
|
||||
dayId: number
|
||||
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
|
||||
position: number
|
||||
dayNumber: number
|
||||
}
|
||||
|
||||
interface RoadtripStopPopupProps {
|
||||
draft: RoadtripStopDraft | null
|
||||
/** Names of stops already on this trip that came from the same OSM object. */
|
||||
duplicateName?: string | null
|
||||
onClose: () => void
|
||||
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
|
||||
/** Opens the full place form instead, carrying what has been filled in so far. */
|
||||
onMoreDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding something found along the drive, without the full place form.
|
||||
*
|
||||
* The form is right for a place you are planning a day around; it is wrong for a petrol
|
||||
* station. Everything it asks for — category, price, photo, notes, files — is empty for a
|
||||
* refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
|
||||
* which neither column shows. This asks the two questions that matter (what kind of stop,
|
||||
* how long) and says where it will land, with a way out to the full form for the rest.
|
||||
*/
|
||||
export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.minutes ?? 30)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!draft) return null
|
||||
|
||||
const kind = STOP_KINDS.find(k => k.key === stopType)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ stopType, dwellMinutes: dwell })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
||||
// theme-lint-disable — the road-trip palette, the same one the map pin and the
|
||||
// result row use; a token here would make the three disagree.
|
||||
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
|
||||
>
|
||||
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
|
||||
<p className="mt-0.5 text-caption text-content-muted">
|
||||
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{duplicateName ? (
|
||||
// The full place form warns about duplicates; losing that warning would make
|
||||
// this the easiest way to add the same petrol station twice.
|
||||
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.stop.duplicate', { name: duplicateName })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.kind')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon }) => {
|
||||
const on = stopType === key
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
setStopType(on ? null : key)
|
||||
// Picking a kind is also picking how long it takes, until the user
|
||||
// says otherwise — a charge is not a fuel stop.
|
||||
if (!on) setDwell(STOP_KINDS.find(k => k.key === key)!.minutes)
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
|
||||
on
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{DWELL_CHOICES.map(minutes => (
|
||||
<button
|
||||
key={minutes}
|
||||
type="button"
|
||||
aria-pressed={dwell === minutes}
|
||||
onClick={() => setDwell(minutes)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
|
||||
dwell === minutes
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Hourglass size={11} aria-hidden />
|
||||
{formatDurationShort(minutes * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreDetails}
|
||||
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('roadtrip.stop.moreDetails')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t('roadtrip.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* How the offered routes are drawn, after Apple Maps.
|
||||
*
|
||||
* All of them blue, not one blue and the rest grey: they are all the same road network
|
||||
* and all equally drivable, so the difference between them is emphasis, not category. The
|
||||
* chosen one is the full-strength blue the route already uses; the others are a pale blue
|
||||
* that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
|
||||
* line on it disappears, which is exactly what happened when these were grey.
|
||||
*
|
||||
* Fixed values rather than theme tokens: they have to match the blue the route itself is
|
||||
* drawn in, which is fixed too, and a token that shifted with the colour scheme would
|
||||
* break the pairing.
|
||||
*/
|
||||
|
||||
/** The route as chosen — same blue as the drawn trip route. */
|
||||
export const ALT_PRIMARY = '#0a84ff'
|
||||
/** The other offers: unmistakably blue, unmistakably secondary. */
|
||||
export const ALT_SECONDARY = '#7eb8f0'
|
||||
/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
|
||||
export const ALT_CASING = '#ffffff'
|
||||
|
||||
/**
|
||||
* The label colours, again after Apple: the chosen route's time is a filled blue pill,
|
||||
* the alternatives' are near-black. Both carry white text.
|
||||
*/
|
||||
export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
|
||||
export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
|
||||
export const ALT_LABEL_TEXT = '#ffffff'
|
||||
@@ -0,0 +1,125 @@
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
|
||||
export interface AlternativeOverlay {
|
||||
index: number
|
||||
coordinates: [number, number][]
|
||||
color: string
|
||||
/** The drive time, the way Apple Maps puts it on the road itself. */
|
||||
label: string
|
||||
/** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
|
||||
note: string
|
||||
/** How long this way takes and how far it is, for the list beside the map. */
|
||||
duration: number
|
||||
distance: number
|
||||
/** Seconds more than the quickest offer; negative never happens by construction. */
|
||||
slowerThanQuickest: number
|
||||
labelBg: string
|
||||
/** Where to hang the label — a point on this route and on no other. */
|
||||
at: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance between two points, in degrees, with longitude scaled by latitude.
|
||||
*
|
||||
* Only ever compared against other values from this same function, so degrees are fine
|
||||
* and a haversine would cost more for an answer nobody reads.
|
||||
*/
|
||||
function roughDist(a: [number, number], b: [number, number]): number {
|
||||
const dLat = a[0] - b[0]
|
||||
const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
|
||||
return dLat * dLat + dLng * dLng
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of `line` that lies furthest from every one of `others`.
|
||||
*
|
||||
* This is where a route is unmistakably itself. Anchoring a label at the geometric
|
||||
* midpoint instead put it wherever that happened to fall — and on a pair of routes that
|
||||
* split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
|
||||
* to be pinned to the wrong road.
|
||||
*
|
||||
* Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
|
||||
* thousand-point lines in full costs milliseconds for a pixel of accuracy.
|
||||
*/
|
||||
function mostDistinctPoint(
|
||||
line: [number, number][],
|
||||
others: [number, number][][],
|
||||
): { lat: number; lng: number } | null {
|
||||
if (!line.length) return null
|
||||
if (!others.length) {
|
||||
const mid = line[Math.floor(line.length / 2)]
|
||||
return mid ? { lat: mid[0], lng: mid[1] } : null
|
||||
}
|
||||
const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
|
||||
let best: [number, number] | null = null
|
||||
let bestScore = -1
|
||||
for (let i = 0; i < line.length; i += sample(line)) {
|
||||
// How far this point is from the NEAREST other route: a point close to any of them
|
||||
// is not distinctive, however far it sits from the rest.
|
||||
let nearestOfAll = Infinity
|
||||
for (const other of others) {
|
||||
let nearest = Infinity
|
||||
const step = sample(other)
|
||||
for (let j = 0; j < other.length; j += step) {
|
||||
const d = roughDist(line[i], other[j])
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest < nearestOfAll) nearestOfAll = nearest
|
||||
}
|
||||
if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
|
||||
}
|
||||
return best ? { lat: best[0], lng: best[1] } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes as the map should draw them, built once so every renderer agrees.
|
||||
*
|
||||
* Each label hangs where its own route is furthest from all the others, so a label always
|
||||
* sits on a stretch only that route uses. Two routes that share their first and last
|
||||
* thirds still get their labels on the middle third, where they actually differ.
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
if (routes.length < 2) return []
|
||||
|
||||
// The quickest of what came back, which is what Apple calls out — not necessarily the
|
||||
// first entry, since the road currently driven is put at the top when there is one.
|
||||
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
|
||||
const anyCurrent = routes.some(r => r.current)
|
||||
|
||||
return routes.map((route, index) => {
|
||||
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
|
||||
const at = mostDistinctPoint(route.coordinates, others)
|
||||
// Blue is the road you are on: the one currently driven, or the router's own pick
|
||||
// when nothing has been bent. Everything else is the pale blue of an offer.
|
||||
const primary = route.current || (!anyCurrent && index === quickest)
|
||||
return {
|
||||
index,
|
||||
coordinates: route.coordinates,
|
||||
color: primary ? ALT_PRIMARY : ALT_SECONDARY,
|
||||
label: formatDurationShort(route.duration),
|
||||
// What this way IS beats what it is not: a road offered because the motorway was
|
||||
// left out of it says so, rather than being labelled by how much slower it is —
|
||||
// that is the reason somebody would take it.
|
||||
note: route.current
|
||||
? labels.current
|
||||
: route.avoids === 'motorway'
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
|
||||
at: at ?? { lat: 0, lng: 0 },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorAfterReorder,
|
||||
isEmptyReanchoring,
|
||||
type AnchoredVia,
|
||||
} from './roadtripModel'
|
||||
|
||||
/**
|
||||
* A via lives at a position in the day's stop list, and that position is resolved fresh
|
||||
* every time the day is routed. These tests pin what happens to it when the list moves
|
||||
* under it — the arithmetic that decides whether a reshaped drive survives adding a
|
||||
* petrol station to it.
|
||||
*/
|
||||
const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
|
||||
({ id, after_order_index: after, lat, lng })
|
||||
|
||||
/** The anchors as a plain map, so a test reads as "which via ended up where". */
|
||||
const anchors = (moved: { id: number; after_order_index: number }[]) =>
|
||||
Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
|
||||
|
||||
describe('reanchorAfterInsert', () => {
|
||||
it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
|
||||
// Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
|
||||
// The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
|
||||
// position 1. The via is passed before the fuel stop, so it belongs to the first half.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
|
||||
// Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
|
||||
// first half back on itself and leave the long second half unshaped — which is the
|
||||
// route snapping back to the old road.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
|
||||
const early = via(1, 1)
|
||||
const late = via(2, 1)
|
||||
const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
|
||||
throw new Error('nothing is split when the new stop leads the day')
|
||||
})
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
|
||||
// Four stops, legs 0..2, appended as the fifth.
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-008: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterRemove', () => {
|
||||
it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
|
||||
// Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
|
||||
const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
|
||||
// Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
|
||||
expect(plan.remove).toEqual([2])
|
||||
expect(anchors(plan.vias)).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterReorder', () => {
|
||||
it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
|
||||
// Five stops, legs 0..3. Stop 1 moves to position 3.
|
||||
const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
|
||||
// Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
|
||||
// Moving the first stop kills the leg leaving it, and nothing later re-creates that
|
||||
// via's anchor — reporting it for deletion is the only honest answer.
|
||||
const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-016: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
computeSchedule,
|
||||
formatClock,
|
||||
formatDurationShort,
|
||||
insertIndexForAlong,
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
@@ -218,3 +219,33 @@ describe('sumLegSeconds', () => {
|
||||
expect(sumLegSeconds([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertIndexForAlong', () => {
|
||||
// Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
|
||||
const stops = [0, 50, 290]
|
||||
|
||||
it('puts a hit between the two stops it falls between', () => {
|
||||
expect(insertIndexForAlong(stops, 120)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 20)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands before the stop the day starts from', () => {
|
||||
expect(insertIndexForAlong(stops, 0)).toBe(1)
|
||||
// A corridor is wider than the road, so a hit can project just behind the start.
|
||||
expect(insertIndexForAlong(stops, -3)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands after the stop the day ends at', () => {
|
||||
expect(insertIndexForAlong(stops, 290)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 400)).toBe(2)
|
||||
})
|
||||
|
||||
it('puts a hit exactly on a stop after it, not before', () => {
|
||||
expect(insertIndexForAlong(stops, 50)).toBe(2)
|
||||
})
|
||||
|
||||
it('has nowhere to insert on a day that is not a drive', () => {
|
||||
expect(insertIndexForAlong([], 10)).toBe(0)
|
||||
expect(insertIndexForAlong([0], 10)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,54 @@
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
* Every kind the corridor search can find, and the list is meant to stay that way: what
|
||||
* you pick off "along the route" is by definition something you come across on the way,
|
||||
* so it belongs inside the leg it was found on rather than among the places the trip is
|
||||
* for. They are drawn on the dashed line with their own icon, carry no number, and are
|
||||
* left out of every stop count — four places with a charger, a rest stop and a bakery
|
||||
* between them is a four-stop day.
|
||||
*
|
||||
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
|
||||
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
|
||||
* a destination on one screen and a pause on the next.
|
||||
*/
|
||||
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
|
||||
|
||||
/**
|
||||
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
|
||||
*
|
||||
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
|
||||
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
|
||||
* printed in, a campsite is green. The last two are the colours the general place search
|
||||
* already gives food and sights, so a restaurant found along the route looks like the
|
||||
* restaurants found anywhere else.
|
||||
*
|
||||
* Deliberately literal and shared rather than tokenised: these mean the same thing in
|
||||
* both themes the way a motorway sign does, and one definition is what stops the rail,
|
||||
* the search result and the map pin from drifting into three different reds.
|
||||
*/
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
fuel: '#E8590C',
|
||||
charging: '#CA8A04',
|
||||
rest_area: '#3B82F6',
|
||||
campsite: '#16A34A',
|
||||
restaurant: '#EF4444',
|
||||
sights: '#EC4899',
|
||||
}
|
||||
|
||||
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
|
||||
export function serviceColor(stopType: string | null | undefined): string {
|
||||
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
|
||||
}
|
||||
|
||||
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
|
||||
export function isServiceStopType(stopType: string | null | undefined): boolean {
|
||||
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
|
||||
}
|
||||
|
||||
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
|
||||
export function formatDurationShort(seconds: number): string {
|
||||
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||||
@@ -170,3 +218,184 @@ export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => stri
|
||||
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a place found along the drive belongs in the day's chain.
|
||||
*
|
||||
* Both numbers are distances travelled along the same routed line, so the comparison is
|
||||
* "which stops has the car already passed when it reaches this one". A petrol station
|
||||
* 120 km into the day goes after every stop nearer than that and before the rest,
|
||||
* which is what makes adding one a single click rather than a drag afterwards.
|
||||
*
|
||||
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
|
||||
* slightly before the first stop or past the last, and neither "before where you set off"
|
||||
* nor "after where you arrive" is a place a fuel stop can go.
|
||||
*/
|
||||
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
|
||||
if (stopsAlongKm.length < 2) return stopsAlongKm.length
|
||||
let i = 0
|
||||
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
|
||||
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
|
||||
}
|
||||
|
||||
/* ── Via re-anchoring ────────────────────────────────────────────────────────
|
||||
*
|
||||
* A via records which stop it follows as a POSITION in the day's stop list, and that
|
||||
* position is resolved fresh every time the day is routed. So the moment the list
|
||||
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
|
||||
* anchor at or past the change means a different leg than it did when it was written.
|
||||
* The visible symptom is the route quietly snapping back to the road the user had
|
||||
* steered it away from, with the via handle still sitting on the map where they left it.
|
||||
*
|
||||
* Nothing about that is repairable after the fact: once the stops have moved there is no
|
||||
* record of which leg a via was drawn for. So the correction has to be computed against
|
||||
* the list as it stands and applied with the same change.
|
||||
*
|
||||
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
|
||||
* which half of a split leg a via belongs to — is asked of the caller, because only the
|
||||
* caller has the routed line to measure against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A via, reduced to what re-anchoring needs to know about it.
|
||||
*
|
||||
* The coordinates are part of it because the one decision that is not pure arithmetic —
|
||||
* which half of a split leg a via belongs to — is answered by measuring where it sits on
|
||||
* the road, and the caller is handed the whole via to measure.
|
||||
*/
|
||||
export interface AnchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Where one via ends up. */
|
||||
export interface ReanchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The corrected anchoring, and the vias that no longer have a leg at all.
|
||||
*
|
||||
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
|
||||
* write for no reason, and it keeps the diff readable when something goes wrong.
|
||||
*/
|
||||
export interface Reanchoring {
|
||||
vias: ReanchoredVia[]
|
||||
remove: number[]
|
||||
}
|
||||
|
||||
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
|
||||
|
||||
/** Only what moved, so an operation that changes nothing costs no request. */
|
||||
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const next = at(via.after_order_index)
|
||||
if (next === null) remove.push(via.id)
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was inserted at `position`, so everything from there on sits one place later.
|
||||
*
|
||||
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
|
||||
* before the new stop stays on the first half, one after it moves to the second. That is
|
||||
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
|
||||
* the reshaping — the via went to the first half by accident of arithmetic, the second
|
||||
* half got none, and the router took its own road again for the rest of the leg.
|
||||
*
|
||||
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
|
||||
* one: inserting at the very front or the very end splits nothing.
|
||||
*/
|
||||
export function reanchorAfterInsert(
|
||||
vias: AnchoredVia[],
|
||||
position: number,
|
||||
liesBeforeInsert: (via: AnchoredVia) => boolean,
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const split = position - 1
|
||||
const moved: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
const i = via.after_order_index
|
||||
let next = i
|
||||
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
|
||||
else if (i >= position) next = i + 1
|
||||
if (next !== i) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was taken out at `position`, so the legs either side of it become one.
|
||||
*
|
||||
* A via on either half keeps bending the same drive, so both end up on the merged leg —
|
||||
* dropping them would throw away a decision the user made about a road that still exists.
|
||||
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
|
||||
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
|
||||
* to sit. Those are reported for deletion rather than left pointing past the day.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the removal.
|
||||
*/
|
||||
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
// Two stops become one: the day has no drive left, so no via has a leg.
|
||||
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
|
||||
return collect(vias, i => {
|
||||
if (position === 0) return i === 0 ? null : i - 1
|
||||
if (position === stopCount - 1) return i === position - 1 ? null : i
|
||||
if (i === position) return position - 1
|
||||
return i > position ? i - 1 : i
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop moved from `from` to `to` inside its own day.
|
||||
*
|
||||
* Composed from the two operations it actually is — taken out, put back — because that
|
||||
* is the only reading under which the answer stays consistent with the other two. No
|
||||
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
|
||||
* themselves are about to be different and there is nothing meaningful to measure a via
|
||||
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
|
||||
* them where they were relative to the stop they were drawn after.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
|
||||
* it looks AFTER the stop has been taken out.
|
||||
*/
|
||||
export function reanchorAfterReorder(
|
||||
vias: AnchoredVia[],
|
||||
from: number,
|
||||
to: number,
|
||||
stopCount: number,
|
||||
): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
|
||||
const dropped = new Set(afterRemove.remove)
|
||||
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
// The list the insert acts on is the one the removal left behind, so each via is
|
||||
// carried through at whatever index it holds by then.
|
||||
const shifted: AnchoredVia[] = vias
|
||||
.filter(v => !dropped.has(v.id))
|
||||
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
|
||||
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
|
||||
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
|
||||
const result: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
if (dropped.has(via.id)) continue
|
||||
const mid = movedTo.get(via.id) ?? via.after_order_index
|
||||
const end = finalIndex.get(via.id) ?? mid
|
||||
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
|
||||
}
|
||||
return { vias: result, remove: afterRemove.remove }
|
||||
}
|
||||
|
||||
/** Whether a re-anchoring has anything to write at all. */
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createElement } from 'react'
|
||||
import { Fuel, Zap, Coffee, Tent, Utensils, Camera, type LucideIcon } from 'lucide-react'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { isServiceStopType, serviceColor } from './roadtripModel'
|
||||
|
||||
/** The same icons the rail puts on its discs, so one kind of stop looks like itself. */
|
||||
const SERVICE_ICON: Record<string, LucideIcon> = {
|
||||
fuel: Fuel,
|
||||
charging: Zap,
|
||||
rest_area: Coffee,
|
||||
campsite: Tent,
|
||||
restaurant: Utensils,
|
||||
sights: Camera,
|
||||
}
|
||||
|
||||
/**
|
||||
* Smaller than a place marker on purpose.
|
||||
*
|
||||
* A petrol station is not a destination — it is something the drive passes through — and
|
||||
* a marker the same size as the places the trip is for made a day look like it had twice
|
||||
* as many stops as it has. Selected grows a little, the same way a place marker does, so
|
||||
* clicking one still confirms itself.
|
||||
*/
|
||||
export const SERVICE_MARKER_SIZE = 24
|
||||
export const SERVICE_MARKER_SIZE_SELECTED = 30
|
||||
const BORDER = 2
|
||||
|
||||
/** The full box a service marker occupies, border included — what an anchor centres on. */
|
||||
export function serviceMarkerOuter(selected: boolean): number {
|
||||
return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker for a stop that interrupts the drive, or null for an ordinary place.
|
||||
*
|
||||
* One flat disc in the kind's own colour with a white icon — never the place's photo or
|
||||
* its brand logo. A fuel stop used to come back from the place search carrying the
|
||||
* operator's logo, so the map showed an Esso roundel where every other road-trip surface
|
||||
* showed a pump: unreadable at marker size, different for every brand, and nothing at all
|
||||
* for the ones with no logo on file.
|
||||
*
|
||||
* Built as markup rather than as a component because both renderers need a string: one
|
||||
* feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
|
||||
*/
|
||||
export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
|
||||
if (!isServiceStopType(stopType)) return null
|
||||
const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
|
||||
const Icon = SERVICE_ICON[stopType ?? ''] ?? Coffee
|
||||
const svg = renderIconMarkup(createElement(Icon, {
|
||||
size: Math.round(size * 0.56),
|
||||
color: '#fff',
|
||||
strokeWidth: 2.4,
|
||||
}))
|
||||
const ring = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 2px 6px rgba(0,0,0,0.25)'
|
||||
return `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${serviceColor(stopType)};
|
||||
border:${BORDER}px solid #fff;box-shadow:${ring};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;cursor:pointer;
|
||||
">${svg}</div>`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The road trip's type scale, pinned to the day plan's.
|
||||
*
|
||||
* Both road trip panels sit beside the day plan and the tab switcher flips between
|
||||
* them, so a road trip whose names ran a size larger than the day plan's read as a
|
||||
* different app.
|
||||
* The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
|
||||
* (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
|
||||
* drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
|
||||
*
|
||||
* Written as `calc(px * var(--fs-scale-<tier>))` rather than as a tier utility because
|
||||
* the tiers land on 24/18/14/12px and this rail needs the steps between them; the
|
||||
* multiplier is what keeps the user's per-tier text-size setting reaching them. A value
|
||||
* and its unit always share one tier, so scaling one tier cannot break a pair apart.
|
||||
*/
|
||||
export const FS = {
|
||||
/** A total in the head. */
|
||||
total: 'calc(20px * var(--fs-scale-title, 1))',
|
||||
/** The unit hanging off one. */
|
||||
totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
|
||||
/** A day's name — the day plan's own size. */
|
||||
dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
/** A stop's name — the day plan's own size. */
|
||||
name: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
/** A clock reading, and a service stop's dwell. */
|
||||
time: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
/** A drive band, a date, a line of explanation. */
|
||||
meta: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
/** The number in a marker. */
|
||||
marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
|
||||
/** A badge, a caption over a total. */
|
||||
label: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
/** The word "Stay" — the smallest thing in the rail. */
|
||||
micro: 'calc(8px * var(--fs-scale-caption, 1))',
|
||||
/** The name of a panel — "Along the route". The largest text either panel carries. */
|
||||
panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
|
||||
/** A pill, a segment, a line of progress: anything you press but do not read as prose. */
|
||||
control: 'calc(10.5px * var(--fs-scale-body, 1))',
|
||||
} as const
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||
@@ -19,7 +19,16 @@ export interface CorridorSearch {
|
||||
capped: boolean
|
||||
/** Boxes the place search could not answer — a partial result is still shown. */
|
||||
failedAreas: number
|
||||
/**
|
||||
* Boxes where the server hit its own per-request ceiling and dropped the rest.
|
||||
* Distinct from `capped`, which is about stretches never asked for at all: here the
|
||||
* stretch was searched and the answer came back short. Without saying so, filtering
|
||||
* the results cannot tell "nothing of that name on the way" from "cut off before it".
|
||||
*/
|
||||
truncatedAreas: number
|
||||
error: boolean
|
||||
/** The thinned line every `alongKm` above is measured along. */
|
||||
spine: LatLng[]
|
||||
search: () => void
|
||||
clear: () => void
|
||||
}
|
||||
@@ -61,10 +70,21 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [capped, setCapped] = useState(false)
|
||||
const [failedAreas, setFailedAreas] = useState(0)
|
||||
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
||||
const [error, setError] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const runIdRef = useRef(0)
|
||||
|
||||
/**
|
||||
* The routed line, thinned. A corridor built from every vertex would make hundreds of
|
||||
* overlapping boxes out of what a handful describe just as well.
|
||||
*
|
||||
* Shared rather than local to the search, because every `alongKm` in the results is a
|
||||
* distance along THIS line. Anything comparing a hit against the day's own stops has to
|
||||
* project them onto the same one, or the two sets of numbers do not mean the same thing.
|
||||
*/
|
||||
const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setResults([])
|
||||
@@ -72,6 +92,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
setLoading(false)
|
||||
setCapped(false)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
}, [])
|
||||
|
||||
@@ -82,19 +103,17 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
|
||||
const search = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
if (line.length < 2 || categories.length === 0) return
|
||||
if (spine.length < 2 || categories.length === 0) return
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const runId = ++runIdRef.current
|
||||
|
||||
// Thin the routed geometry first: a corridor built from every vertex would make
|
||||
// hundreds of overlapping boxes out of what a handful describe just as well.
|
||||
const spine = simplifyLine(line, Math.max(1, widthKm / 3))
|
||||
const allTiles = corridorTiles(spine, widthKm)
|
||||
const tiles = allTiles.slice(0, MAX_TILES)
|
||||
|
||||
setCapped(allTiles.length > tiles.length)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
setResults([])
|
||||
setLoading(true)
|
||||
@@ -109,6 +128,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
void (async () => {
|
||||
const seen = new Map<string, CorridorPoi>()
|
||||
let failures = 0
|
||||
let shortened = 0
|
||||
let done = 0
|
||||
let next = 0
|
||||
|
||||
@@ -120,6 +140,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
const collect = async (tile: Bbox): Promise<boolean> => {
|
||||
try {
|
||||
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
|
||||
// The server answers with at most a few hundred per box and says so. A hit
|
||||
// dropped there is invisible here, so the count has to reach the surface.
|
||||
if (data.truncated) setTruncatedAreas(++shortened)
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
@@ -166,9 +189,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
setError(failures === jobs.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [line, categories, widthKm, locale])
|
||||
}, [spine, categories, widthKm, locale])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
return { results, progress, loading, capped, failedAreas, error, search, clear }
|
||||
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
|
||||
}
|
||||
|
||||
@@ -1,148 +1,145 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
|
||||
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
|
||||
|
||||
import { useRoadtripCorridor, CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM } from './useRoadtripCorridor'
|
||||
import { useRoadtripCorridor } from './useRoadtripCorridor'
|
||||
|
||||
const stop = (id: number, lat: number, lng: number): RoadtripStop => ({
|
||||
assignmentId: id,
|
||||
placeId: id * 10,
|
||||
name: `Stop ${id}`,
|
||||
lat,
|
||||
lng,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
})
|
||||
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
|
||||
return {
|
||||
lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
|
||||
phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 1,
|
||||
dayNumber: 1,
|
||||
date: null,
|
||||
title: null,
|
||||
stops: [stop(1, 53.55, 9.99), stop(2, 52.52, 13.4)],
|
||||
legs: [],
|
||||
schedule: { entries: [], warnings: [] },
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
dayId: 1, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], schedule: { entries: [], warnings: [] },
|
||||
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days,
|
||||
lines: [],
|
||||
segments: [],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
totalStops: 0,
|
||||
loading: false,
|
||||
days, lines: [], segments: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
/** What the corridor hook was handed on the last render. */
|
||||
const lastCall = (): { line: LatLng[]; categories: string[]; widthKm: number } => {
|
||||
const calls = useCorridorPois.mock.calls
|
||||
const call = calls[calls.length - 1]
|
||||
return { line: call[0], categories: call[1], widthKm: call[2] }
|
||||
/** The search state the mocked hook hands back, results included. */
|
||||
function searchWith(results: CorridorPoi[]): CorridorSearch {
|
||||
return {
|
||||
results,
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
// The thinned line the day's geometry becomes; hits and stops are measured along it.
|
||||
spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCorridorPois.mockReset()
|
||||
useCorridorPois.mockReturnValue({
|
||||
results: [], progress: { done: 0, total: 0 }, loading: false,
|
||||
capped: false, failedAreas: 0, error: false, search: vi.fn(), clear: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRoadtripCorridor', () => {
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-001: searches along the roads driven, not the line between stops', () => {
|
||||
// The motorway swings north of the straight line; searching the straight line finds
|
||||
// open country, which is the bug this replaced.
|
||||
const geometry: [number, number][] = [[53.55, 9.99], [53.6, 11.2], [52.52, 13.4]]
|
||||
renderHook(() => useRoadtripCorridor(routes([day({ geometry })])))
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
expect(lastCall().line).toEqual([
|
||||
{ lat: 53.55, lng: 9.99 },
|
||||
{ lat: 53.6, lng: 11.2 },
|
||||
{ lat: 52.52, lng: 13.4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-002: falls back to the stops until the day has routed', () => {
|
||||
renderHook(() => useRoadtripCorridor(routes([day({ geometry: [] })])))
|
||||
|
||||
expect(lastCall().line).toEqual([
|
||||
{ lat: 53.55, lng: 9.99 },
|
||||
{ lat: 52.52, lng: 13.4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-003: opens on the first day with a drive, before anything is picked', () => {
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day({ dayId: 4 }), day({ dayId: 5 })])))
|
||||
|
||||
expect(result.current.dayId).toBe('4')
|
||||
expect(result.current.day?.dayId).toBe(4)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-004: picking a day switches which drive is searched', () => {
|
||||
const second = day({ dayId: 5, geometry: [[48.1, 11.5], [47.8, 12.1]] })
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day({ dayId: 4 }), second])))
|
||||
|
||||
act(() => { result.current.setDayId('5') })
|
||||
|
||||
expect(result.current.day?.dayId).toBe(5)
|
||||
expect(lastCall().line).toEqual([{ lat: 48.1, lng: 11.5 }, { lat: 47.8, lng: 12.1 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-005: follows along when the chosen day disappears', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ days }: { days: RoadtripDay[] }) => useRoadtripCorridor(routes(days)),
|
||||
{ initialProps: { days: [day({ dayId: 4 }), day({ dayId: 5 })] } },
|
||||
)
|
||||
act(() => { result.current.setDayId('5') })
|
||||
expect(result.current.day?.dayId).toBe(5)
|
||||
|
||||
rerender({ days: [day({ dayId: 4 })] })
|
||||
expect(result.current.dayId).toBe('4')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-006: toggling a kind adds it and toggling again removes it', () => {
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
expect(result.current.categories).toEqual(['fuel'])
|
||||
|
||||
act(() => { result.current.toggleCategory('campsite') })
|
||||
expect(result.current.categories).toEqual(['fuel', 'campsite'])
|
||||
|
||||
act(() => { result.current.toggleCategory('fuel') })
|
||||
expect(result.current.categories).toEqual(['campsite'])
|
||||
expect(lastCall().categories).toEqual(['campsite'])
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-007: the width is handed straight to the search', () => {
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Aral Autohof' }),
|
||||
poi({ osm_id: 'b', name: 'Shell Nord' }),
|
||||
]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
expect(lastCall().widthKm).toBe(5)
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
act(() => { result.current.setWidthKm(10) })
|
||||
expect(lastCall().widthKm).toBe(10)
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-008: a trip with no drive has nothing to search along', () => {
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([])))
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
|
||||
poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
|
||||
]))
|
||||
|
||||
expect(result.current.day).toBeUndefined()
|
||||
expect(result.current.dayId).toBe('')
|
||||
expect(lastCall().line).toEqual([])
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('Shell'))
|
||||
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-009: the offered kinds and widths are the ones the panel draws', () => {
|
||||
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'])
|
||||
expect(CORRIDOR_WIDTHS_KM).toEqual([2, 5, 10])
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter(' '))
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('aral'))
|
||||
expect(result.current.nameFilter).toBe('aral')
|
||||
|
||||
// A search begins: keeping the term would hide the new hits behind the old query.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
|
||||
// Hits arrive box by box and publish as they land. Clearing on every batch instead of
|
||||
// on the start of a search wiped out whatever had been typed in the meantime.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
useCorridorPois.mockReturnValue({
|
||||
...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
|
||||
loading: true,
|
||||
})
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('shell')
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
const before = result.current.visible
|
||||
rerender()
|
||||
|
||||
// Both map renderers rebuild every marker when this array's identity changes.
|
||||
expect(result.current.visible).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCorridorPois, type CorridorSearch } from './useCorridorPois'
|
||||
import type { LatLng } from './corridor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
|
||||
import { insertIndexForAlong } from './roadtripModel'
|
||||
import { projectOntoRoute, type LatLng } from './corridor'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
/** The road-trip categories, in the order the panel shows them. */
|
||||
@@ -19,6 +20,21 @@ export interface RoadtripCorridor {
|
||||
widthKm: number
|
||||
setWidthKm: (km: number) => void
|
||||
search: CorridorSearch
|
||||
/** Narrows what was found by name or brand. Empty means everything. */
|
||||
nameFilter: string
|
||||
setNameFilter: (value: string) => void
|
||||
/**
|
||||
* What both the panel and the map show: the hits that match `nameFilter`.
|
||||
* Read this, never `search.results`, or the two drift apart.
|
||||
*/
|
||||
visible: CorridorPoi[]
|
||||
/**
|
||||
* Which position in the day's chain a hit belongs at, so adding one lands it in the
|
||||
* order it will actually be driven past rather than at the end of the day.
|
||||
*/
|
||||
insertIndexFor: (poi: CorridorPoi) => number
|
||||
/** How far along the drive each of the day's stops sits, in the same units as a hit. */
|
||||
stopsAlongKm: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +48,7 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
|
||||
const [dayId, setDayId] = useState<string>('')
|
||||
const [categories, setCategories] = useState<string[]>(['fuel'])
|
||||
const [widthKm, setWidthKm] = useState<number>(5)
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
|
||||
// Falls back to the first day with a drive, so the panel is useful before the user
|
||||
// has picked anything — and follows along when that day disappears.
|
||||
@@ -53,6 +70,56 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
|
||||
setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
|
||||
}, [])
|
||||
|
||||
// A filter kept across a new search would hide the fresh answer behind the old question.
|
||||
// Cleared when a search STARTS, not whenever results change: hits arrive box by box and
|
||||
// publish as they land, so resetting on every batch wiped out anything typed while the
|
||||
// search was still running.
|
||||
const searching = search.loading
|
||||
const wasSearching = useRef(false)
|
||||
useEffect(() => {
|
||||
if (searching && !wasSearching.current) setNameFilter('')
|
||||
wasSearching.current = searching
|
||||
}, [searching])
|
||||
|
||||
/**
|
||||
* Memoised on purpose, not filtered where it is drawn: both map renderers tear down and
|
||||
* rebuild every POI marker whenever the array's identity changes, so a `.filter()` in a
|
||||
* render body would make the pins flicker on each keystroke and on every unrelated
|
||||
* re-render.
|
||||
*
|
||||
* Brand counts as a name here because the server folds `operator` into `brand`, and
|
||||
* "Shell" is what someone types when the OSM name is "Shell Autohof Nord".
|
||||
*/
|
||||
const visible = useMemo(() => {
|
||||
const needle = nameFilter.trim().toLowerCase()
|
||||
if (!needle) return search.results
|
||||
return search.results.filter(p =>
|
||||
p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))
|
||||
}, [search.results, nameFilter])
|
||||
|
||||
/**
|
||||
* Where a hit belongs in the day's chain, as an index among its stops.
|
||||
*
|
||||
* The stops are projected onto the same thinned line the hits were measured against, so
|
||||
* both sides are distances along one drive and the comparison is simply "which stops has
|
||||
* the car passed by then". A stop the projection cannot place (nowhere near the routed
|
||||
* line) keeps its neighbours' order rather than jumping to the front.
|
||||
*/
|
||||
const stopsAlongKm = useMemo(() => {
|
||||
if (!day || search.spine.length < 2) return []
|
||||
let last = 0
|
||||
return day.stops.map(s => {
|
||||
const hit = projectOntoRoute({ lat: s.lat, lng: s.lng }, search.spine)
|
||||
if (hit) last = hit.alongKm
|
||||
return last
|
||||
})
|
||||
}, [day, search.spine])
|
||||
|
||||
const insertIndexFor = useCallback(
|
||||
(poi: CorridorPoi) => insertIndexForAlong(stopsAlongKm, poi.alongKm),
|
||||
[stopsAlongKm],
|
||||
)
|
||||
|
||||
return {
|
||||
dayId: day ? String(day.dayId) : '',
|
||||
setDayId,
|
||||
@@ -62,5 +129,10 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
|
||||
widthKm,
|
||||
setWidthKm,
|
||||
search,
|
||||
nameFilter,
|
||||
setNameFilter,
|
||||
visible,
|
||||
insertIndexFor,
|
||||
stopsAlongKm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,19 @@ import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../types'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
// class declared down there would not exist yet when the hook does its `instanceof`.
|
||||
const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
||||
class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super('refused')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
|
||||
}
|
||||
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
|
||||
})
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
|
||||
|
||||
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
@@ -18,6 +29,7 @@ interface StopSpec {
|
||||
dwell?: number | null
|
||||
legMode?: string | null
|
||||
incoming?: string | null
|
||||
stopType?: string | null
|
||||
noCoords?: boolean
|
||||
}
|
||||
|
||||
@@ -40,6 +52,7 @@ function assignment(spec: StopSpec, order: number): Assignment {
|
||||
lng: spec.noCoords ? null : spec.at[1],
|
||||
place_time: null,
|
||||
duration_minutes: spec.dwell ?? null,
|
||||
stop_type: spec.stopType ?? null,
|
||||
},
|
||||
} as unknown as Assignment
|
||||
}
|
||||
@@ -124,6 +137,24 @@ describe('useRoadtripRoutes', () => {
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-013: a charger on the way is part of the drive, not a stop', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG },
|
||||
{ id: 2, at: [53, 11], stopType: 'charging' },
|
||||
{ id: 3, at: BERLIN },
|
||||
]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, [53, 11], BERLIN]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Three assignments, three markers on the map, two places the trip is for. The
|
||||
// charger still routes — it is only left out of the count the head shows.
|
||||
expect(result.current.days[0].stops).toHaveLength(3)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-005: sums the legs and hands the map one polyline per routed run', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
@@ -219,6 +250,108 @@ describe('useRoadtripRoutes', () => {
|
||||
expect(result.current.totalStops).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-012: a stop whose id changes after saving keeps its legs and its clock', async () => {
|
||||
// The optimistic insert: a stop added mid-day carries a temporary negative id until
|
||||
// the server answers, then the real id replaces it without a coordinate moving. Since
|
||||
// `planKey` is geometry only, no refetch follows — so legs filed under the id would be
|
||||
// stranded, and a broken chain blanks every arrival after it.
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
const days = [day(1, 1)]
|
||||
const optimistic: StopSpec[] = [
|
||||
{ id: 1, at: HAMBURG, time: '09:00' },
|
||||
{ id: -1755000000, at: LUENEBURG },
|
||||
{ id: 2, at: BERLIN },
|
||||
]
|
||||
const first = map(1, optimistic)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
|
||||
{ initialProps: { assignments: first } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
||||
|
||||
const saved = JSON.parse(JSON.stringify(first)) as AssignmentsMap
|
||||
saved['1'][1].id = 4711
|
||||
rerender({ assignments: saved })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
||||
expect(result.current.days[0].schedule.entries[2].arrival).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-013: a rate-limited host is given the time it asked for', async () => {
|
||||
// The public OSRM instances answer 429 above roughly one request a second and say how
|
||||
// long to wait. Retrying sooner than that is just a second refusal, so the host's
|
||||
// Retry-After wins over the shorter backoff the hook would otherwise use.
|
||||
vi.useFakeTimers()
|
||||
calculateRouteWithLegs
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
|
||||
.mockResolvedValue(routed(1))
|
||||
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||
|
||||
// The hook's own first backoff is 1500 ms; at that point it must still be waiting.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.days[0].legs[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-014: a via joins the routing request between the stops it follows', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
||||
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
||||
}))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// Three waypoints for two stops: the via is threaded in where it belongs.
|
||||
const [waypoints] = calculateRouteWithLegs.mock.calls[0]
|
||||
expect(waypoints).toHaveLength(3)
|
||||
expect(waypoints[1]).toEqual({ lat: 53.2, lng: 10.4 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-015: the two legs a via creates are shown as the one drive they are', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
// The router answers per waypoint pair: two legs for one stop pair.
|
||||
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
||||
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
||||
}))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// The rail shows one leg between two stops, carrying the whole drive.
|
||||
expect(result.current.days[0].legs).toHaveLength(1)
|
||||
expect(result.current.days[0].legs[0]).toMatchObject({ distance: 100000, duration: 3600 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-016: moving a via re-asks the router, renaming a place still does not', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }] }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, days, map(1, stops), 'driving', v),
|
||||
{ initialProps: { v: vias } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A via that moved is a different road, so the route has to be asked for again.
|
||||
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.9 }] } })
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
|
||||
const days = [day(1, 1)]
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { computeSchedule, splitIntoRuns, type Schedule } from './roadtripModel'
|
||||
import { computeSchedule, isServiceStopType, splitIntoRuns, type Schedule } from './roadtripModel'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment } from '../../types'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
|
||||
export interface RoadtripStop {
|
||||
@@ -19,6 +20,8 @@ export interface RoadtripStop {
|
||||
/** Mode of the leg LEAVING this stop; null inherits the day default. */
|
||||
legMode: string | null
|
||||
incomingLegMode: string | null
|
||||
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
|
||||
stopType: string | null
|
||||
}
|
||||
|
||||
export interface RoadtripDay {
|
||||
@@ -42,8 +45,27 @@ export interface RoadtripDay {
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
|
||||
export interface QuietDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** Usually none or one — two would have made it a drive. */
|
||||
stops: RoadtripStop[]
|
||||
}
|
||||
|
||||
export interface RoadtripRoutes {
|
||||
days: RoadtripDay[]
|
||||
/**
|
||||
* Days with fewer than two stops, in trip order.
|
||||
*
|
||||
* They carry no drive, so they are not part of `days` and nothing is routed for them —
|
||||
* but a stop has to be able to move onto one, and a day that is not on screen cannot be
|
||||
* dropped on. Listed separately rather than folded into `days` so the totals and the
|
||||
* routing keep meaning exactly what they meant before.
|
||||
*/
|
||||
quietDays: QuietDay[]
|
||||
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
|
||||
lines: [number, number][][]
|
||||
/** The routed legs themselves, so the map can label each connector. */
|
||||
@@ -84,6 +106,24 @@ interface RoutedLeg {
|
||||
line: [number, number][]
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
|
||||
* these, and so are the keys the routed legs are filed under, so the two can never drift
|
||||
* apart.
|
||||
*
|
||||
* Deliberately not the assignment id. A stop added mid-day is written optimistically with
|
||||
* a temporary negative id (`assignmentsSlice`) and swapped for the real one once the
|
||||
* server answers, without its coordinates changing — so `planKey` stays identical, the
|
||||
* effect does not run again, and a leg filed under the id would sit under a dead one
|
||||
* forever. A missing leg breaks the schedule's chain (`computeSchedule` gives up its
|
||||
* cursor), which would silently blank every arrival time after the new stop.
|
||||
*/
|
||||
const stopKey = (s: RoadtripStop): string =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
|
||||
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
@@ -97,6 +137,7 @@ const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
||||
stopType: p.stop_type ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +159,17 @@ export function useRoadtripRoutes(
|
||||
assignments: AssignmentsMap,
|
||||
/** Mode for legs that neither the stop nor the day pins down. */
|
||||
fallbackProfile: string = 'driving',
|
||||
/**
|
||||
* Points the drive is made to pass through, per day (#1797). They join the routing
|
||||
* request between the stops they follow, so the router draws the road the traveller
|
||||
* chose rather than the one it prefers.
|
||||
*/
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
): RoadtripRoutes {
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [legsByDay, setLegsByDay] = useState<Record<number, Record<number, RoutedLeg>>>({})
|
||||
const [legsByDay, setLegsByDay] = useState<Record<number, Record<string, RoutedLeg>>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
@@ -151,11 +198,36 @@ export function useRoadtripRoutes(
|
||||
.filter(d => d.stops.length > 1)
|
||||
}, [days, assignments])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => {
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(d => ({
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
date: d.date ?? null,
|
||||
title: d.title ?? null,
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(asStop)
|
||||
.filter((s): s is RoadtripStop => s !== null),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
const viaKey = useMemo(
|
||||
() => Object.entries(viasByDay)
|
||||
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
|
||||
.sort()
|
||||
.join(';'),
|
||||
[viasByDay],
|
||||
)
|
||||
|
||||
const planKey = useMemo(
|
||||
() => plan.map(d => `${d.dayId}:${d.stops.map(s => `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`).join('|')}`).join(';'),
|
||||
[plan],
|
||||
() => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
|
||||
[plan, viaKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,11 +244,11 @@ export function useRoadtripRoutes(
|
||||
const dayDefault = (dayId: number): string =>
|
||||
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
|
||||
|
||||
const collected: Record<number, Record<number, RoutedLeg>> = {}
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const day of plan) {
|
||||
const dayLegs: Record<number, RoutedLeg> = {}
|
||||
const dayLegs: Record<string, RoutedLeg> = {}
|
||||
collected[day.dayId] = dayLegs
|
||||
const dfMode = dayDefault(day.dayId)
|
||||
|
||||
@@ -187,29 +259,59 @@ export function useRoadtripRoutes(
|
||||
dfMode,
|
||||
))
|
||||
|
||||
const dayVias = viasByDay[day.dayId] ?? []
|
||||
|
||||
for (const { stops: run, mode } of runs) {
|
||||
tasks.push(async () => {
|
||||
// Waypoints are the stops with this day's vias threaded in between them, so the
|
||||
// router draws the road the traveller picked. `stopAt` remembers which waypoint
|
||||
// each stop became, because the answer has a leg per waypoint PAIR and the rail
|
||||
// wants one leg per stop pair.
|
||||
const waypoints: { lat: number; lng: number }[] = []
|
||||
const stopAt: number[] = []
|
||||
run.forEach((stop, i) => {
|
||||
stopAt.push(waypoints.length)
|
||||
waypoints.push({ lat: stop.lat, lng: stop.lng })
|
||||
if (i === run.length - 1) return
|
||||
const dayIndex = day.stops.indexOf(stop)
|
||||
dayVias
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
||||
})
|
||||
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
run.map(s => ({ lat: s.lat, lng: s.lng })),
|
||||
waypoints,
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
)
|
||||
// legs[i] is the drive from run[i] to run[i+1]; the geometry comes back for
|
||||
// the run as a whole, which is what the map draws.
|
||||
r.legs.forEach((leg, i) => {
|
||||
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
|
||||
// with a via between it comes back as two legs, and the rail shows one.
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
if (from) dayLegs[from.assignmentId] = { seg: { ...leg, mode }, line: i === 0 ? r.coordinates : [] }
|
||||
})
|
||||
const to = run[i + 1]
|
||||
if (!from || !to) continue
|
||||
const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
|
||||
if (!parts.length) continue
|
||||
const merged = parts.length === 1 ? parts[0] : {
|
||||
...parts[0],
|
||||
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
||||
}
|
||||
dayLegs[legKey(from, to)] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [] }
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Almost always a rate limit on the shared routing host. Back off and try
|
||||
// again; a run that still won't route (island hop, dead router) simply stays
|
||||
// blank, and the totals say so by being partial rather than wrong.
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay === undefined) return
|
||||
await sleep(delay, controller.signal)
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
const asked = err instanceof RoutingRefusedError && err.isRateLimit ? err.retryAfterMs : null
|
||||
await sleep(Math.max(delay, asked ?? 0), controller.signal)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -246,7 +348,7 @@ export function useRoadtripRoutes(
|
||||
const segments: RouteSegment[] = []
|
||||
const out = plan.map(day => {
|
||||
const dayLegs = legsByDay[day.dayId] ?? {}
|
||||
const routed = day.stops.slice(0, -1).map(s => dayLegs[s.assignmentId])
|
||||
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
|
||||
for (const leg of routed) {
|
||||
if (!leg) continue
|
||||
if (leg.line.length > 1) lines.push(leg.line)
|
||||
@@ -270,8 +372,11 @@ export function useRoadtripRoutes(
|
||||
segments,
|
||||
totalDistance: out.reduce((s, d) => s + d.distance, 0),
|
||||
totalDuration: out.reduce((s, d) => s + d.duration, 0),
|
||||
totalStops: out.reduce((s, d) => s + d.stops.length, 0),
|
||||
// Service stops are not stops in this sense — a charger on the way is part of the
|
||||
// drive, and counting it here would make the head disagree with the day cards.
|
||||
totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
quietDays,
|
||||
loading,
|
||||
}
|
||||
}, [plan, legsByDay, loading])
|
||||
}, [plan, legsByDay, loading, quietDays])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { roadtripApi } = vi.hoisted(() => ({
|
||||
roadtripApi: {
|
||||
listVias: vi.fn(),
|
||||
addVia: vi.fn(),
|
||||
moveVia: vi.fn(),
|
||||
removeVia: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../api/client', () => ({ roadtripApi }))
|
||||
|
||||
import { useRoadtripVias } from './useRoadtripVias'
|
||||
|
||||
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
|
||||
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
|
||||
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
|
||||
roadtripApi.removeVia.mockReset().mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('useRoadtripVias', () => {
|
||||
it('FE-ROADTRIP-VIAS-001: asks once for the whole trip, not once per day', async () => {
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
await waitFor(() => expect(Object.keys(result.current.byDay)).toHaveLength(2))
|
||||
expect(roadtripApi.listVias).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.byDay[1].map(v => v.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-002: with road trip mode off nothing is fetched', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, false))
|
||||
|
||||
await waitFor(() => expect(result.current.byDay).toEqual({}))
|
||||
expect(roadtripApi.listVias).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-003: an addon that is switched off means no vias, not an error', async () => {
|
||||
roadtripApi.listVias.mockRejectedValue(new Error('404'))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
|
||||
expect(result.current.byDay).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-004: adding one re-reads the list rather than guessing its id', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledTimes(1))
|
||||
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 5, day_id: 3 })] })
|
||||
await act(async () => { await result.current.add(3, 1, 52, 11) })
|
||||
|
||||
expect(roadtripApi.addVia).toHaveBeenCalledWith(7, 3, { after_order_index: 1, lat: 52, lng: 11 })
|
||||
// The server assigns id and sequence; patching the list by hand would be a second
|
||||
// source of truth for the sake of one round trip.
|
||||
await waitFor(() => expect(result.current.byDay[3]?.[0].id).toBe(5))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-005: moving and removing go to the day they belong to', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
|
||||
|
||||
await act(async () => { await result.current.move(2, 9, 51, 12) })
|
||||
expect(roadtripApi.moveVia).toHaveBeenCalledWith(7, 2, 9, { lat: 51, lng: 12 })
|
||||
|
||||
await act(async () => { await result.current.remove(2, 9) })
|
||||
expect(roadtripApi.removeVia).toHaveBeenCalledWith(7, 2, 9)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-006: without a trip there is nothing to write to', async () => {
|
||||
const { result } = renderHook(() => useRoadtripVias(null, true))
|
||||
|
||||
await act(async () => { await result.current.add(1, 0, 53, 10) })
|
||||
|
||||
expect(roadtripApi.addVia).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { roadtripApi } from '../../api/client'
|
||||
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
export interface RoadtripVias {
|
||||
/** Every via of the trip, keyed by day. */
|
||||
byDay: Record<number, RoadtripVia[]>
|
||||
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise<void>
|
||||
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
|
||||
remove: (dayId: number, id: number) => Promise<void>
|
||||
/**
|
||||
* Correct a day's anchors after its stops changed shape.
|
||||
*
|
||||
* Awaited by the caller before it lets the day re-route: the routing effect resolves
|
||||
* `after_order_index` against whatever the stop list looks like at that moment, so a
|
||||
* re-anchoring that lands afterwards is a second, visibly wrong route in between.
|
||||
*/
|
||||
reanchor: (dayId: number, plan: Reanchoring) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY: Record<number, RoadtripVia[]> = {}
|
||||
|
||||
/**
|
||||
* The points this trip's drives are routed through.
|
||||
*
|
||||
* Loaded once for the whole trip rather than per day: a road trip routes every day at
|
||||
* once, and one request for the lot beats one per day against a server that has to open
|
||||
* the same table each time.
|
||||
*
|
||||
* Writes are optimistic in the sense that the list is refreshed from the answer, not
|
||||
* patched by hand — a via has a server-assigned id and sequence, and guessing them would
|
||||
* be a second source of truth for the sake of one round trip.
|
||||
*/
|
||||
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
|
||||
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
|
||||
|
||||
const group = useCallback((vias: RoadtripVia[]) => {
|
||||
const next: Record<number, RoadtripVia[]> = {}
|
||||
for (const v of vias) (next[v.day_id] ??= []).push(v)
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!tripId || !active) { setByDay(EMPTY); return }
|
||||
try {
|
||||
const { vias } = await roadtripApi.listVias(tripId)
|
||||
setByDay(group(vias))
|
||||
} catch {
|
||||
// An instance with the addon off answers 404 here, which is not an error worth
|
||||
// reporting — it just means there are no vias to draw.
|
||||
setByDay(EMPTY)
|
||||
}
|
||||
}, [tripId, active, group])
|
||||
|
||||
useEffect(() => { void reload() }, [reload])
|
||||
|
||||
const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const reanchor = useCallback(async (dayId: number, plan: Reanchoring) => {
|
||||
if (!tripId || isEmptyReanchoring(plan)) return
|
||||
await roadtripApi.reanchorVias(tripId, dayId, plan)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
const remove = useCallback(async (dayId: number, id: number) => {
|
||||
if (!tripId) return
|
||||
await roadtripApi.removeVia(tripId, dayId, id)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
return { byDay, add, move, remove, reanchor }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/** One offered way of driving a leg, with what it means for this trip. */
|
||||
export interface OfferedRoute extends RouteAlternative {
|
||||
/** True for the road currently driven — the one the vias on this leg produce. */
|
||||
current?: boolean
|
||||
/** True for the router's own preference with no vias at all. */
|
||||
direct?: boolean
|
||||
}
|
||||
|
||||
/** Which leg is being reconsidered, and what was offered for it. */
|
||||
export interface LegAlternatives {
|
||||
dayId: number
|
||||
/** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
|
||||
index: number
|
||||
routes: OfferedRoute[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export interface RouteAlternativesState {
|
||||
open: LegAlternatives | null
|
||||
/** Asks the router for the ways of driving this one leg. */
|
||||
ask: (dayId: number, index: number, from: RoadtripStop, to: RoadtripStop, profile: string, vias: RoadtripVia[]) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** Two lines are the same road if their ends and their length agree closely enough. */
|
||||
function sameRoute(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 50 && Math.abs(a.duration - b.duration) < 30
|
||||
}
|
||||
|
||||
/**
|
||||
* Other ways of driving one leg.
|
||||
*
|
||||
* One leg at a time, on demand, and never for the whole trip: OSRM answers with
|
||||
* alternatives only between exactly two coordinates, so asking for a day would mean
|
||||
* breaking up the single request `splitIntoRuns` bundles it into — the optimisation that
|
||||
* makes a road trip appear at once rather than trickle in.
|
||||
*
|
||||
* When the leg already carries vias, the road actually being driven is NOT among what the
|
||||
* router offers for the two bare endpoints — it was asked a different question. So it is
|
||||
* fetched separately and put at the top, marked as the current one. Without that, opening
|
||||
* the picker on a leg you have already reshaped shows you three roads, none of which is
|
||||
* the one you are on, and no way back to it.
|
||||
*/
|
||||
export function useRouteAlternatives(): RouteAlternativesState {
|
||||
const [open, setOpen] = useState<LegAlternatives | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const close = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setOpen(null)
|
||||
}, [])
|
||||
|
||||
const ask = useCallback((
|
||||
dayId: number,
|
||||
index: number,
|
||||
from: RoadtripStop,
|
||||
to: RoadtripStop,
|
||||
profile: string,
|
||||
vias: RoadtripVia[],
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setOpen({ dayId, index, routes: [], loading: true, error: false })
|
||||
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const ends = [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }]
|
||||
|
||||
// The router's offers for the bare leg, plus — only when the leg is already bent —
|
||||
// the road that bend produces, so both are on the table together.
|
||||
const offers = calculateAlternatives(ends[0], ends[1], osrmProfile, { signal: controller.signal })
|
||||
const current = vias.length
|
||||
? calculateRoute(
|
||||
[ends[0], ...vias.map(v => ({ lat: v.lat, lng: v.lng })), ends[1]],
|
||||
osrmProfile,
|
||||
{ signal: controller.signal },
|
||||
).then(r => ({
|
||||
coordinates: r.coordinates,
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
current: true,
|
||||
} as OfferedRoute)).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
void Promise.all([offers, current])
|
||||
.then(([alternatives, driven]) => {
|
||||
if (controller.signal.aborted) return
|
||||
const routes: OfferedRoute[] = alternatives.map((r, i) => ({ ...r, direct: i === 0 }))
|
||||
// Only prepend the driven road when it really is a different one; with a via that
|
||||
// barely moves the route, the two are the same road and one entry is honest.
|
||||
if (driven && !routes.some(r => sameRoute(r, driven))) routes.unshift(driven)
|
||||
setOpen({ dayId, index, routes, loading: false, error: false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
// A router that will not answer is not worth a dialog; the leg keeps the route it
|
||||
// already has and the panel says so.
|
||||
setOpen({ dayId, index, routes: [], loading: false, error: true })
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { open, ask, close }
|
||||
}
|
||||
@@ -169,6 +169,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [provider, setProvider] = useState<Provider>(initialProvider)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [routingBaseUrl, setRoutingBaseUrl] = useState<string>(settings.routing_base_url || '')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
|
||||
@@ -182,6 +183,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const nextProvider = normalizeProvider(settings.map_provider)
|
||||
setProvider(nextProvider)
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setRoutingBaseUrl(settings.routing_base_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setCartoKey(settings.carto_api_key || '')
|
||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||
@@ -217,6 +219,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
await updateSettings({
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
routing_base_url: routingBaseUrl.trim(),
|
||||
mapbox_access_token: mapboxToken,
|
||||
carto_api_key: cartoKey,
|
||||
...stylePatch,
|
||||
@@ -333,6 +336,23 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routing is the same engine whichever renderer draws the map, so this sits outside
|
||||
the provider blocks. Tokens rather than the slate-* around it: theme:lint holds
|
||||
new markup to them, and a field added today should not need converting later. */}
|
||||
{!managed && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-body font-medium text-content">{t('settings.routingBase')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBaseUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBaseUrl(e.target.value)}
|
||||
placeholder="https://osrm.example.org"
|
||||
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content placeholder:text-content-faint focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
|
||||
{provider === 'leaflet' && !managed && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user