mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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:
@@ -61,6 +61,10 @@ import {
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripVia,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -1212,6 +1216,29 @@ export const mapsApi = {
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,6 +42,13 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.journey-feed-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Road trip rail: hide the scrollbar of the day list. The totals card is pinned
|
||||
above the list and therefore outside its scroll box, so a visible scrollbar
|
||||
would take width from the day cards alone and leave the two stacks a few
|
||||
pixels out of line — the one thing a column of nested cards cannot hide. */
|
||||
.roadtrip-rail-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.roadtrip-rail-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Journey video: let the player fill the box the lightbox gives it.
|
||||
*
|
||||
* Plyr sizes its wrapper from the video's intrinsic aspect ratio via a
|
||||
|
||||
@@ -69,6 +69,9 @@ const ExpenseModal = lazyWithRetry(() =>
|
||||
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
|
||||
const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
|
||||
const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel'))
|
||||
const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup'))
|
||||
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
|
||||
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
|
||||
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
|
||||
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
|
||||
// — about 200 kB of packed zone geometry that every trip used to load.
|
||||
@@ -256,6 +259,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||
stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
|
||||
@@ -272,7 +282,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
@@ -407,12 +417,47 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={roadtripActive ? roadtripCorridor.search.results : poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
pois={roadtripActive
|
||||
/* `visible`, not `search.results`: the map is the picture of this very
|
||||
list, and filtering the list while seventy pins stay on the map no
|
||||
longer answers "which of these". */
|
||||
? roadtripCorridor.visible
|
||||
: poi.pois}
|
||||
onPoiClick={handlePoiClick}
|
||||
// Only while road trip mode is on: outside it there is no drive to drop onto.
|
||||
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
|
||||
// Clicking the drawn route puts a via there; only in road trip mode, where
|
||||
// the route is the thing being worked on.
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={alternativeFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
onHighlightAlternative={setHighlightedAlternative}
|
||||
onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
|
||||
onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{/* Over the map rather than in a dialog: the answer to "which of these" is the
|
||||
roads drawn behind it, so covering them to ask would hide the point. */}
|
||||
{routeAlternatives.open && (
|
||||
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
|
||||
<LazyPanel id="roadtrip-alternatives">
|
||||
<RoadtripAlternativesBar
|
||||
open={routeAlternatives.open}
|
||||
overlays={alternativeOverlays}
|
||||
onChoose={chooseRouteAlternative}
|
||||
onClose={routeAlternatives.close}
|
||||
onHighlight={setHighlightedAlternative}
|
||||
/>
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
@@ -483,6 +528,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
routes={roadtripRoutes}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -590,7 +640,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
{roadtripActive ? (
|
||||
<LazyPanel id="roadtrip-corridor">
|
||||
<RoadtripCorridorPanel corridor={roadtripCorridor} routes={roadtripRoutes} onAddPoi={openAddPlaceFromPoi} />
|
||||
{/* No Add button for someone who may not add: openAddPlaceFromPoi
|
||||
returns silently without the permission, which reads as a broken
|
||||
button rather than a missing one. */}
|
||||
<RoadtripCorridorPanel
|
||||
corridor={roadtripCorridor}
|
||||
routes={roadtripRoutes}
|
||||
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
@@ -872,6 +929,26 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The small way in for something found along the drive. Mounted only while a
|
||||
draft exists, so the chunk stays unloaded for anyone not using road trip mode. */}
|
||||
{/* How long a stop takes. Its own gate, not the corridor draft's: a stay is set on
|
||||
any stop of the trip, not only on something just found along the route. */}
|
||||
{stayDraft && (
|
||||
<LazyPanel id="roadtrip-stay">
|
||||
<RoadtripStayModal stop={stayDraft} onClose={() => setStayDraft(null)} onSave={setRoadtripStay} />
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stopDraft && (
|
||||
<LazyPanel id="roadtrip-stop">
|
||||
<RoadtripStopPopup
|
||||
draft={stopDraft}
|
||||
duplicateName={stopDraftDuplicate}
|
||||
onClose={() => setStopDraft(null)}
|
||||
onSave={saveStopDraft}
|
||||
onMoreDetails={stopDraftToForm}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
|
||||
<TripFormModal
|
||||
isOpen={showTripForm}
|
||||
|
||||
@@ -2036,3 +2036,99 @@ describe('useTripPlanner — misc state', () => {
|
||||
expect(result.current.tripMembers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner — road trip stops', () => {
|
||||
/** A day whose stops the rail would show, plus one the rail hides for want of coordinates. */
|
||||
const seedDrive = () => {
|
||||
const places = [
|
||||
buildPlace({ id: 10, name: 'Hamburg', lat: 53.55, lng: 9.99 }),
|
||||
buildPlace({ id: 20, name: 'Lueneburg', lat: 53.25, lng: 10.41 }),
|
||||
buildPlace({ id: 30, name: 'Berlin', lat: 52.52, lng: 13.40 }),
|
||||
// No coordinates, so it never appears in the rail — and must survive a reorder.
|
||||
buildPlace({ id: 40, name: 'Idee ohne Ort', lat: null, lng: null }),
|
||||
]
|
||||
return seedTrip({
|
||||
days: [buildDay({ id: 7, day_number: 1 })],
|
||||
places,
|
||||
assignments: {
|
||||
'7': [
|
||||
buildAssignment({ id: 1, day_id: 7, place_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 7, place_id: 20, order_index: 1, place: places[1] }),
|
||||
buildAssignment({ id: 3, day_id: 7, place_id: 30, order_index: 2, place: places[2] }),
|
||||
buildAssignment({ id: 4, day_id: 7, place_id: 40, order_index: 3, place: places[3] }),
|
||||
],
|
||||
},
|
||||
} as unknown as Partial<TripStoreState>)
|
||||
}
|
||||
|
||||
it('FE-TP-HOOK-111: reordering the rail sends the day’s WHOLE order, hidden stops included', async () => {
|
||||
seedDrive()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
// The rail lists three stops; the fourth has no coordinates and is not on show.
|
||||
await act(async () => { await result.current.reorderRoadtripStop(7, 3, 0) })
|
||||
|
||||
// Both the slice and the WebSocket handler rebuild the day purely from these ids, so
|
||||
// leaving the invisible one out would delete it from every session's store.
|
||||
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 7, [3, 1, 2, 4])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-112: moving a stop onto itself asks the server for nothing', async () => {
|
||||
seedDrive()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
await act(async () => { await result.current.reorderRoadtripStop(7, 2, 1) })
|
||||
|
||||
expect(actions.reorderAssignments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-113: an index past the end of the chain lands on the last stop', async () => {
|
||||
seedDrive()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
await act(async () => { await result.current.reorderRoadtripStop(7, 1, 99) })
|
||||
|
||||
// Clamped to the last VISIBLE stop, which is Berlin — not past the hidden fourth row.
|
||||
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 7, [2, 3, 1, 4])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-114: a failed reorder says so rather than leaving a phantom order', async () => {
|
||||
seedDrive()
|
||||
actions.reorderAssignments.mockRejectedValueOnce(new Error('nope'))
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
await act(async () => { await result.current.reorderRoadtripStop(7, 3, 0) })
|
||||
|
||||
expect(toasts.some(t => t.type === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-115: an unknown assignment is not reordered into existence', async () => {
|
||||
seedDrive()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
await act(async () => { await result.current.reorderRoadtripStop(7, 999, 0) })
|
||||
|
||||
expect(actions.reorderAssignments).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner — dropping a hit on the drive', () => {
|
||||
it('FE-TP-HOOK-116: a drop far from the route is ignored rather than guessed at', async () => {
|
||||
seedTrip()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
// No corridor results and no day: nothing to match, so nothing may open.
|
||||
act(() => { result.current.dropPoiOnRoute('node:9', 0, 0) })
|
||||
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-117: a drop for an unknown hit opens nothing', async () => {
|
||||
seedTrip()
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
act(() => { result.current.dropPoiOnRoute('node:does-not-exist', 53.5, 9.9) })
|
||||
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,19 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
||||
import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor'
|
||||
import {
|
||||
insertIndexForAlong,
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorAfterReorder,
|
||||
} from '../../components/Roadtrip/roadtripModel'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
@@ -227,6 +240,19 @@ export function useTripPlanner() {
|
||||
// mobile day toolbar, a long-press on the mobile map); every other opener
|
||||
// clears it, so a place added from the pool still lands in the pool (#1998).
|
||||
const [placeFormDayId, setPlaceFormDayId] = useState<number | null>(null)
|
||||
/**
|
||||
* Where in the day the place being added belongs, when the caller knows.
|
||||
* Null means the old behaviour: the server appends it at the end.
|
||||
*/
|
||||
const [placeFormPosition, setPlaceFormPosition] = useState<number | null>(null)
|
||||
/**
|
||||
* The corridor hit waiting to become a stop, while the small popup is open.
|
||||
*
|
||||
* The full place form is the wrong question for a petrol station — category, price,
|
||||
* photo, notes and files are all empty for one — so in road trip mode a hit opens this
|
||||
* instead, and the form stays one click away behind "more details".
|
||||
*/
|
||||
const [stopDraft, setStopDraft] = useState<RoadtripStopDraft | null>(null)
|
||||
const [reservationModalDayId, setReservationModalDayId] = useState<number | null>(null)
|
||||
|
||||
// The bottom-nav "+" opens the new-place form via ?create=place.
|
||||
@@ -508,7 +534,14 @@ export function useTripPlanner() {
|
||||
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
|
||||
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
||||
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
|
||||
const roadtripRoutes = useRoadtripRoutes(tripId, roadtripActive ? days : EMPTY_DAYS, assignments, routeProfile)
|
||||
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
|
||||
const roadtripRoutes = useRoadtripRoutes(
|
||||
tripId,
|
||||
roadtripActive ? days : EMPTY_DAYS,
|
||||
assignments,
|
||||
routeProfile,
|
||||
roadtripVias.byDay,
|
||||
)
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
|
||||
|
||||
@@ -587,7 +620,12 @@ export function useTripPlanner() {
|
||||
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }, dayId?: number | null) => {
|
||||
const openAddPlaceFromPoi = useCallback((
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
|
||||
dayId?: number | null,
|
||||
/** Index within that day. Omitted, the place is appended, which is what every caller did before. */
|
||||
position?: number | null,
|
||||
) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
setPrefillCoords({
|
||||
lat: poi.lat,
|
||||
@@ -601,9 +639,449 @@ export function useTripPlanner() {
|
||||
setEditingPlace(null)
|
||||
setEditingAssignmentId(null)
|
||||
setPlaceFormDayId(dayId ?? null)
|
||||
setPlaceFormPosition(position ?? null)
|
||||
setShowPlaceForm(true)
|
||||
}, [trip])
|
||||
|
||||
/**
|
||||
* Adding a POI straight off the map, with the day it belongs to.
|
||||
*
|
||||
* In road trip mode that is the day being searched: without it the place lands in the
|
||||
* unplanned pool, and neither column shows that pool while road trip mode is on, so a
|
||||
* just-added stop disappears without a trace. Outside road trip mode nothing changes —
|
||||
* `undefined` keeps the old "let the user pick" behaviour.
|
||||
*
|
||||
* Memoised because both map renderers rebuild every POI marker whenever this callback's
|
||||
* identity changes.
|
||||
*/
|
||||
const roadtripDayId = roadtripCorridor.day?.dayId ?? null
|
||||
const { insertIndexFor: roadtripInsertIndexFor } = roadtripCorridor
|
||||
const roadtripDayNumber = roadtripCorridor.day?.dayNumber ?? 0
|
||||
const handlePoiClick = useCallback((poi: Parameters<typeof openAddPlaceFromPoi>[0]) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
// A corridor hit knows how far along the drive it sits, so it can go straight into
|
||||
// the chain in driving order instead of being dragged there afterwards.
|
||||
const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
|
||||
if (hit && roadtripDayId != null) {
|
||||
setStopDraft({ poi: hit, dayId: roadtripDayId, position: roadtripInsertIndexFor(hit), dayNumber: roadtripDayNumber })
|
||||
return
|
||||
}
|
||||
openAddPlaceFromPoi(poi, roadtripActive ? roadtripDayId : undefined)
|
||||
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, can, trip])
|
||||
|
||||
/**
|
||||
* The stops of a day as the road trip counts them, in the order it drives them.
|
||||
*
|
||||
* This is the index space `after_order_index` lives in: sorted by `order_index`, and
|
||||
* filtered to the rows that have coordinates, because a place the map cannot put
|
||||
* anywhere is not a point the router is given. Built from `assignments` rather than
|
||||
* from `roadtripRoutes` so it also answers for a day with one stop or none — exactly
|
||||
* the day a stop gets pushed onto when a leg turns out too long.
|
||||
*/
|
||||
const roadtripStopsOf = useCallback((dayId: number) =>
|
||||
(assignments[String(dayId)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'),
|
||||
[assignments])
|
||||
|
||||
/**
|
||||
* Which half of a split leg a via belongs to, measured on the road actually driven.
|
||||
*
|
||||
* Both the via and the new stop are projected onto the day's current routed line, so
|
||||
* the comparison is "which one does the car reach first" rather than a straight-line
|
||||
* guess. The line already includes the detour the via causes, so the via sits exactly
|
||||
* on it and its position along the drive is exact.
|
||||
*
|
||||
* Falls back to keeping the via on the first half. A projection only fails when the
|
||||
* point is nowhere near the drive, and in that case leaving the anchor where it was is
|
||||
* the answer that changes least.
|
||||
*/
|
||||
const viaLiesBefore = useCallback((dayId: number, at: { lat: number; lng: number }) => {
|
||||
const line: LatLng[] = (roadtripRoutes.days.find(d => d.dayId === dayId)?.geometry ?? [])
|
||||
.map(([lat, lng]) => ({ lat, lng }))
|
||||
const insertAt = line.length ? projectOntoRoute(at, line)?.alongKm ?? null : null
|
||||
return (via: { lat: number; lng: number }) => {
|
||||
if (insertAt === null) return true
|
||||
const viaAt = projectOntoRoute({ lat: via.lat, lng: via.lng }, line)?.alongKm
|
||||
return viaAt === undefined || viaAt === null ? true : viaAt < insertAt
|
||||
}
|
||||
}, [roadtripRoutes.days])
|
||||
|
||||
/**
|
||||
* Saves a corridor hit as a stop: the place itself, then its position in the day.
|
||||
*
|
||||
* `stop_type` is what makes it a fuel stop rather than a place that happens to sell
|
||||
* fuel — the road-trip kinds are their own dimension, deliberately not one of the
|
||||
* traveller's editable categories, so the palette and the meaning stay put.
|
||||
*/
|
||||
const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
try {
|
||||
const place = await tripActions.addPlace(tripId, {
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
address: poi.address || null,
|
||||
website: poi.website || undefined,
|
||||
phone: poi.phone || undefined,
|
||||
osm_id: poi.osm_id,
|
||||
duration_minutes: dwellMinutes,
|
||||
stop_type: stopType,
|
||||
})
|
||||
if (place?.id) {
|
||||
// Worked out BEFORE the stop lands, against the day as it stands and the road as
|
||||
// it is currently driven — once the list has shifted there is no record of which
|
||||
// leg each via was drawn for.
|
||||
const plan = reanchorAfterInsert(
|
||||
roadtripVias.byDay[dayId] ?? [],
|
||||
position,
|
||||
viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
|
||||
)
|
||||
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
|
||||
// Awaited before the day re-routes: the routing effect reads the anchors against
|
||||
// the new stop list, so a correction landing after it would draw the wrong road
|
||||
// first and the right one a moment later.
|
||||
await roadtripVias.reanchor(dayId, plan)
|
||||
updateRouteForDay(dayId)
|
||||
}
|
||||
setStopDraft(null)
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
|
||||
|
||||
/**
|
||||
* Moves a stop within its day, from the road trip rail.
|
||||
*
|
||||
* The rail reports only "this assignment, from here to there" and the full order is
|
||||
* rebuilt here, from the day's COMPLETE assignment list rather than from what the rail
|
||||
* shows. That matters: the rail hides stops without coordinates, and both
|
||||
* `reorderAssignments` and the WebSocket handler rebuild the day's array purely from the
|
||||
* ids they are given — anything left out would vanish from the store, for every session
|
||||
* watching the trip.
|
||||
*
|
||||
* Within one day only. Moving between days stays in the day plan, where empty and
|
||||
* one-stop days are visible and can be dropped onto; the rail leaves them out, so a day
|
||||
* would disappear from under the cursor mid-gesture.
|
||||
*
|
||||
* No confirmation prompt for a stop with a pinned time, unlike the day plan: the rail
|
||||
* recomputes the cascade immediately and marks a stop it can no longer reach in time.
|
||||
* Showing the consequence is better than asking about it in advance.
|
||||
*/
|
||||
const reorderRoadtripStop = useCallback(async (dayId: number, assignmentId: number, toIndex: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
const all = assignments[String(dayId)] ?? []
|
||||
const ordered = [...all].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const fromIdx = ordered.findIndex(a => a.id === assignmentId)
|
||||
if (fromIdx === -1) return
|
||||
|
||||
// `toIndex` counts stops as the rail lists them; map it onto the full list, which may
|
||||
// hold rows the rail never showed.
|
||||
const visible = ordered.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
||||
const target = visible[Math.min(Math.max(toIndex, 0), visible.length - 1)]
|
||||
if (!target || target.id === assignmentId) return
|
||||
const toIdx = ordered.findIndex(a => a.id === target.id)
|
||||
if (toIdx === -1 || toIdx === fromIdx) return
|
||||
|
||||
const next = [...ordered]
|
||||
const [moved] = next.splice(fromIdx, 1)
|
||||
next.splice(toIdx, 0, moved)
|
||||
|
||||
// In the rail's own index space, which is the one the anchors are counted in. A stop
|
||||
// without coordinates never entered that space, so moving it changes nothing there.
|
||||
const fromVis = visible.findIndex(a => a.id === assignmentId)
|
||||
const plan = fromVis === -1
|
||||
? null
|
||||
: reanchorAfterReorder(roadtripVias.byDay[dayId] ?? [], fromVis, toIndex, visible.length)
|
||||
|
||||
try {
|
||||
await tripActions.reorderAssignments(tripId, dayId, next.map(a => a.id))
|
||||
if (plan) await roadtripVias.reanchor(dayId, plan)
|
||||
updateRouteForDay(dayId)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias])
|
||||
|
||||
/**
|
||||
* The stop whose length is being set, or null while the dialog is closed.
|
||||
*
|
||||
* Held here rather than in the rail because the write goes through `tripActions`, and
|
||||
* the rail is a list — putting a dialog's state inside a row means it dies whenever the
|
||||
* list re-renders around it.
|
||||
*/
|
||||
const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
|
||||
|
||||
/**
|
||||
* How long the traveller stays at one stop.
|
||||
*
|
||||
* Writes `places.duration_minutes`, which the schedule has read since it was written
|
||||
* and which nothing in TREK has ever been able to set — the road trip is the only place
|
||||
* the value means anything, so it is the only place that edits it.
|
||||
*
|
||||
* Zero rather than null to clear: the update statement folds a null into "leave it
|
||||
* alone" (`COALESCE(?, duration_minutes)`), so a null could give a stop a stay but
|
||||
* never take one away. The rail reads zero and absent as the same thing.
|
||||
*/
|
||||
const setRoadtripStay = useCallback(async (placeId: number, minutes: number) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
try {
|
||||
await tripActions.updatePlace(tripId, placeId, { duration_minutes: minutes })
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [tripId, tripActions, toast, t, can, trip])
|
||||
|
||||
/**
|
||||
* Moves a stop onto another day, from the road trip rail.
|
||||
*
|
||||
* Split from `reorderRoadtripStop` because it is a different call with a different
|
||||
* failure mode: `moveAssignment` writes to two days, and the rail has to be able to
|
||||
* reach days it draws no drive for — a day with one stop or none is exactly what a
|
||||
* stop gets moved onto when a leg turns out to be too long for one day.
|
||||
*
|
||||
* The target index counts the stops the rail shows on that day; an empty day takes
|
||||
* position 0.
|
||||
*/
|
||||
const moveRoadtripStopToDay = useCallback(async (
|
||||
fromDayId: number,
|
||||
assignmentId: number,
|
||||
toDayId: number,
|
||||
toIndex: number,
|
||||
) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
if (fromDayId === toDayId) return
|
||||
const target = (assignments[String(toDayId)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const visible = target.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
||||
// Map the rail's own count onto the day's full list, which may hold rows it hides.
|
||||
const anchor = visible[Math.min(Math.max(toIndex, 0), Math.max(visible.length - 1, 0))]
|
||||
const at = anchor ? target.findIndex(a => a.id === anchor.id) : target.length
|
||||
|
||||
// Both days shift at once, and each needs its own correction: the stop leaves a gap
|
||||
// on one side and opens one on the other. No geometry is measured for the arriving
|
||||
// day — its roads are about to be different anyway, so there is nothing stable to
|
||||
// measure a via against.
|
||||
const fromStops = roadtripStopsOf(fromDayId)
|
||||
const fromVis = fromStops.findIndex(a => a.id === assignmentId)
|
||||
const fromPlan = fromVis === -1
|
||||
? null
|
||||
: reanchorAfterRemove(roadtripVias.byDay[fromDayId] ?? [], fromVis, fromStops.length)
|
||||
const toVis = Math.min(Math.max(toIndex, 0), visible.length)
|
||||
const toPlan = fromVis === -1
|
||||
? null
|
||||
: reanchorAfterInsert(roadtripVias.byDay[toDayId] ?? [], toVis, () => true)
|
||||
|
||||
try {
|
||||
await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, at < 0 ? target.length : at)
|
||||
if (fromPlan) await roadtripVias.reanchor(fromDayId, fromPlan)
|
||||
if (toPlan) await roadtripVias.reanchor(toDayId, toPlan)
|
||||
updateRouteForDay(fromDayId)
|
||||
updateRouteForDay(toDayId)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias, roadtripStopsOf])
|
||||
|
||||
const routeAlternatives = useRouteAlternatives()
|
||||
/**
|
||||
* Which offered route the pointer is on, so the map can light that one up.
|
||||
*
|
||||
* Lives here rather than in the bar because the map draws it and the bar reports it —
|
||||
* neither owns it, and passing it through the page would put state in a wiring
|
||||
* container the Page pattern keeps stateless.
|
||||
*/
|
||||
const [highlightedAlternative, setHighlightedAlternative] = useState<number | null>(null)
|
||||
// Closing the picker has to clear it, or the next one opens with a road already lit.
|
||||
useEffect(() => {
|
||||
if (!routeAlternatives.open) setHighlightedAlternative(null)
|
||||
}, [routeAlternatives.open])
|
||||
|
||||
/**
|
||||
* The offered routes as the map draws them: line, colour, and the label that sits on
|
||||
* the road. Built here rather than in the page so the page stays a wiring container
|
||||
* and both renderers get the identical shape.
|
||||
*/
|
||||
const alternativeOverlays = useMemo(
|
||||
() => buildAlternativeOverlays(routeAlternatives.open?.routes, {
|
||||
fastest: t('roadtrip.alt.fastest'),
|
||||
current: t('roadtrip.alt.current'),
|
||||
noMotorway: t('roadtrip.alt.noMotorway'),
|
||||
noToll: t('roadtrip.alt.noToll'),
|
||||
}),
|
||||
[routeAlternatives.open, t],
|
||||
)
|
||||
|
||||
/**
|
||||
* The stretch of map the offered routes cover, handed to whichever renderer is up.
|
||||
*
|
||||
* Opening the picker without moving the camera means weighing three roads you cannot
|
||||
* see. Derived from the overlays rather than from the two endpoints so the frame holds
|
||||
* the whole of every alternative, including one that swings far off the direct line.
|
||||
* Empty while nothing is open, and the map is told to do nothing with an empty list —
|
||||
* so closing the picker leaves the view where the user put it.
|
||||
*/
|
||||
const alternativeFocusPoints = useMemo(
|
||||
() => alternativeOverlays.flatMap(o => o.coordinates),
|
||||
[alternativeOverlays],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
const from = day?.stops[legIndex]
|
||||
const to = day?.stops[legIndex + 1]
|
||||
if (!from || !to || !day) return
|
||||
if (routeAlternatives.open?.dayId === dayId && routeAlternatives.open.index === legIndex) {
|
||||
routeAlternatives.close()
|
||||
return
|
||||
}
|
||||
// The vias on THIS leg, so the road currently driven is offered alongside the
|
||||
// router's own suggestions rather than being missing from its own picker.
|
||||
const dayIndex = day.stops.indexOf(from)
|
||||
const legVias = (roadtripVias.byDay[dayId] ?? [])
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
routeAlternatives.ask(dayId, legIndex, from, to, routeProfile, legVias)
|
||||
}, [roadtripRoutes.days, routeAlternatives, routeProfile, roadtripVias.byDay])
|
||||
|
||||
/**
|
||||
* Taking one of the offered routes.
|
||||
*
|
||||
* Saved as a via at the point where that route differs most from the default, not as a
|
||||
* stored polyline: a polyline goes stale with the next OSM update and with every stop
|
||||
* that moves, while a via keeps forcing the router back onto this road for as long as
|
||||
* the road exists.
|
||||
*/
|
||||
const chooseRouteAlternative = useCallback(async (index: number) => {
|
||||
const open = routeAlternatives.open
|
||||
const alt = open?.routes[index]
|
||||
if (!open || !alt) return
|
||||
// Choosing the road already being driven changes nothing.
|
||||
if (alt.current) { routeAlternatives.close(); return }
|
||||
// The router's own preference means no detour at all, so the vias on this leg go.
|
||||
if (alt.direct || !alt.divergence) {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
|
||||
const stop = day?.stops[open.index]
|
||||
const dayIndex = stop && day ? day.stops.indexOf(stop) : -1
|
||||
const existing = (roadtripVias.byDay[open.dayId] ?? []).filter(v => v.after_order_index === dayIndex)
|
||||
for (const via of existing) await roadtripVias.remove(open.dayId, via.id).catch(() => {})
|
||||
routeAlternatives.close()
|
||||
return
|
||||
}
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
|
||||
const stop = day?.stops[open.index]
|
||||
if (!day || !stop) return
|
||||
try {
|
||||
await roadtripVias.add(open.dayId, day.stops.indexOf(stop), alt.divergence.lat, alt.divergence.lng)
|
||||
routeAlternatives.close()
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [routeAlternatives, roadtripRoutes.days, roadtripVias, toast, t])
|
||||
|
||||
/**
|
||||
* A click on the drawn route puts a via there, and the drive is redrawn through it.
|
||||
*
|
||||
* Which pair of stops it belongs between comes from projecting the click onto the
|
||||
* day's routed geometry — the same measurement the corridor search uses, so "after the
|
||||
* third stop" means the same thing everywhere. The day is the one whose line was hit,
|
||||
* found by trying each day's geometry and keeping the closest.
|
||||
*/
|
||||
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (day.geometry.length < 2) continue
|
||||
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
|
||||
const hit = projectOntoRoute({ lat, lng }, spine)
|
||||
if (!hit) continue
|
||||
if (best && hit.offRouteKm >= best.offRouteKm) continue
|
||||
// Which stop the via follows: the last one the car passes before reaching it.
|
||||
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
|
||||
const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1)
|
||||
best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm }
|
||||
}
|
||||
// A click that landed on some other line is not a via anywhere.
|
||||
if (!best || best.offRouteKm > 2) return
|
||||
try {
|
||||
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [roadtripRoutes.days, roadtripVias, can, trip, toast, t])
|
||||
|
||||
/** Dragging a via redraws the route through its new position. */
|
||||
const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
try {
|
||||
await roadtripVias.move(dayId, id, lat, lng)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [roadtripVias, can, trip, toast, t])
|
||||
|
||||
/** Removing a via lets the drive take the direct road again. */
|
||||
const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
|
||||
if (!can('day_edit', trip)) return
|
||||
try {
|
||||
await roadtripVias.remove(dayId, id)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [roadtripVias, can, trip, toast, t])
|
||||
|
||||
/**
|
||||
* A corridor hit dropped on the map, placed where it was dropped rather than where the
|
||||
* corridor projected it.
|
||||
*
|
||||
* The two differ whenever a drive passes near the same spot twice — a loop, an
|
||||
* out-and-back — and the automatic projection can only pick one of them. Dropping says
|
||||
* which, and the drop coordinate is projected onto the same routed line the hits were
|
||||
* measured along, so the answer is in the same units as everything else.
|
||||
*
|
||||
* A drop nowhere near the drive is ignored rather than guessed at: adding a stop
|
||||
* fifty kilometres off the route because the pointer slipped is worse than nothing
|
||||
* happening.
|
||||
*/
|
||||
const dropPoiOnRoute = useCallback((osmId: string, lat: number, lng: number) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
const hit = roadtripCorridor.visible.find(p => p.osm_id === osmId)
|
||||
const day = roadtripCorridor.day
|
||||
if (!hit || !day) return
|
||||
const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine)
|
||||
if (!at || at.offRouteKm > roadtripCorridor.widthKm) return
|
||||
setStopDraft({
|
||||
poi: hit,
|
||||
dayId: day.dayId,
|
||||
position: insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm),
|
||||
dayNumber: day.dayNumber,
|
||||
})
|
||||
}, [roadtripCorridor, can, trip])
|
||||
|
||||
/** Hands the draft over to the full form, keeping the day and the position it worked out. */
|
||||
const stopDraftToForm = useCallback(() => {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
setStopDraft(null)
|
||||
openAddPlaceFromPoi(poi, dayId, position)
|
||||
}, [stopDraft, openAddPlaceFromPoi])
|
||||
|
||||
/**
|
||||
* A place on this trip that came from the same OSM object.
|
||||
*
|
||||
* The full place form warns about duplicates; without the same check here the popup
|
||||
* would be the quickest way to add one petrol station twice.
|
||||
*/
|
||||
const stopDraftDuplicate = useMemo(() => {
|
||||
if (!stopDraft?.poi.osm_id) return null
|
||||
return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null
|
||||
}, [stopDraft, places])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
@@ -641,7 +1119,9 @@ export function useTripPlanner() {
|
||||
// screen entirely — which reads as "it wasn't saved" (#1998).
|
||||
if (place?.id && placeFormDayId != null) {
|
||||
try {
|
||||
await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id)
|
||||
// With a position the stop lands where it will be driven past, not at the end
|
||||
// of the day. The slice has taken one all along; nothing ever passed it.
|
||||
await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id, placeFormPosition)
|
||||
updateRouteForDay(placeFormDayId)
|
||||
} catch (err: unknown) {
|
||||
// The place itself exists; only the day link failed.
|
||||
@@ -667,7 +1147,7 @@ export function useTripPlanner() {
|
||||
// exist a moment ago (#1298), the same way the booking modals work.
|
||||
return place?.id ? { id: place.id } : undefined
|
||||
}
|
||||
}, [editingPlace, editingAssignmentId, placeFormDayId, tripId, toast, pushUndo, updateRouteForDay])
|
||||
}, [editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, tripId, toast, pushUndo, updateRouteForDay])
|
||||
|
||||
// Open the place editor from any entry point (Places pool, inspector, map).
|
||||
// Times live per day-assignment, so when no day is in context resolve the
|
||||
@@ -1118,6 +1598,13 @@ export function useTripPlanner() {
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
|
||||
stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
|
||||
@@ -1134,7 +1621,7 @@ export function useTripPlanner() {
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
|
||||
@@ -59,6 +59,8 @@ export function forgetServerLanguage(): void {
|
||||
// can't drift apart again.
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
map_tile_url: '',
|
||||
// Empty = the public routing hosts TREK ships with.
|
||||
routing_base_url: '',
|
||||
dark_mode: false,
|
||||
// Empty = no personal display currency, so Costs falls back to the trip's own.
|
||||
default_currency: '',
|
||||
|
||||
@@ -140,9 +140,13 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
||||
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
|
||||
|
||||
const newToItems = [...toItems]
|
||||
newToItems.splice(insertAt, 0, { ...assignment, day_id: Number.parseInt(String(toDayId)) })
|
||||
newToItems.forEach((a, i) => { a.order_index = i })
|
||||
const withMoved = [...toItems]
|
||||
withMoved.splice(insertAt, 0, { ...assignment, day_id: Number.parseInt(String(toDayId)) })
|
||||
// Renumber into copies. Assigning `order_index` in place would mutate the very objects
|
||||
// still held in `prevAssignments`, so the rollback below would restore a list whose
|
||||
// indices had already been overwritten — a failed move left the order wrong instead of
|
||||
// putting it back.
|
||||
const newToItems = withMoved.map((a, i) => ({ ...a, order_index: i }))
|
||||
|
||||
set(s => ({
|
||||
assignments: {
|
||||
|
||||
@@ -108,6 +108,11 @@ export type DistanceUnit = 'metric' | 'imperial'
|
||||
|
||||
export interface Settings {
|
||||
map_tile_url: string
|
||||
/**
|
||||
* Base URL of a self-hosted routing engine (#1797). Empty falls back to the public
|
||||
* FOSSGIS hosts, which allow about one request a second.
|
||||
*/
|
||||
routing_base_url?: string
|
||||
dark_mode: boolean | string
|
||||
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
|
||||
default_currency: string | null
|
||||
|
||||
+10
-2
@@ -7,7 +7,8 @@ import type { INestApplication } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { AppModule } from './nest/app.module';
|
||||
import { httpConfig } from './nest/app-config';
|
||||
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
|
||||
import { SettingsService } from './nest/settings/settings.service';
|
||||
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
|
||||
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
|
||||
import { setupApiDocs } from './nest/platform/api-docs';
|
||||
@@ -84,7 +85,14 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
// the one bridge that lets the pre-init Express layer consume the validated
|
||||
// config instead of reading process.env itself.
|
||||
const http = app.get<ConfigType<typeof httpConfig>>(httpConfig.KEY);
|
||||
applyGlobalMiddleware(instance, { http });
|
||||
// Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or
|
||||
// the browser blocks every request to it without an error the app could report.
|
||||
const settings = app.get(SettingsService, { strict: false });
|
||||
const routingBase = settings?.getAdminUserDefaults()?.routing_base_url;
|
||||
applyGlobalMiddleware(instance, {
|
||||
http,
|
||||
extraConnectSrc: routingCspOrigins([typeof routingBase === 'string' ? routingBase : null]),
|
||||
});
|
||||
// Same pre-init consumption bridge as httpConfig above: the StorageService
|
||||
// instance is resolvable before init, and the handlers only *register* here —
|
||||
// per-request resolution runs after app.init() completed the registry load.
|
||||
|
||||
@@ -4257,6 +4257,7 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
/**
|
||||
/**
|
||||
* Place shadow log: which search result a user actually picked.
|
||||
*
|
||||
@@ -4292,6 +4293,58 @@ function runMigrations(db: Database.Database): void {
|
||||
// Retention deletes by age, the export pages by id.
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)');
|
||||
},
|
||||
/**
|
||||
* What kind of stop a place is on a drive — fuel, charging, rest area, campsite.
|
||||
*
|
||||
* Deliberately NOT a `categories` row. Those are the traveller's own list, editable
|
||||
* and instance-wide (`categories.service.ts` selects them without a user filter), so
|
||||
* seeding four road-trip kinds there would push them into everyone's dropdown and hand
|
||||
* their colour to whoever edits the list first. A refuelling stop is not a taste; it is
|
||||
* a fact about the place, and the road-trip categories already own its icon and colour
|
||||
* (`poiCategories.ts`).
|
||||
*
|
||||
* Free text rather than a CHECK constraint: the set grows with what the corridor
|
||||
* search can look for, and SQLite cannot alter a constraint without rebuilding the
|
||||
* table. NULL means an ordinary place, which is every row that exists today.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'stop_type')) {
|
||||
db.exec('ALTER TABLE places ADD COLUMN stop_type TEXT');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Points a day's drive is made to pass through, without being stops (#1797).
|
||||
*
|
||||
* The difference is the whole point: a stop is somewhere you go, and it takes a
|
||||
* number in the chain, a place row, an arrival time and a line in the itinerary. A
|
||||
* via is none of that — it only bends the route, which is what "take the coast road
|
||||
* instead" means. Storing one as a place was the alternative, and it would have put a
|
||||
* numbered stop in the middle of the day for a spot nobody stops at.
|
||||
*
|
||||
* Anchored to `after_order_index` rather than to an assignment id: a stop added
|
||||
* mid-day is written with a temporary negative id and swapped for the real one moments
|
||||
* later, so a foreign key to it would dangle. The index is what the routing request is
|
||||
* built from anyway.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roadtrip_vias (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
after_order_index INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -72,15 +72,47 @@ export function redact(value: unknown): unknown {
|
||||
* bootstrap.ts. The flag that used to switch a second parser on had exactly one
|
||||
* caller, which passed false.
|
||||
*/
|
||||
/**
|
||||
* Turns a configured routing base URL into the origin CSP needs.
|
||||
*
|
||||
* Only the origin: a CSP source with a path only matches that path prefix, and a router
|
||||
* is asked at several of them (`/route/v1/...`, `/table/v1/...`). Anything that is not an
|
||||
* absolute http(s) URL is dropped rather than passed through — a bad value in a settings
|
||||
* row must not be able to widen the policy.
|
||||
*/
|
||||
export function routingCspOrigins(baseUrls: (string | null | undefined)[]): string[] {
|
||||
const origins = new Set<string>();
|
||||
for (const raw of baseUrls) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
|
||||
origins.add(url.origin);
|
||||
} catch {
|
||||
// Not a URL; the client falls back to the public hosts, so the policy stays as it was.
|
||||
}
|
||||
}
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
export function applyGlobalMiddleware(
|
||||
app: express.Application,
|
||||
opts: { http?: AppEnv['http'] } = {},
|
||||
opts: {
|
||||
http?: AppEnv['http'];
|
||||
/**
|
||||
* Extra origins the browser may talk to, from the instance's own settings — today the
|
||||
* self-hosted routing engine (#1797). Read once at apply time like everything else
|
||||
* here, so changing it takes a restart; the alternative is a database read on every
|
||||
* request for a value that changes about once in a deployment's life.
|
||||
*/
|
||||
extraConnectSrc?: string[];
|
||||
} = {},
|
||||
): void {
|
||||
// The whole pipeline is configured at APPLY time (the per-request closures
|
||||
// capture these values), so a snapshot is the correct semantic. bootstrap
|
||||
// threads in the DI-loaded httpConfig; direct callers fall back to an
|
||||
// apply-time readEnv() — same values, same freeze point.
|
||||
const { http = readEnv().http } = opts;
|
||||
const { http = readEnv().http, extraConnectSrc = [] } = opts;
|
||||
const { nodeEnv, isProduction } = readEnv().app;
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
@@ -207,7 +239,11 @@ export function applyGlobalMiddleware(
|
||||
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
|
||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
|
||||
"https://tiles.openfreemap.org"
|
||||
"https://tiles.openfreemap.org",
|
||||
// A self-hosted routing engine, when the instance has one configured. Without
|
||||
// this the browser blocks it silently: no error the app can catch, just legs
|
||||
// that never route (#1797).
|
||||
...extraConnectSrc,
|
||||
],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GlobalAuthGuard } from './auth/global-auth.guard';
|
||||
import { MfaPolicyGuard } from './auth/mfa-policy.guard';
|
||||
import { ManagedGuard } from './common/managed.guard';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { RoadtripModule } from './roadtrip/roadtrip.module';
|
||||
import { PublicApiModule } from './public-api/public-api.module';
|
||||
import { HelpModule } from './help/help.module';
|
||||
import { AirportsModule } from './airports/airports.module';
|
||||
@@ -77,7 +78,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
providers: [
|
||||
// Default-deny: a route is authenticated unless it carries @Public() or
|
||||
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
|
||||
|
||||
@@ -43,6 +43,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
|
||||
osm_id: a.osm_id,
|
||||
website: a.website,
|
||||
phone: a.phone,
|
||||
// The rail marks a fuel stop as one; without it here every stop would need
|
||||
// its own place request to find out what kind it is.
|
||||
stop_type: a.stop_type ?? null,
|
||||
category: a.category_id ? {
|
||||
id: a.category_id,
|
||||
name: a.category_name,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
placeImageUrlSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeWebsiteSchema,
|
||||
roadtripStopTypeSchema,
|
||||
type RoadtripStopType,
|
||||
} from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
@@ -74,22 +76,24 @@ export class PlacesMcp {
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area or campsite. Leave unset for an ordinary place.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createPlace(
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency }: {
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type }: {
|
||||
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
|
||||
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
|
||||
stop_type?: RoadtripStopType;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type });
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface PlaceCreateInput {
|
||||
place_time?: string; end_time?: string;
|
||||
duration_minutes?: number; notes?: string; image_url?: string;
|
||||
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
|
||||
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[];
|
||||
}
|
||||
|
||||
@@ -77,6 +79,8 @@ export interface PlaceUpdateInput {
|
||||
place_time?: string; end_time?: string;
|
||||
duration_minutes?: number; notes?: string; image_url?: string;
|
||||
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
|
||||
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
transport_mode?: string; route_color?: string | null; tags?: number[];
|
||||
}
|
||||
|
||||
@@ -224,24 +228,29 @@ export class PlacesService {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
|
||||
transport_mode, route_geometry, route_color, tags = [],
|
||||
transport_mode, route_geometry, route_color, stop_type, tags = [],
|
||||
} = body;
|
||||
|
||||
const result = this.dbs.run(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode,
|
||||
route_geometry, route_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
route_geometry, route_color, stop_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
|
||||
// 0 is a legitimate value for all four (Null Island, a free entry, a
|
||||
// drive-by stop) and the falsy coercion silently threw it away.
|
||||
//
|
||||
// `duration_minutes` stays NULL when nobody says otherwise, rather than being
|
||||
// invented as an hour. It is read in exactly one place — the road trip rail's
|
||||
// "Stay" — and a default meant every place ever created claimed a one-hour stop
|
||||
// the traveller had never entered, with no way to tell the two apart.
|
||||
tripId, name, description || null, lat ?? null, lng ?? null, address || null,
|
||||
category_id || null, price ?? null, currency || null,
|
||||
place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null,
|
||||
place_time || null, end_time || null, duration_minutes ?? null, notes || null, image_url || null,
|
||||
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
|
||||
route_geometry || null, route_color || null,
|
||||
route_geometry || null, route_color || null, stop_type || null,
|
||||
);
|
||||
|
||||
const placeId = result.lastInsertRowid;
|
||||
@@ -306,7 +315,7 @@ export class PlacesService {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
|
||||
transport_mode, route_color, tags,
|
||||
transport_mode, route_color, stop_type, tags,
|
||||
} = body;
|
||||
|
||||
this.dbs.run(`
|
||||
@@ -331,6 +340,7 @@ export class PlacesService {
|
||||
phone = ?,
|
||||
transport_mode = COALESCE(?, transport_mode),
|
||||
route_color = ?,
|
||||
stop_type = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
@@ -358,6 +368,8 @@ export class PlacesService {
|
||||
// Deliberately not COALESCE: an explicit null is how the picker resets a
|
||||
// track back to its category colour (#776).
|
||||
route_color !== undefined ? route_color : existingPlace.route_color,
|
||||
// Same shape: an explicit null is how a fuel stop becomes an ordinary place again.
|
||||
stop_type !== undefined ? stop_type : existingPlace.stop_type,
|
||||
placeId,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/roadtrip — the points a drive is routed through (#1797).
|
||||
*
|
||||
* Gated on the road trip addon: an instance with it switched off gets 404s here, the same
|
||||
* as every other addon surface, and nothing about the plain day plan changes.
|
||||
*
|
||||
* No WebSocket broadcast, unlike the assignment routes. A via is a detail of how one
|
||||
* person's route is drawn rather than a change to the itinerary everybody reads, and
|
||||
* adding an event family for it would mean extending the typed trip-event catalogue for
|
||||
* something a collaborator sees on their next load anyway. Worth revisiting if vias ever
|
||||
* become shared editing rather than route shaping.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/roadtrip')
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class RoadtripController {
|
||||
constructor(private readonly roadtrip: RoadtripService) {}
|
||||
|
||||
/** Every via of the trip, so the client can route all days without a request per day. */
|
||||
@Get('vias')
|
||||
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[] } {
|
||||
return { vias: this.roadtrip.listForTrip(tripId) };
|
||||
}
|
||||
|
||||
@Get('days/:dayId/vias')
|
||||
list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): { vias: RoadtripVia[] } {
|
||||
this.requireDay(dayId, tripId);
|
||||
return { vias: this.roadtrip.listForDay(dayId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post('days/:dayId/vias')
|
||||
create(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: RoadtripViaCreateDto,
|
||||
): { via: RoadtripVia } {
|
||||
this.requireDay(dayId, tripId);
|
||||
return { via: this.roadtrip.create(dayId, body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pin this day's vias after its stops changed shape.
|
||||
*
|
||||
* Declared before `vias/:id` so the static path is matched first, and taken as one
|
||||
* batch rather than a PUT per via: the anchors are only correct as a set, and a
|
||||
* half-applied shift leaves two vias on one leg and a third past the end of the day.
|
||||
*/
|
||||
@RequirePermission('day_edit')
|
||||
@Put('days/:dayId/vias')
|
||||
@HttpCode(200)
|
||||
reanchor(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: RoadtripViaReanchorDto,
|
||||
): { vias: RoadtripVia[] } {
|
||||
this.requireDay(dayId, tripId);
|
||||
return { vias: this.roadtrip.reanchor(dayId, body) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put('days/:dayId/vias/:id')
|
||||
@HttpCode(200)
|
||||
update(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: RoadtripViaUpdateDto,
|
||||
): { via: RoadtripVia } {
|
||||
this.requireDay(dayId, tripId);
|
||||
const via = this.roadtrip.move(id, dayId, body.lat, body.lng);
|
||||
if (!via) throw new HttpException({ error: 'Via not found' }, 404);
|
||||
return { via };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete('days/:dayId/vias/:id')
|
||||
remove(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
): { success: true } {
|
||||
this.requireDay(dayId, tripId);
|
||||
if (!this.roadtrip.remove(id, dayId)) {
|
||||
throw new HttpException({ error: 'Via not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard proves the caller may reach the trip; this proves the day is part of it.
|
||||
* Without it a valid day id from someone else's trip would be editable through a trip
|
||||
* the caller does have access to.
|
||||
*/
|
||||
private requireDay(dayId: string, tripId: string): void {
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadtripViaUpdateRequestSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* createZodDto wrappers over the @trek/shared road-trip contracts. The global
|
||||
* ZodValidationPipe validates any @Body() typed with one of these by metatype, so the
|
||||
* schemas in shared/ stay the single source of truth for the wire contract.
|
||||
*/
|
||||
export class RoadtripViaCreateDto extends createZodDto(roadtripViaCreateRequestSchema) {}
|
||||
export class RoadtripViaUpdateDto extends createZodDto(roadtripViaUpdateRequestSchema) {}
|
||||
export class RoadtripViaReanchorDto extends createZodDto(roadtripViaReanchorRequestSchema) {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
/**
|
||||
* Road-trip via points over MCP, the same surface the REST routes expose (#1797).
|
||||
*
|
||||
* Parity is the point: the permission checked here is `day_edit`, the same action string
|
||||
* the controller demands, so an assistant can never do through a tool what a person
|
||||
* cannot do through the UI. Reads go through the same day-belongs-to-trip check, so a
|
||||
* valid day id from another trip is not reachable by way of one the caller can see.
|
||||
*/
|
||||
@McpController()
|
||||
export class RoadtripMcp {
|
||||
constructor(
|
||||
private readonly roadtrip: RoadtripService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly auth: AuthService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_route_vias',
|
||||
description: 'List the points a day\'s drive is routed through without stopping at them. These bend the route (a scenic road, a pass, avoiding a motorway) and are not stops on the itinerary.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().optional().describe('Omit to list the vias of every day of the trip'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (dayId != null) {
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
return ok({ vias: this.roadtrip.listForDay(dayId) });
|
||||
}
|
||||
return ok({ vias: this.roadtrip.listForTrip(tripId) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'add_route_via',
|
||||
description: 'Make a day\'s drive pass through a point without stopping there — use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
after_order_index: z.number().int().min(0).describe('Which stop of the day the via follows, counting from 0'),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async addVia(
|
||||
{ tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
return ok({ via: this.roadtrip.create(dayId, { after_order_index, lat, lng }) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'reanchor_route_vias',
|
||||
description: 'Re-pin a day\'s via points after its stops changed. A via records which stop it follows by position, so adding, removing or reordering a stop leaves every later via pointing at the wrong leg and the drive silently reverts to the road it was steered away from. Send the corrected positions for the whole day at once; ids left out keep the position they have.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
vias: z.array(z.object({
|
||||
id: z.number().int().positive(),
|
||||
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
|
||||
})).max(500),
|
||||
remove: z.array(z.number().int().positive()).max(500).optional()
|
||||
.describe('Vias whose leg no longer exists at all — deleting the last stop of a day leaves the leg into it with nothing to sit on'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async reanchorVias(
|
||||
{ tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
return ok({ vias: this.roadtrip.reanchor(dayId, { vias, remove }) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'remove_route_via',
|
||||
description: 'Remove a via point, letting the drive take the direct route again.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
viaId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
if (!this.roadtrip.remove(viaId, dayId)) return noAccess();
|
||||
return ok({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RoadtripController } from './roadtrip.controller';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { RoadtripMcp } from './roadtrip.mcp';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
|
||||
@Module({
|
||||
// McpShared brings the tool guards, Auth the demo check, Permissions the trip guard.
|
||||
imports: [McpSharedModule, PermissionsModule, AuthModule],
|
||||
controllers: [RoadtripController],
|
||||
providers: [RoadtripService, RoadtripMcp],
|
||||
exports: [RoadtripService],
|
||||
})
|
||||
export class RoadtripModule {}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/**
|
||||
* Via points: the places a day's drive is made to pass through without stopping.
|
||||
*
|
||||
* Kept apart from `day_assignments` on purpose. A stop is somewhere you go — it takes a
|
||||
* number in the chain, an arrival time and a line in the itinerary. A via only bends the
|
||||
* route. Modelling one as a place would put a numbered stop in the middle of the day for
|
||||
* a spot nobody stops at, and it would show up in the PDF, the map pins and the schedule.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoadtripService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/** The day exists and belongs to this trip. 404 material, checked before every write. */
|
||||
dayExists(dayId: string | number, tripId: string | number): boolean {
|
||||
return !!this.db.get<{ id: number }>(
|
||||
'SELECT id FROM days WHERE id = ? AND trip_id = ?',
|
||||
dayId,
|
||||
tripId,
|
||||
);
|
||||
}
|
||||
|
||||
listForDay(dayId: string | number): RoadtripVia[] {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
|
||||
FROM roadtrip_vias
|
||||
WHERE day_id = ?
|
||||
ORDER BY after_order_index, sequence, id`,
|
||||
dayId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every via of a trip, so the client can route all days without one request per day. */
|
||||
listForTrip(tripId: string | number): RoadtripVia[] {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
|
||||
FROM roadtrip_vias v
|
||||
JOIN days d ON d.id = v.day_id
|
||||
WHERE d.trip_id = ?
|
||||
ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`,
|
||||
tripId,
|
||||
);
|
||||
}
|
||||
|
||||
create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): RoadtripVia {
|
||||
// Appended after whatever already follows that stop, unless the caller says where.
|
||||
const sequence = input.sequence ?? (
|
||||
this.db.get<{ next: number }>(
|
||||
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
|
||||
dayId,
|
||||
input.after_order_index,
|
||||
)?.next ?? 0
|
||||
);
|
||||
const result = this.db.run(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
||||
dayId,
|
||||
input.after_order_index,
|
||||
sequence,
|
||||
input.lat,
|
||||
input.lng,
|
||||
);
|
||||
return this.byId(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
/** Moving a via is the whole edit; where it sits in the chain does not change. */
|
||||
move(id: string | number, dayId: string | number, lat: number, lng: number): RoadtripVia | null {
|
||||
const existing = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
|
||||
id,
|
||||
dayId,
|
||||
);
|
||||
if (!existing) return null;
|
||||
this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id);
|
||||
return this.byId(Number(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pin a day's vias in one go, after its stops changed shape.
|
||||
*
|
||||
* One transaction, because the numbers only mean anything as a set: applying half of a
|
||||
* shift leaves two vias claiming the same leg and a third pinned past the end of the
|
||||
* day. `AND day_id = ?` on every statement is what stops an id from another day — or
|
||||
* another trip — being renumbered through a day the caller does happen to reach.
|
||||
*
|
||||
* Silent about ids it does not find. The caller computed this list from a snapshot of
|
||||
* the day, and a via somebody else deleted in the meantime is not a failure of the
|
||||
* re-anchoring; failing the batch over it would leave the rest of the day mis-pinned.
|
||||
*/
|
||||
reanchor(
|
||||
dayId: string | number,
|
||||
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
|
||||
): RoadtripVia[] {
|
||||
return this.db.transaction(() => {
|
||||
for (const id of input.remove ?? []) {
|
||||
this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
}
|
||||
for (const via of input.vias) {
|
||||
this.db.run(
|
||||
'UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?',
|
||||
via.after_order_index,
|
||||
via.id,
|
||||
dayId,
|
||||
);
|
||||
}
|
||||
return this.listForDay(dayId);
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string | number, dayId: string | number): boolean {
|
||||
const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private byId(id: number): RoadtripVia | null {
|
||||
return this.db.get<RoadtripVia>(
|
||||
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
|
||||
id,
|
||||
) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,18 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'default_currency',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
/**
|
||||
* Base URL of the routing engine, when the instance runs its own (#1797).
|
||||
*
|
||||
* Empty means the public OSRM hosts TREK ships with, which allow roughly one request a
|
||||
* second — enough for a day plan, not for a road trip that routes every leg of every
|
||||
* day, and they answer nothing beyond plain routing. Defaultable rather than per-user
|
||||
* because a routing engine is a property of the deployment, not a taste.
|
||||
*
|
||||
* Changing it needs a server restart: the browser's connect-src allowlist is assembled
|
||||
* once at boot, and a router the policy does not name fails silently.
|
||||
*/
|
||||
'routing_base_url',
|
||||
// CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and
|
||||
// the key is per-instance rather than per-person: defaultable so one admin
|
||||
// value clears the watermark for everybody at once.
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface Place {
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
transport_mode?: string;
|
||||
/** What kind of stop this is on a drive (#1797); null for an ordinary place. */
|
||||
stop_type?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -335,6 +337,7 @@ export interface AssignmentRow extends DayAssignment {
|
||||
osm_id: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
stop_type: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware';
|
||||
import { applyGlobalMiddleware, routingCspOrigins } from '../../../src/middleware/globalMiddleware';
|
||||
|
||||
async function directiveSources(name: string): Promise<string[]> {
|
||||
const app = express();
|
||||
@@ -122,3 +122,45 @@ describe('forced-HTTPS redirect', () => {
|
||||
expect(probe.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routingCspOrigins', () => {
|
||||
it('CSP-ROUTING-001: keeps only the origin, because a path source matches that path alone', () => {
|
||||
// A router is asked at several paths (/route/v1/…, /table/v1/…); pinning one would
|
||||
// block the rest without an error the app could report.
|
||||
expect(routingCspOrigins(['https://osrm.example.org/route/v1/driving']))
|
||||
.toEqual(['https://osrm.example.org']);
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
|
||||
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
|
||||
// A bad settings row must not be able to loosen the policy.
|
||||
expect(routingCspOrigins(['', null, undefined, 'not a url', 'javascript:alert(1)', 'ftp://x/y']))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-004: the same host twice is one source', () => {
|
||||
expect(routingCspOrigins([
|
||||
'https://osrm.example.org/route/v1/driving',
|
||||
'https://osrm.example.org/table/v1/driving',
|
||||
])).toEqual(['https://osrm.example.org']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect-src with a self-hosted router', () => {
|
||||
it('CSP-ROUTING-005: a configured origin is named, or the browser blocks it silently', async () => {
|
||||
const app = express();
|
||||
applyGlobalMiddleware(app, { extraConnectSrc: ['https://osrm.example.org'] });
|
||||
app.get('/probe', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const res = await request(app).get('/probe');
|
||||
const csp = String(res.headers['content-security-policy'] || '');
|
||||
const connect = csp.split(';').map(d => d.trim()).find(d => d.startsWith('connect-src'))!;
|
||||
|
||||
expect(connect).toContain('https://osrm.example.org');
|
||||
// The public hosts stay, so an instance can be switched back without another deploy.
|
||||
expect(connect).toContain('https://routing.openstreetmap.de/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
|
||||
|
||||
/**
|
||||
* Via points, against a real SQLite. The table is tiny and the interesting parts are the
|
||||
* ordering and the day scoping, both of which are SQL — mocking the DB would test the mock.
|
||||
*/
|
||||
function makeService() {
|
||||
const raw = new Database(':memory:');
|
||||
raw.exec(`
|
||||
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL);
|
||||
CREATE TABLE roadtrip_vias (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
after_order_index INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO days (id, trip_id) VALUES (1, 7), (2, 7), (3, 99);
|
||||
`);
|
||||
const db = {
|
||||
get: <T>(sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params as never[]) as T | undefined,
|
||||
all: <T>(sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params as never[]) as T[],
|
||||
run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]),
|
||||
transaction: <T>(fn: (conn: unknown) => T) => raw.transaction(() => fn(raw))(),
|
||||
};
|
||||
return new RoadtripService(db as never);
|
||||
}
|
||||
|
||||
describe('RoadtripService', () => {
|
||||
let service: RoadtripService;
|
||||
beforeEach(() => { service = makeService(); });
|
||||
|
||||
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', () => {
|
||||
// The trip guard proves access to the trip; this is what stops a valid day id from
|
||||
// someone else’s trip being edited through one the caller can reach.
|
||||
expect(service.dayExists(1, 7)).toBe(true);
|
||||
expect(service.dayExists(3, 7)).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', () => {
|
||||
const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 });
|
||||
|
||||
expect(first.sequence).toBe(0);
|
||||
expect(second.sequence).toBe(1);
|
||||
expect(service.listForDay(1).map(v => v.id)).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', () => {
|
||||
const late = service.create(1, { after_order_index: 2, lat: 51, lng: 12 });
|
||||
const early = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect(service.listForDay(1).map(v => v.id)).toEqual([early.id, late.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', () => {
|
||||
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
service.create(2, { after_order_index: 0, lat: 52, lng: 11 });
|
||||
service.create(3, { after_order_index: 0, lat: 50, lng: 14 });
|
||||
|
||||
// Day 3 belongs to trip 99 and must not appear in trip 7.
|
||||
expect(service.listForTrip(7).map(v => v.day_id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-005: moving a via changes where it is, not where it sits in the chain', () => {
|
||||
const via = service.create(1, { after_order_index: 1, lat: 53, lng: 10 });
|
||||
|
||||
const moved = service.move(via.id, 1, 52.5, 11.5);
|
||||
|
||||
expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', () => {
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect(service.move(via.id, 2, 0, 0)).toBeNull();
|
||||
expect(service.remove(via.id, 2)).toBe(false);
|
||||
// Still where it was.
|
||||
expect(service.listForDay(1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', () => {
|
||||
const a = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const b = service.create(1, { after_order_index: 1, lat: 54, lng: 11 });
|
||||
const c = service.create(1, { after_order_index: 2, lat: 55, lng: 12 });
|
||||
|
||||
const after = service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] });
|
||||
|
||||
const byId = new Map(after.map(v => [v.id, v.after_order_index]));
|
||||
expect(byId.get(a.id)).toBe(0);
|
||||
expect(byId.get(b.id)).toBe(2);
|
||||
expect(byId.get(c.id)).toBe(3);
|
||||
// Where a via sits is untouched — only which leg it belongs to changed.
|
||||
expect(after.find(v => v.id === b.id)?.lat).toBe(54);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', () => {
|
||||
const gone = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const kept = service.create(1, { after_order_index: 2, lat: 54, lng: 11 });
|
||||
|
||||
const after = service.reanchor(1, {
|
||||
vias: [{ id: kept.id, after_order_index: 1 }],
|
||||
remove: [gone.id],
|
||||
});
|
||||
|
||||
expect(after.map(v => v.id)).toEqual([kept.id]);
|
||||
expect(after[0].after_order_index).toBe(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', () => {
|
||||
// The batch is trusted to name ids, so `AND day_id = ?` is the only thing standing
|
||||
// between a day the caller can reach and every via of the trip.
|
||||
const other = service.create(2, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] });
|
||||
|
||||
const untouched = service.listForDay(2);
|
||||
expect(untouched.map(v => v.id)).toEqual([other.id]);
|
||||
expect(untouched[0].after_order_index).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', () => {
|
||||
// The caller computed the batch from a snapshot; a via somebody else deleted in the
|
||||
// meantime must not leave the remaining ones mis-pinned.
|
||||
const kept = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
const after = service.reanchor(1, {
|
||||
vias: [{ id: 9999, after_order_index: 4 }, { id: kept.id, after_order_index: 1 }],
|
||||
});
|
||||
|
||||
expect(after.map(v => v.after_order_index)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', () => {
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
|
||||
expect(service.remove(via.id, 1)).toBe(true);
|
||||
expect(service.remove(via.id, 1)).toBe(false);
|
||||
expect(service.listForDay(1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'طعام',
|
||||
'roadtrip.poi.sights': 'معالم',
|
||||
'roadtrip.poi.within': 'ضمن',
|
||||
'roadtrip.poi.filter': 'تصفية حسب الاسم',
|
||||
'roadtrip.poi.noMatch': 'لا يوجد على الطريق ما يطابق «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} من {total} على الطريق',
|
||||
'roadtrip.poi.truncated': 'في {count} من المقاطع كان هناك أكثر مما تتسع له استجابة واحدة — قلّص الممر لرؤية البقية.',
|
||||
'roadtrip.poi.search': 'ابحث على طول المسار',
|
||||
'roadtrip.poi.searching': 'جارٍ البحث {done} من {total}',
|
||||
'roadtrip.poi.capped': 'المسار طويل — تم البحث في الجزء الأول فقط.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'بعد {distance}',
|
||||
'roadtrip.summary.distance': 'المسافة',
|
||||
'roadtrip.summary.driving': 'مدة القيادة',
|
||||
'roadtrip.alt.ask': 'مسارات أخرى',
|
||||
'roadtrip.alt.title': 'طرق قيادة هذه المرحلة',
|
||||
'roadtrip.alt.loading': 'يجري سؤال خادم المسارات…',
|
||||
'roadtrip.alt.onlyOne': 'لهذه المرحلة طريق معقول واحد فقط.',
|
||||
'roadtrip.alt.failed': 'خادم المسارات لا يستجيب حاليًا.',
|
||||
'roadtrip.alt.current': 'الحالي',
|
||||
'roadtrip.alt.fastest': 'الأسرع',
|
||||
'roadtrip.alt.slower': 'أبطأ بـ {time}',
|
||||
'roadtrip.alt.quicker': 'أسرع بـ {time}',
|
||||
'roadtrip.day': 'اليوم {number}',
|
||||
'roadtrip.quietDay.empty': 'لا توجد محطات بعد — أفلِت واحدة هنا',
|
||||
'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا',
|
||||
'roadtrip.leg.driveText': '{distance} خلال {time}',
|
||||
'roadtrip.day.stopCount': '{count} محطات',
|
||||
'roadtrip.stop.stayShort': 'مدة',
|
||||
'roadtrip.leg.pending': 'لا يوجد مسار',
|
||||
'roadtrip.stop.addTitle': 'إضافة كمحطة توقف',
|
||||
'roadtrip.stop.kind': 'نوع المحطة',
|
||||
'roadtrip.stop.landsOn': 'اليوم {day}، المحطة {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» موجود بالفعل في هذه الرحلة.',
|
||||
'roadtrip.stop.moreDetails': 'مزيد من التفاصيل',
|
||||
'roadtrip.stop.stay': 'مدة التوقف هنا',
|
||||
'roadtrip.stop.pinned': 'الوقت الذي حددته',
|
||||
'roadtrip.stop.computed': 'محسوب من زمن القيادة',
|
||||
'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة',
|
||||
'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
|
||||
'roadtrip.warn.overnight': 'اليوم التالي',
|
||||
'roadtrip.summary.stops': 'محطات التوقف',
|
||||
'roadtrip.stay.add': 'تحديد مدة التوقف',
|
||||
'roadtrip.stay.none': 'بدون توقف',
|
||||
'roadtrip.stay.custom': 'دقائق',
|
||||
'roadtrip.stay.arrive': 'الوصول',
|
||||
'roadtrip.stay.leave': 'المغادرة',
|
||||
'roadtrip.alt.noMotorway': 'بدون طريق سريع',
|
||||
'roadtrip.alt.noToll': 'بدون رسوم',
|
||||
'roadtrip.summary.partial': 'لا يزال يجري حساب بقية الطريق',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -23,6 +23,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'قالب الخريطة',
|
||||
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
|
||||
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
|
||||
'settings.routingBase': 'محرك مسارات خاص',
|
||||
'settings.routingBaseHint': 'خادم OSRM خاص بك. اتركه فارغًا لاستخدام الخوادم العامة التي تسمح بطلب واحد تقريبًا في الثانية — يكفي ليوم واحد، وضيّق لرحلة برية. يسري بعد إعادة تشغيل الخادم.',
|
||||
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
|
||||
'settings.mapProvider': 'مزود الخريطة',
|
||||
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
'roadtrip.poi.sights': 'Pontos turísticos',
|
||||
'roadtrip.poi.within': 'Num raio de',
|
||||
'roadtrip.poi.filter': 'Filtrar por nome',
|
||||
'roadtrip.poi.noMatch': 'Nada no trajeto corresponde a “{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} no trajeto',
|
||||
'roadtrip.poi.truncated': 'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.',
|
||||
'roadtrip.poi.search': 'Buscar ao longo da rota',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'A rota é longa — apenas o primeiro trecho foi pesquisado.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'após {distance}',
|
||||
'roadtrip.summary.distance': 'Distância',
|
||||
'roadtrip.summary.driving': 'Tempo de direção',
|
||||
'roadtrip.alt.ask': 'Outras rotas',
|
||||
'roadtrip.alt.title': 'Rotas para este trecho',
|
||||
'roadtrip.alt.loading': 'Consultando o roteador…',
|
||||
'roadtrip.alt.onlyOne': 'Só há uma forma sensata de fazer este trecho.',
|
||||
'roadtrip.alt.failed': 'O roteador não está respondendo agora.',
|
||||
'roadtrip.alt.current': 'Atual',
|
||||
'roadtrip.alt.fastest': 'Mais rápida',
|
||||
'roadtrip.alt.slower': '{time} mais lenta',
|
||||
'roadtrip.alt.quicker': '{time} mais rápida',
|
||||
'roadtrip.day': 'Dia {number}',
|
||||
'roadtrip.quietDay.empty': 'Ainda sem paradas — solte uma aqui',
|
||||
'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui',
|
||||
'roadtrip.leg.driveText': '{distance} em {time}',
|
||||
'roadtrip.day.stopCount': '{count} paradas',
|
||||
'roadtrip.stop.stayShort': 'Parada',
|
||||
'roadtrip.leg.pending': 'Sem rota',
|
||||
'roadtrip.stop.addTitle': 'Adicionar como parada',
|
||||
'roadtrip.stop.kind': 'Tipo de parada',
|
||||
'roadtrip.stop.landsOn': 'Dia {day}, como parada {position}',
|
||||
'roadtrip.stop.duplicate': '“{name}” já está nesta viagem.',
|
||||
'roadtrip.stop.moreDetails': 'Mais detalhes',
|
||||
'roadtrip.stop.stay': 'Tempo nesta parada',
|
||||
'roadtrip.stop.pinned': 'Horário definido por você',
|
||||
'roadtrip.stop.computed': 'Calculado a partir do trajeto',
|
||||
'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover',
|
||||
'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
|
||||
'roadtrip.warn.overnight': 'Dia seguinte',
|
||||
'roadtrip.summary.stops': 'Paradas',
|
||||
'roadtrip.stay.add': 'Definir a permanencia',
|
||||
'roadtrip.stay.none': 'Sem permanencia',
|
||||
'roadtrip.stay.custom': 'Minutos',
|
||||
'roadtrip.stay.arrive': 'Chegada',
|
||||
'roadtrip.stay.leave': 'Partida',
|
||||
'roadtrip.alt.noMotorway': 'Sem rodovia',
|
||||
'roadtrip.alt.noToll': 'Sem pedagio',
|
||||
'roadtrip.summary.partial': 'O restante do trajeto ainda está sendo calculado',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Modelo de mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
|
||||
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
|
||||
'settings.routingBase': 'Servidor de rotas próprio',
|
||||
'settings.routingBaseHint': 'Uma instância própria do OSRM. Vazio usa os servidores públicos, que permitem cerca de uma requisição por segundo — suficiente para um dia, apertado para uma viagem de carro. Passa a valer após reiniciar o servidor.',
|
||||
'settings.mapHint': 'URL do modelo de blocos do mapa',
|
||||
'settings.mapProvider': 'Provedor de mapa',
|
||||
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Menjar',
|
||||
'roadtrip.poi.sights': 'Llocs d’interès',
|
||||
'roadtrip.poi.within': 'En un radi de',
|
||||
'roadtrip.poi.filter': 'Filtra per nom',
|
||||
'roadtrip.poi.noMatch': 'Res del trajecte no coincideix amb «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} al trajecte',
|
||||
'roadtrip.poi.truncated': 'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.',
|
||||
'roadtrip.poi.search': 'Cerca al llarg de la ruta',
|
||||
'roadtrip.poi.searching': 'Cercant {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta és llarga: només s’ha cercat el primer tram.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'després de {distance}',
|
||||
'roadtrip.summary.distance': 'Distància',
|
||||
'roadtrip.summary.driving': 'Temps de conducció',
|
||||
'roadtrip.alt.ask': 'Altres rutes',
|
||||
'roadtrip.alt.title': 'Rutes per a aquest tram',
|
||||
'roadtrip.alt.loading': 'S’està consultant l’encaminador…',
|
||||
'roadtrip.alt.onlyOne': 'Només hi ha una manera raonable de fer aquest tram.',
|
||||
'roadtrip.alt.failed': 'L’encaminador no respon ara mateix.',
|
||||
'roadtrip.alt.current': 'Actual',
|
||||
'roadtrip.alt.fastest': 'La més ràpida',
|
||||
'roadtrip.alt.slower': '{time} més lenta',
|
||||
'roadtrip.alt.quicker': '{time} més ràpida',
|
||||
'roadtrip.day': 'Dia {number}',
|
||||
'roadtrip.quietDay.empty': 'Encara sense parades: deixa’n una aquí',
|
||||
'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí',
|
||||
'roadtrip.leg.driveText': '{distance} en {time}',
|
||||
'roadtrip.day.stopCount': '{count} parades',
|
||||
'roadtrip.stop.stayShort': 'Parada',
|
||||
'roadtrip.leg.pending': 'Sense ruta',
|
||||
'roadtrip.stop.addTitle': 'Afegeix com a parada',
|
||||
'roadtrip.stop.kind': 'Tipus de parada',
|
||||
'roadtrip.stop.landsOn': 'Dia {day}, com a parada {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» ja és en aquest viatge.',
|
||||
'roadtrip.stop.moreDetails': 'Més detalls',
|
||||
'roadtrip.stop.stay': 'Temps en aquesta parada',
|
||||
'roadtrip.stop.pinned': 'Hora que has fixat',
|
||||
'roadtrip.stop.computed': 'Calculat a partir del trajecte',
|
||||
'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar',
|
||||
'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
|
||||
'roadtrip.warn.overnight': 'Dia següent',
|
||||
'roadtrip.summary.stops': 'Parades',
|
||||
'roadtrip.stay.add': 'Defineix una estada',
|
||||
'roadtrip.stay.none': 'Sense estada',
|
||||
'roadtrip.stay.custom': 'Minuts',
|
||||
'roadtrip.stay.arrive': 'Arribada',
|
||||
'roadtrip.stay.leave': 'Sortida',
|
||||
'roadtrip.alt.noMotorway': 'Sense autopista',
|
||||
'roadtrip.alt.noToll': 'Sense peatges',
|
||||
'roadtrip.summary.partial': 'Encara s’està calculant la resta del trajecte',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -14,6 +14,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Plantilla del mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Selecciona una plantilla...',
|
||||
'settings.mapDefaultHint': 'Deixa-ho buit per a OpenStreetMap (per defecte)',
|
||||
'settings.routingBase': 'Motor de rutes propi',
|
||||
'settings.routingBaseHint': 'Una instància pròpia d’OSRM. Buit fa servir els servidors públics, que permeten aproximadament una petició per segon: prou per a un dia, just per a un viatge llarg. Té efecte després de reiniciar el servidor.',
|
||||
'settings.mapHint': "Plantilla d'URL per als mosaics del mapa",
|
||||
'settings.mapProvider': 'Proveïdor de mapa',
|
||||
'settings.mapProviderHint': 'Afecta els mapes de Trip Planner i Journey. Atles sempre utilitza Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Jídlo',
|
||||
'roadtrip.poi.sights': 'Zajímavosti',
|
||||
'roadtrip.poi.within': 'Do',
|
||||
'roadtrip.poi.filter': 'Filtrovat podle názvu',
|
||||
'roadtrip.poi.noMatch': 'Na trase nic neodpovídá „{name}“.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} na trase',
|
||||
'roadtrip.poi.truncated': 'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.',
|
||||
'roadtrip.poi.search': 'Hledat podél trasy',
|
||||
'roadtrip.poi.searching': 'Hledání {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa je dlouhá — prohledán byl jen první úsek.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'po {distance}',
|
||||
'roadtrip.summary.distance': 'Vzdálenost',
|
||||
'roadtrip.summary.driving': 'Doba jízdy',
|
||||
'roadtrip.alt.ask': 'Jiné trasy',
|
||||
'roadtrip.alt.title': 'Trasy tohoto úseku',
|
||||
'roadtrip.alt.loading': 'Dotaz na směrovací server…',
|
||||
'roadtrip.alt.onlyOne': 'Pro tento úsek existuje jen jedna rozumná trasa.',
|
||||
'roadtrip.alt.failed': 'Směrovací server teď neodpovídá.',
|
||||
'roadtrip.alt.current': 'Aktuální',
|
||||
'roadtrip.alt.fastest': 'Nejrychlejší',
|
||||
'roadtrip.alt.slower': 'o {time} déle',
|
||||
'roadtrip.alt.quicker': 'o {time} rychleji',
|
||||
'roadtrip.day': 'Den {number}',
|
||||
'roadtrip.quietDay.empty': 'Zatím žádné zastávky — přetáhněte sem',
|
||||
'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku',
|
||||
'roadtrip.leg.driveText': '{distance} za {time}',
|
||||
'roadtrip.day.stopCount': 'zastávek: {count}',
|
||||
'roadtrip.stop.stayShort': 'Pobyt',
|
||||
'roadtrip.leg.pending': 'Bez trasy',
|
||||
'roadtrip.stop.addTitle': 'Přidat jako zastávku',
|
||||
'roadtrip.stop.kind': 'Druh zastávky',
|
||||
'roadtrip.stop.landsOn': '{day}. den, jako {position}. zastávka',
|
||||
'roadtrip.stop.duplicate': '„{name}“ už v této cestě je.',
|
||||
'roadtrip.stop.moreDetails': 'Více podrobností',
|
||||
'roadtrip.stop.stay': 'Čas na této zastávce',
|
||||
'roadtrip.stop.pinned': 'Čas, který jste nastavili',
|
||||
'roadtrip.stop.computed': 'Vypočteno z doby jízdy',
|
||||
'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete',
|
||||
'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase',
|
||||
'roadtrip.warn.overnight': 'Následující den',
|
||||
'roadtrip.summary.stops': 'Zastávky',
|
||||
'roadtrip.stay.add': 'Nastavit dobu zastavky',
|
||||
'roadtrip.stay.none': 'Bez zastavky',
|
||||
'roadtrip.stay.custom': 'Minuty',
|
||||
'roadtrip.stay.arrive': 'Prijezd',
|
||||
'roadtrip.stay.leave': 'Odjezd',
|
||||
'roadtrip.alt.noMotorway': 'Bez dalnice',
|
||||
'roadtrip.alt.noToll': 'Bez mytneho',
|
||||
'roadtrip.summary.partial': 'Zbytek trasy se ještě počítá',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Šablona mapy',
|
||||
'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...',
|
||||
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
|
||||
'settings.routingBase': 'Vlastní směrovací server',
|
||||
'settings.routingBaseHint': 'Vlastní instance OSRM. Prázdné použije veřejné servery s limitem asi jednoho požadavku za sekundu — na den to stačí, na road trip je to málo. Projeví se po restartu serveru.',
|
||||
'settings.mapHint': 'URL šablony pro mapové dlaždice',
|
||||
'settings.mapProvider': 'Poskytovatel mapy',
|
||||
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Essen',
|
||||
'roadtrip.poi.sights': 'Sehenswertes',
|
||||
'roadtrip.poi.within': 'Im Umkreis',
|
||||
'roadtrip.poi.filter': 'Nach Name filtern',
|
||||
'roadtrip.poi.noMatch': 'Unterwegs passt nichts zu „{name}“.',
|
||||
'roadtrip.poi.foundFiltered': '{count} von {total} unterwegs',
|
||||
'roadtrip.poi.truncated': 'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.',
|
||||
'roadtrip.poi.search': 'Entlang der Route suchen',
|
||||
'roadtrip.poi.searching': 'Suche {done} von {total}',
|
||||
'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'nach {distance}',
|
||||
'roadtrip.summary.distance': 'Strecke',
|
||||
'roadtrip.summary.driving': 'Fahrzeit',
|
||||
'roadtrip.alt.ask': 'Andere Wege',
|
||||
'roadtrip.alt.title': 'Wege für diese Etappe',
|
||||
'roadtrip.alt.loading': 'Der Router wird gefragt…',
|
||||
'roadtrip.alt.onlyOne': 'Für diese Etappe gibt es nur einen sinnvollen Weg.',
|
||||
'roadtrip.alt.failed': 'Der Router antwortet gerade nicht.',
|
||||
'roadtrip.alt.current': 'Aktuell',
|
||||
'roadtrip.alt.fastest': 'Schnellste',
|
||||
'roadtrip.alt.slower': '{time} länger',
|
||||
'roadtrip.alt.quicker': '{time} kürzer',
|
||||
'roadtrip.day': 'Tag {number}',
|
||||
'roadtrip.quietDay.empty': 'Noch keine Stopps — hier ablegen',
|
||||
'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen',
|
||||
'roadtrip.leg.driveText': '{distance} in {time}',
|
||||
'roadtrip.day.stopCount': '{count} Stopps',
|
||||
'roadtrip.stop.stayShort': 'Dauer',
|
||||
'roadtrip.leg.pending': 'Keine Route',
|
||||
'roadtrip.stop.addTitle': 'Als Stopp hinzufügen',
|
||||
'roadtrip.stop.kind': 'Art des Stopps',
|
||||
'roadtrip.stop.landsOn': 'Tag {day}, als Stopp {position}',
|
||||
'roadtrip.stop.duplicate': '„{name}“ ist auf dieser Reise schon vorhanden.',
|
||||
'roadtrip.stop.moreDetails': 'Mehr Details',
|
||||
'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
|
||||
'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
|
||||
'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
|
||||
'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt',
|
||||
'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
|
||||
'roadtrip.warn.overnight': 'Folgetag',
|
||||
'roadtrip.summary.stops': 'Stopps',
|
||||
'roadtrip.stay.add': 'Aufenthalt festlegen',
|
||||
'roadtrip.stay.none': 'Kein Aufenthalt',
|
||||
'roadtrip.stay.custom': 'Minuten',
|
||||
'roadtrip.stay.arrive': 'Ankunft',
|
||||
'roadtrip.stay.leave': 'Abfahrt',
|
||||
'roadtrip.alt.noMotorway': 'Ohne Autobahn',
|
||||
'roadtrip.alt.noToll': 'Ohne Maut',
|
||||
'roadtrip.summary.partial': 'Der Rest der Strecke wird noch berechnet',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Karten-Vorlage',
|
||||
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
|
||||
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
||||
'settings.routingBase': 'Eigene Routing-Instanz',
|
||||
'settings.routingBaseHint': 'Eine eigene OSRM-Instanz. Leer nutzt die öffentlichen Server, die etwa eine Anfrage pro Sekunde erlauben — für einen Tag genug, für einen Roadtrip knapp. Wirkt erst nach einem Serverneustart.',
|
||||
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
||||
'settings.mapProvider': 'Kartenanbieter',
|
||||
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Food',
|
||||
'roadtrip.poi.sights': 'Sights',
|
||||
'roadtrip.poi.within': 'Within',
|
||||
'roadtrip.poi.filter': 'Filter by name',
|
||||
'roadtrip.poi.noMatch': 'Nothing on the way matches “{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} of {total} on the way',
|
||||
'roadtrip.poi.truncated': '{count} stretches had more than fits in one answer — narrow the corridor to see the rest.',
|
||||
'roadtrip.poi.search': 'Search along the route',
|
||||
'roadtrip.poi.searching': 'Searching {done} of {total}',
|
||||
'roadtrip.poi.capped': 'The route is long — only the first stretch was searched.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'after {distance}',
|
||||
'roadtrip.summary.distance': 'Distance',
|
||||
'roadtrip.summary.driving': 'Driving time',
|
||||
'roadtrip.alt.ask': 'Other ways',
|
||||
'roadtrip.alt.title': 'Ways to drive this leg',
|
||||
'roadtrip.alt.loading': 'Asking the router…',
|
||||
'roadtrip.alt.onlyOne': 'Only one sensible way to drive this one.',
|
||||
'roadtrip.alt.failed': 'The router is not answering right now.',
|
||||
'roadtrip.alt.current': 'Current',
|
||||
'roadtrip.alt.fastest': 'Fastest',
|
||||
'roadtrip.alt.slower': '{time} slower',
|
||||
'roadtrip.alt.quicker': '{time} quicker',
|
||||
'roadtrip.day': 'Day {number}',
|
||||
'roadtrip.quietDay.empty': 'No stops yet — drop one here',
|
||||
'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here',
|
||||
'roadtrip.leg.driveText': '{distance} in {time}',
|
||||
'roadtrip.day.stopCount': '{count} stops',
|
||||
'roadtrip.stop.stayShort': 'Stay',
|
||||
'roadtrip.leg.pending': 'No route',
|
||||
'roadtrip.stop.addTitle': 'Add as a stop',
|
||||
'roadtrip.stop.kind': 'Kind of stop',
|
||||
'roadtrip.stop.landsOn': 'Day {day}, as stop {position}',
|
||||
'roadtrip.stop.duplicate': '“{name}” is already on this trip.',
|
||||
'roadtrip.stop.moreDetails': 'More details',
|
||||
'roadtrip.stop.stay': 'Time at this stop',
|
||||
'roadtrip.stop.pinned': 'Time you set',
|
||||
'roadtrip.stop.computed': 'Calculated from the drive',
|
||||
'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove',
|
||||
'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
|
||||
'roadtrip.warn.overnight': 'Next day',
|
||||
'roadtrip.summary.stops': 'Stops',
|
||||
'roadtrip.stay.add': 'Add a stay',
|
||||
'roadtrip.stay.none': 'No stay',
|
||||
'roadtrip.stay.custom': 'Minutes',
|
||||
'roadtrip.stay.arrive': 'Arrive',
|
||||
'roadtrip.stay.leave': 'Leave',
|
||||
'roadtrip.alt.noMotorway': 'No motorway',
|
||||
'roadtrip.alt.noToll': 'No tolls',
|
||||
'roadtrip.summary.partial': 'Still working out the rest of the drive',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -33,6 +33,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Map Template',
|
||||
'settings.mapTemplatePlaceholder.select': 'Select template...',
|
||||
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
|
||||
'settings.routingBase': 'Own routing engine',
|
||||
'settings.routingBaseHint': 'An OSRM instance of your own. Empty uses the public servers, which allow about one request a second — enough for a day, tight for a road trip. Takes effect after a server restart.',
|
||||
'settings.mapHint': 'URL template for map tiles',
|
||||
'settings.mapProvider': 'Map Provider',
|
||||
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
'roadtrip.poi.sights': 'Lugares de interés',
|
||||
'roadtrip.poi.within': 'En un radio de',
|
||||
'roadtrip.poi.filter': 'Filtrar por nombre',
|
||||
'roadtrip.poi.noMatch': 'Nada en la ruta coincide con «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} en la ruta',
|
||||
'roadtrip.poi.truncated': 'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.',
|
||||
'roadtrip.poi.search': 'Buscar a lo largo de la ruta',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta es larga: solo se buscó en el primer tramo.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'tras {distance}',
|
||||
'roadtrip.summary.distance': 'Distancia',
|
||||
'roadtrip.summary.driving': 'Tiempo de conducción',
|
||||
'roadtrip.alt.ask': 'Otras rutas',
|
||||
'roadtrip.alt.title': 'Rutas para este tramo',
|
||||
'roadtrip.alt.loading': 'Consultando al enrutador…',
|
||||
'roadtrip.alt.onlyOne': 'Solo hay una forma razonable de hacer este tramo.',
|
||||
'roadtrip.alt.failed': 'El enrutador no responde ahora mismo.',
|
||||
'roadtrip.alt.current': 'Actual',
|
||||
'roadtrip.alt.fastest': 'La más rápida',
|
||||
'roadtrip.alt.slower': '{time} más',
|
||||
'roadtrip.alt.quicker': '{time} menos',
|
||||
'roadtrip.day': 'Día {number}',
|
||||
'roadtrip.quietDay.empty': 'Sin paradas todavía: suelta una aquí',
|
||||
'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí',
|
||||
'roadtrip.leg.driveText': '{distance} en {time}',
|
||||
'roadtrip.day.stopCount': '{count} paradas',
|
||||
'roadtrip.stop.stayShort': 'Parada',
|
||||
'roadtrip.leg.pending': 'Sin ruta',
|
||||
'roadtrip.stop.addTitle': 'Añadir como parada',
|
||||
'roadtrip.stop.kind': 'Tipo de parada',
|
||||
'roadtrip.stop.landsOn': 'Día {day}, como parada {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» ya está en este viaje.',
|
||||
'roadtrip.stop.moreDetails': 'Más detalles',
|
||||
'roadtrip.stop.stay': 'Tiempo en la parada',
|
||||
'roadtrip.stop.pinned': 'Hora fijada por ti',
|
||||
'roadtrip.stop.computed': 'Calculado a partir del trayecto',
|
||||
'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar',
|
||||
'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
|
||||
'roadtrip.warn.overnight': 'Día siguiente',
|
||||
'roadtrip.summary.stops': 'Paradas',
|
||||
'roadtrip.stay.add': 'Anadir una parada',
|
||||
'roadtrip.stay.none': 'Sin parada',
|
||||
'roadtrip.stay.custom': 'Minutos',
|
||||
'roadtrip.stay.arrive': 'Llegada',
|
||||
'roadtrip.stay.leave': 'Salida',
|
||||
'roadtrip.alt.noMotorway': 'Sin autopista',
|
||||
'roadtrip.alt.noToll': 'Sin peajes',
|
||||
'roadtrip.summary.partial': 'Aún se está calculando el resto del trayecto',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Plantilla del mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
|
||||
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
|
||||
'settings.routingBase': 'Motor de rutas propio',
|
||||
'settings.routingBaseHint': 'Una instancia propia de OSRM. Vacío usa los servidores públicos, que permiten cerca de una petición por segundo: suficiente para un día, justo para una ruta larga. Surte efecto tras reiniciar el servidor.',
|
||||
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
|
||||
'settings.mapProvider': 'Proveedor de mapa',
|
||||
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Restauration',
|
||||
'roadtrip.poi.sights': 'À voir',
|
||||
'roadtrip.poi.within': 'Dans un rayon de',
|
||||
'roadtrip.poi.filter': 'Filtrer par nom',
|
||||
'roadtrip.poi.noMatch': 'Rien sur le trajet ne correspond à « {name} ».',
|
||||
'roadtrip.poi.foundFiltered': '{count} sur {total} sur le trajet',
|
||||
'roadtrip.poi.truncated': 'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.',
|
||||
'roadtrip.poi.search': 'Chercher le long du trajet',
|
||||
'roadtrip.poi.searching': 'Recherche {done} sur {total}',
|
||||
'roadtrip.poi.capped': 'L’itinéraire est long : seule la première portion a été parcourue.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'après {distance}',
|
||||
'roadtrip.summary.distance': 'Distance',
|
||||
'roadtrip.summary.driving': 'Temps de conduite',
|
||||
'roadtrip.alt.ask': 'Autres itinéraires',
|
||||
'roadtrip.alt.title': 'Itinéraires pour cette étape',
|
||||
'roadtrip.alt.loading': 'Interrogation du routeur…',
|
||||
'roadtrip.alt.onlyOne': 'Il n’y a qu’un itinéraire raisonnable pour cette étape.',
|
||||
'roadtrip.alt.failed': 'Le routeur ne répond pas pour le moment.',
|
||||
'roadtrip.alt.current': 'Actuel',
|
||||
'roadtrip.alt.fastest': 'La plus rapide',
|
||||
'roadtrip.alt.slower': '{time} de plus',
|
||||
'roadtrip.alt.quicker': '{time} de moins',
|
||||
'roadtrip.day': 'Jour {number}',
|
||||
'roadtrip.quietDay.empty': 'Aucun arrêt — déposez-en un ici',
|
||||
'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici',
|
||||
'roadtrip.leg.driveText': '{distance} en {time}',
|
||||
'roadtrip.day.stopCount': '{count} arrêts',
|
||||
'roadtrip.stop.stayShort': 'Durée',
|
||||
'roadtrip.leg.pending': 'Pas d’itinéraire',
|
||||
'roadtrip.stop.addTitle': 'Ajouter comme arrêt',
|
||||
'roadtrip.stop.kind': 'Type d’arrêt',
|
||||
'roadtrip.stop.landsOn': 'Jour {day}, en tant qu’arrêt {position}',
|
||||
'roadtrip.stop.duplicate': '« {name} » figure déjà dans ce voyage.',
|
||||
'roadtrip.stop.moreDetails': 'Plus de détails',
|
||||
'roadtrip.stop.stay': 'Temps sur place',
|
||||
'roadtrip.stop.pinned': 'Heure que vous avez fixée',
|
||||
'roadtrip.stop.computed': 'Calculé d’après le trajet',
|
||||
'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer',
|
||||
'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée',
|
||||
'roadtrip.warn.overnight': 'Jour suivant',
|
||||
'roadtrip.summary.stops': 'Étapes',
|
||||
'roadtrip.stay.add': 'Definir une duree',
|
||||
'roadtrip.stay.none': 'Aucune duree',
|
||||
'roadtrip.stay.custom': 'Minutes',
|
||||
'roadtrip.stay.arrive': 'Arrivee',
|
||||
'roadtrip.stay.leave': 'Depart',
|
||||
'roadtrip.alt.noMotorway': 'Sans autoroute',
|
||||
'roadtrip.alt.noToll': 'Sans peage',
|
||||
'roadtrip.summary.partial': 'Le reste du trajet est encore en cours de calcul',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Modèle de carte',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
|
||||
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
|
||||
'settings.routingBase': 'Moteur de calcul d’itinéraire propre',
|
||||
'settings.routingBaseHint': 'Votre propre instance OSRM. Vide utilise les serveurs publics, limités à environ une requête par seconde : suffisant pour une journée, juste pour un road trip. Prend effet après un redémarrage du serveur.',
|
||||
'settings.mapHint': "Modèle d'URL pour les tuiles de carte",
|
||||
'settings.mapProvider': 'Fournisseur de carte',
|
||||
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Φαγητό',
|
||||
'roadtrip.poi.sights': 'Αξιοθέατα',
|
||||
'roadtrip.poi.within': 'Εντός',
|
||||
'roadtrip.poi.filter': 'Φιλτράρισμα κατά όνομα',
|
||||
'roadtrip.poi.noMatch': 'Τίποτα στη διαδρομή δεν ταιριάζει με «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} από {total} στη διαδρομή',
|
||||
'roadtrip.poi.truncated': 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.',
|
||||
'roadtrip.poi.search': 'Αναζήτηση κατά μήκος της διαδρομής',
|
||||
'roadtrip.poi.searching': 'Αναζήτηση {done} από {total}',
|
||||
'roadtrip.poi.capped': 'Η διαδρομή είναι μεγάλη — αναζητήθηκε μόνο το πρώτο τμήμα.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'μετά από {distance}',
|
||||
'roadtrip.summary.distance': 'Απόσταση',
|
||||
'roadtrip.summary.driving': 'Χρόνος οδήγησης',
|
||||
'roadtrip.alt.ask': 'Άλλες διαδρομές',
|
||||
'roadtrip.alt.title': 'Διαδρομές για αυτό το σκέλος',
|
||||
'roadtrip.alt.loading': 'Ερώτημα στον δρομολογητή…',
|
||||
'roadtrip.alt.onlyOne': 'Υπάρχει μόνο μία λογική διαδρομή για αυτό το σκέλος.',
|
||||
'roadtrip.alt.failed': 'Ο δρομολογητής δεν απαντά αυτή τη στιγμή.',
|
||||
'roadtrip.alt.current': 'Τρέχουσα',
|
||||
'roadtrip.alt.fastest': 'Η ταχύτερη',
|
||||
'roadtrip.alt.slower': '{time} πιο αργή',
|
||||
'roadtrip.alt.quicker': '{time} πιο γρήγορη',
|
||||
'roadtrip.day': 'Ημέρα {number}',
|
||||
'roadtrip.quietDay.empty': 'Καμία στάση ακόμη — αφήστε μία εδώ',
|
||||
'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ',
|
||||
'roadtrip.leg.driveText': '{distance} σε {time}',
|
||||
'roadtrip.day.stopCount': '{count} στάσεις',
|
||||
'roadtrip.stop.stayShort': 'Στάση',
|
||||
'roadtrip.leg.pending': 'Χωρίς διαδρομή',
|
||||
'roadtrip.stop.addTitle': 'Προσθήκη ως στάση',
|
||||
'roadtrip.stop.kind': 'Είδος στάσης',
|
||||
'roadtrip.stop.landsOn': 'Ημέρα {day}, ως στάση {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» υπάρχει ήδη σε αυτό το ταξίδι.',
|
||||
'roadtrip.stop.moreDetails': 'Περισσότερες λεπτομέρειες',
|
||||
'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
|
||||
'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
|
||||
'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
|
||||
'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση',
|
||||
'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
|
||||
'roadtrip.warn.overnight': 'Επόμενη μέρα',
|
||||
'roadtrip.summary.stops': 'Στάσεις',
|
||||
'roadtrip.stay.add': 'Ορισμος διαρκειας σταθμευσης',
|
||||
'roadtrip.stay.none': 'Χωρις σταση',
|
||||
'roadtrip.stay.custom': 'Λεπτα',
|
||||
'roadtrip.stay.arrive': 'Αφιξη',
|
||||
'roadtrip.stay.leave': 'Αναχωρηση',
|
||||
'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο',
|
||||
'roadtrip.alt.noToll': 'Χωρις διοδια',
|
||||
'roadtrip.summary.partial': 'Υπολογίζεται ακόμη η υπόλοιπη διαδρομή',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Πρότυπο Χάρτη',
|
||||
'settings.mapTemplatePlaceholder.select': 'Επιλέξτε πρότυπο...',
|
||||
'settings.mapDefaultHint': 'Αφήστε κενό για OpenStreetMap (προεπιλογή)',
|
||||
'settings.routingBase': 'Δική σας μηχανή δρομολόγησης',
|
||||
'settings.routingBaseHint': 'Δική σας εγκατάσταση OSRM. Κενό σημαίνει τους δημόσιους διακομιστές, που επιτρέπουν περίπου ένα αίτημα ανά δευτερόλεπτο: αρκεί για μία ημέρα, λίγο για ένα οδικό ταξίδι. Ισχύει μετά από επανεκκίνηση του διακομιστή.',
|
||||
'settings.mapHint': 'Πρότυπο URL για πλακίδια χάρτη',
|
||||
'settings.mapProvider': 'Πάροχος Χάρτη',
|
||||
'settings.mapProviderHint':
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Étkezés',
|
||||
'roadtrip.poi.sights': 'Látnivalók',
|
||||
'roadtrip.poi.within': 'Ezen belül',
|
||||
'roadtrip.poi.filter': 'Szűrés név szerint',
|
||||
'roadtrip.poi.noMatch': 'Az útvonalon semmi sem egyezik ezzel: „{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} / {total} az útvonalon',
|
||||
'roadtrip.poi.truncated': '{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.',
|
||||
'roadtrip.poi.search': 'Keresés az útvonal mentén',
|
||||
'roadtrip.poi.searching': 'Keresés {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Az útvonal hosszú – csak az első szakaszon kerestünk.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': '{distance} után',
|
||||
'roadtrip.summary.distance': 'Távolság',
|
||||
'roadtrip.summary.driving': 'Vezetési idő',
|
||||
'roadtrip.alt.ask': 'Más útvonalak',
|
||||
'roadtrip.alt.title': 'Útvonalak ehhez a szakaszhoz',
|
||||
'roadtrip.alt.loading': 'Az útvonaltervező lekérdezése…',
|
||||
'roadtrip.alt.onlyOne': 'Ehhez a szakaszhoz csak egy értelmes útvonal van.',
|
||||
'roadtrip.alt.failed': 'Az útvonaltervező most nem válaszol.',
|
||||
'roadtrip.alt.current': 'Jelenlegi',
|
||||
'roadtrip.alt.fastest': 'Leggyorsabb',
|
||||
'roadtrip.alt.slower': '{time}-cel lassabb',
|
||||
'roadtrip.alt.quicker': '{time}-cel gyorsabb',
|
||||
'roadtrip.day': '{number}. nap',
|
||||
'roadtrip.quietDay.empty': 'Még nincs megálló — húzzon ide egyet',
|
||||
'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót',
|
||||
'roadtrip.leg.driveText': '{distance} {time} alatt',
|
||||
'roadtrip.day.stopCount': '{count} megálló',
|
||||
'roadtrip.stop.stayShort': 'Idő',
|
||||
'roadtrip.leg.pending': 'Nincs útvonal',
|
||||
'roadtrip.stop.addTitle': 'Hozzáadás megállóként',
|
||||
'roadtrip.stop.kind': 'Megálló típusa',
|
||||
'roadtrip.stop.landsOn': '{day}. nap, {position}. megállóként',
|
||||
'roadtrip.stop.duplicate': '„{name}” már szerepel ebben az utazásban.',
|
||||
'roadtrip.stop.moreDetails': 'További részletek',
|
||||
'roadtrip.stop.stay': 'Idő ezen a megállón',
|
||||
'roadtrip.stop.pinned': 'Az általad megadott időpont',
|
||||
'roadtrip.stop.computed': 'A menetidőből számolva',
|
||||
'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható',
|
||||
'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
|
||||
'roadtrip.warn.overnight': 'Következő nap',
|
||||
'roadtrip.summary.stops': 'Megállók',
|
||||
'roadtrip.stay.add': 'Tartozkodas beallitasa',
|
||||
'roadtrip.stay.none': 'Nincs tartozkodas',
|
||||
'roadtrip.stay.custom': 'Perc',
|
||||
'roadtrip.stay.arrive': 'Erkezes',
|
||||
'roadtrip.stay.leave': 'Indulas',
|
||||
'roadtrip.alt.noMotorway': 'Autopalya nelkul',
|
||||
'roadtrip.alt.noToll': 'Dijmentes',
|
||||
'roadtrip.summary.partial': 'A hátralévő útszakasz számítása még folyamatban',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Térkép sablon',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
|
||||
'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
|
||||
'settings.routingBase': 'Saját útvonaltervező',
|
||||
'settings.routingBaseHint': 'Saját OSRM-példány. Üresen hagyva a nyilvános kiszolgálókat használja, amelyek másodpercenként körülbelül egy kérést engednek — egy napra elég, egy autós útra kevés. A kiszolgáló újraindítása után lép életbe.',
|
||||
'settings.mapHint': 'URL sablon a térképcsempékhez',
|
||||
'settings.mapProvider': 'Térkép szolgáltató',
|
||||
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Makanan',
|
||||
'roadtrip.poi.sights': 'Objek wisata',
|
||||
'roadtrip.poi.within': 'Dalam',
|
||||
'roadtrip.poi.filter': 'Saring menurut nama',
|
||||
'roadtrip.poi.noMatch': 'Tidak ada di rute yang cocok dengan “{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} dari {total} di rute',
|
||||
'roadtrip.poi.truncated': 'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.',
|
||||
'roadtrip.poi.search': 'Cari di sepanjang rute',
|
||||
'roadtrip.poi.searching': 'Mencari {done} dari {total}',
|
||||
'roadtrip.poi.capped': 'Rute panjang — hanya bagian pertama yang dicari.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'setelah {distance}',
|
||||
'roadtrip.summary.distance': 'Jarak',
|
||||
'roadtrip.summary.driving': 'Waktu berkendara',
|
||||
'roadtrip.alt.ask': 'Rute lain',
|
||||
'roadtrip.alt.title': 'Rute untuk ruas ini',
|
||||
'roadtrip.alt.loading': 'Menanyakan server rute…',
|
||||
'roadtrip.alt.onlyOne': 'Hanya ada satu rute yang masuk akal untuk ruas ini.',
|
||||
'roadtrip.alt.failed': 'Server rute sedang tidak merespons.',
|
||||
'roadtrip.alt.current': 'Saat ini',
|
||||
'roadtrip.alt.fastest': 'Tercepat',
|
||||
'roadtrip.alt.slower': '{time} lebih lama',
|
||||
'roadtrip.alt.quicker': '{time} lebih cepat',
|
||||
'roadtrip.day': 'Hari {number}',
|
||||
'roadtrip.quietDay.empty': 'Belum ada perhentian — jatuhkan di sini',
|
||||
'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini',
|
||||
'roadtrip.leg.driveText': '{distance} dalam {time}',
|
||||
'roadtrip.day.stopCount': '{count} perhentian',
|
||||
'roadtrip.stop.stayShort': 'Singgah',
|
||||
'roadtrip.leg.pending': 'Tidak ada rute',
|
||||
'roadtrip.stop.addTitle': 'Tambahkan sebagai perhentian',
|
||||
'roadtrip.stop.kind': 'Jenis perhentian',
|
||||
'roadtrip.stop.landsOn': 'Hari {day}, sebagai perhentian {position}',
|
||||
'roadtrip.stop.duplicate': '“{name}” sudah ada di perjalanan ini.',
|
||||
'roadtrip.stop.moreDetails': 'Detail lainnya',
|
||||
'roadtrip.stop.stay': 'Waktu di perhentian ini',
|
||||
'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
|
||||
'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
|
||||
'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus',
|
||||
'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
|
||||
'roadtrip.warn.overnight': 'Hari berikutnya',
|
||||
'roadtrip.summary.stops': 'Perhentian',
|
||||
'roadtrip.stay.add': 'Atur lama singgah',
|
||||
'roadtrip.stay.none': 'Tanpa singgah',
|
||||
'roadtrip.stay.custom': 'Menit',
|
||||
'roadtrip.stay.arrive': 'Tiba',
|
||||
'roadtrip.stay.leave': 'Berangkat',
|
||||
'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat',
|
||||
'roadtrip.alt.noToll': 'Tanpa tol',
|
||||
'roadtrip.summary.partial': 'Sisa perjalanan masih dihitung',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Template Peta',
|
||||
'settings.mapTemplatePlaceholder.select': 'Pilih template...',
|
||||
'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)',
|
||||
'settings.routingBase': 'Mesin rute sendiri',
|
||||
'settings.routingBaseHint': 'Instans OSRM milik sendiri. Kosong berarti server publik, yang mengizinkan sekitar satu permintaan per detik — cukup untuk sehari, mepet untuk perjalanan darat. Berlaku setelah server dimulai ulang.',
|
||||
'settings.mapHint': 'Template URL untuk tile peta',
|
||||
'settings.mapProvider': 'Penyedia peta',
|
||||
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Ristoro',
|
||||
'roadtrip.poi.sights': 'Da vedere',
|
||||
'roadtrip.poi.within': 'Entro',
|
||||
'roadtrip.poi.filter': 'Filtra per nome',
|
||||
'roadtrip.poi.noMatch': 'Lungo il percorso non c’è nulla che corrisponda a “{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} di {total} lungo il percorso',
|
||||
'roadtrip.poi.truncated': 'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.',
|
||||
'roadtrip.poi.search': 'Cerca lungo il percorso',
|
||||
'roadtrip.poi.searching': 'Ricerca {done} di {total}',
|
||||
'roadtrip.poi.capped': 'Il percorso è lungo: è stato cercato solo il primo tratto.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'dopo {distance}',
|
||||
'roadtrip.summary.distance': 'Distanza',
|
||||
'roadtrip.summary.driving': 'Tempo di guida',
|
||||
'roadtrip.alt.ask': 'Altri percorsi',
|
||||
'roadtrip.alt.title': 'Percorsi per questa tratta',
|
||||
'roadtrip.alt.loading': 'Interrogazione del router…',
|
||||
'roadtrip.alt.onlyOne': 'Per questa tratta c’è un solo percorso sensato.',
|
||||
'roadtrip.alt.failed': 'Il router non risponde al momento.',
|
||||
'roadtrip.alt.current': 'Attuale',
|
||||
'roadtrip.alt.fastest': 'La più veloce',
|
||||
'roadtrip.alt.slower': '{time} in più',
|
||||
'roadtrip.alt.quicker': '{time} in meno',
|
||||
'roadtrip.day': 'Giorno {number}',
|
||||
'roadtrip.quietDay.empty': 'Ancora nessuna sosta: trascinane una qui',
|
||||
'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta',
|
||||
'roadtrip.leg.driveText': '{distance} in {time}',
|
||||
'roadtrip.day.stopCount': '{count} soste',
|
||||
'roadtrip.stop.stayShort': 'Sosta',
|
||||
'roadtrip.leg.pending': 'Nessun percorso',
|
||||
'roadtrip.stop.addTitle': 'Aggiungi come sosta',
|
||||
'roadtrip.stop.kind': 'Tipo di sosta',
|
||||
'roadtrip.stop.landsOn': 'Giorno {day}, come sosta {position}',
|
||||
'roadtrip.stop.duplicate': '“{name}” è già presente in questo viaggio.',
|
||||
'roadtrip.stop.moreDetails': 'Altri dettagli',
|
||||
'roadtrip.stop.stay': 'Tempo alla sosta',
|
||||
'roadtrip.stop.pinned': 'Orario impostato da te',
|
||||
'roadtrip.stop.computed': 'Calcolato dal tragitto',
|
||||
'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere',
|
||||
'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
|
||||
'roadtrip.warn.overnight': 'Giorno successivo',
|
||||
'roadtrip.summary.stops': 'Soste',
|
||||
'roadtrip.stay.add': 'Imposta una sosta',
|
||||
'roadtrip.stay.none': 'Nessuna sosta',
|
||||
'roadtrip.stay.custom': 'Minuti',
|
||||
'roadtrip.stay.arrive': 'Arrivo',
|
||||
'roadtrip.stay.leave': 'Partenza',
|
||||
'roadtrip.alt.noMotorway': 'Senza autostrada',
|
||||
'roadtrip.alt.noToll': 'Senza pedaggi',
|
||||
'roadtrip.summary.partial': 'Il resto del percorso è ancora in calcolo',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Modello Mappa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Seleziona modello...',
|
||||
'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
|
||||
'settings.routingBase': 'Motore di percorso proprio',
|
||||
'settings.routingBaseHint': 'Una tua istanza OSRM. Vuoto usa i server pubblici, che consentono circa una richiesta al secondo: sufficiente per un giorno, poco per un viaggio in auto. Ha effetto dopo il riavvio del server.',
|
||||
'settings.mapHint': 'Modello URL per i tile della mappa',
|
||||
'settings.mapProvider': 'Provider mappa',
|
||||
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': '食事',
|
||||
'roadtrip.poi.sights': '見どころ',
|
||||
'roadtrip.poi.within': '範囲',
|
||||
'roadtrip.poi.filter': '名前で絞り込む',
|
||||
'roadtrip.poi.noMatch': 'ルート沿いに「{name}」に一致するものはありません。',
|
||||
'roadtrip.poi.foundFiltered': 'ルート沿いの {total} 件中 {count} 件',
|
||||
'roadtrip.poi.truncated': '{count} 区間で一度に返せる件数を超えました。回廊を狭めると残りを確認できます。',
|
||||
'roadtrip.poi.search': 'ルート沿いを検索',
|
||||
'roadtrip.poi.searching': '検索中 {done}/{total}',
|
||||
'roadtrip.poi.capped': 'ルートが長いため、最初の区間のみ検索しました。',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': '{distance} 地点',
|
||||
'roadtrip.summary.distance': '距離',
|
||||
'roadtrip.summary.driving': '運転時間',
|
||||
'roadtrip.alt.ask': '別のルート',
|
||||
'roadtrip.alt.title': 'この区間の走り方',
|
||||
'roadtrip.alt.loading': 'ルーティングサーバーに問い合わせています…',
|
||||
'roadtrip.alt.onlyOne': 'この区間に妥当なルートは 1 つだけです。',
|
||||
'roadtrip.alt.failed': 'ルーティングサーバーが応答していません。',
|
||||
'roadtrip.alt.current': '現在',
|
||||
'roadtrip.alt.fastest': '最速',
|
||||
'roadtrip.alt.slower': '{time} 遅い',
|
||||
'roadtrip.alt.quicker': '{time} 速い',
|
||||
'roadtrip.day': '{number} 日目',
|
||||
'roadtrip.quietDay.empty': 'まだ立ち寄り先がありません。ここにドロップ',
|
||||
'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ',
|
||||
'roadtrip.leg.driveText': '{distance}/{time}',
|
||||
'roadtrip.day.stopCount': '{count} 箇所',
|
||||
'roadtrip.stop.stayShort': '滞在',
|
||||
'roadtrip.leg.pending': 'ルートなし',
|
||||
'roadtrip.stop.addTitle': '立ち寄り先として追加',
|
||||
'roadtrip.stop.kind': '立ち寄りの種類',
|
||||
'roadtrip.stop.landsOn': '{day} 日目、{position} 番目の立ち寄り先',
|
||||
'roadtrip.stop.duplicate': '「{name}」はこの旅行にすでにあります。',
|
||||
'roadtrip.stop.moreDetails': '詳細を追加',
|
||||
'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
|
||||
'roadtrip.stop.pinned': '設定した時刻',
|
||||
'roadtrip.stop.computed': '運転時間からの計算値',
|
||||
'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除',
|
||||
'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
|
||||
'roadtrip.warn.overnight': '翌日',
|
||||
'roadtrip.summary.stops': '立ち寄り先',
|
||||
'roadtrip.stay.add': '滞在時間を設定',
|
||||
'roadtrip.stay.none': '滞在なし',
|
||||
'roadtrip.stay.custom': '分',
|
||||
'roadtrip.stay.arrive': '到着',
|
||||
'roadtrip.stay.leave': '出発',
|
||||
'roadtrip.alt.noMotorway': '高速道路なし',
|
||||
'roadtrip.alt.noToll': '有料道路なし',
|
||||
'roadtrip.summary.partial': '残りの行程を計算しています',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': '地図テンプレート',
|
||||
'settings.mapTemplatePlaceholder.select': 'テンプレートを選択…',
|
||||
'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用',
|
||||
'settings.routingBase': '自前のルーティングサーバー',
|
||||
'settings.routingBaseHint': '自前の OSRM インスタンス。空欄なら公開サーバーを使いますが、毎秒 1 リクエスト程度の制限があり、1 日分には足りてもロードトリップには足りません。サーバー再起動後に反映されます。',
|
||||
'settings.mapHint': '地図タイルのURLテンプレート',
|
||||
'settings.mapProvider': '地図プロバイダー',
|
||||
'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': '식사',
|
||||
'roadtrip.poi.sights': '볼거리',
|
||||
'roadtrip.poi.within': '반경',
|
||||
'roadtrip.poi.filter': '이름으로 필터',
|
||||
'roadtrip.poi.noMatch': '경로상에 “{name}”와(과) 일치하는 곳이 없습니다.',
|
||||
'roadtrip.poi.foundFiltered': '경로상 {total}곳 중 {count}곳',
|
||||
'roadtrip.poi.truncated': '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.',
|
||||
'roadtrip.poi.search': '경로 주변 검색',
|
||||
'roadtrip.poi.searching': '검색 중 {done}/{total}',
|
||||
'roadtrip.poi.capped': '경로가 길어 앞부분만 검색했습니다.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': '{distance} 지점',
|
||||
'roadtrip.summary.distance': '거리',
|
||||
'roadtrip.summary.driving': '운전 시간',
|
||||
'roadtrip.alt.ask': '다른 경로',
|
||||
'roadtrip.alt.title': '이 구간을 가는 방법',
|
||||
'roadtrip.alt.loading': '경로 서버에 묻는 중…',
|
||||
'roadtrip.alt.onlyOne': '이 구간에는 합리적인 경로가 하나뿐입니다.',
|
||||
'roadtrip.alt.failed': '경로 서버가 응답하지 않습니다.',
|
||||
'roadtrip.alt.current': '현재',
|
||||
'roadtrip.alt.fastest': '가장 빠름',
|
||||
'roadtrip.alt.slower': '{time} 더 걸림',
|
||||
'roadtrip.alt.quicker': '{time} 더 빠름',
|
||||
'roadtrip.day': '{number}일차',
|
||||
'roadtrip.quietDay.empty': '아직 경유지가 없습니다. 여기에 놓으세요',
|
||||
'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요',
|
||||
'roadtrip.leg.driveText': '{distance}, {time}',
|
||||
'roadtrip.day.stopCount': '{count}곳',
|
||||
'roadtrip.stop.stayShort': '체류',
|
||||
'roadtrip.leg.pending': '경로 없음',
|
||||
'roadtrip.stop.addTitle': '경유지로 추가',
|
||||
'roadtrip.stop.kind': '경유지 종류',
|
||||
'roadtrip.stop.landsOn': '{day}일차, {position}번째 경유지',
|
||||
'roadtrip.stop.duplicate': '“{name}”은(는) 이미 이 여행에 있습니다.',
|
||||
'roadtrip.stop.moreDetails': '자세히 입력',
|
||||
'roadtrip.stop.stay': '이 경유지 체류 시간',
|
||||
'roadtrip.stop.pinned': '직접 지정한 시간',
|
||||
'roadtrip.stop.computed': '주행 시간으로 계산됨',
|
||||
'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제',
|
||||
'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
|
||||
'roadtrip.warn.overnight': '다음 날',
|
||||
'roadtrip.summary.stops': '경유지',
|
||||
'roadtrip.stay.add': '체류 시간 설정',
|
||||
'roadtrip.stay.none': '체류 없음',
|
||||
'roadtrip.stay.custom': '분',
|
||||
'roadtrip.stay.arrive': '도착',
|
||||
'roadtrip.stay.leave': '출발',
|
||||
'roadtrip.alt.noMotorway': '고속도로 제외',
|
||||
'roadtrip.alt.noToll': '통행료 없음',
|
||||
'roadtrip.summary.partial': '남은 구간을 계산하고 있습니다',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': '지도 템플릿',
|
||||
'settings.mapTemplatePlaceholder.select': '템플릿 선택...',
|
||||
'settings.mapDefaultHint': '비워두면 OpenStreetMap (기본값) 사용',
|
||||
'settings.routingBase': '자체 경로 서버',
|
||||
'settings.routingBaseHint': '자체 OSRM 인스턴스입니다. 비워 두면 초당 약 한 번의 요청만 허용하는 공개 서버를 사용합니다. 하루치는 충분하지만 로드트립에는 빠듯합니다. 서버를 다시 시작해야 적용됩니다.',
|
||||
'settings.mapHint': '지도 타일 URL 템플릿',
|
||||
'settings.mapProvider': '지도 공급자',
|
||||
'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Eten',
|
||||
'roadtrip.poi.sights': 'Bezienswaardig',
|
||||
'roadtrip.poi.within': 'Binnen',
|
||||
'roadtrip.poi.filter': 'Filteren op naam',
|
||||
'roadtrip.poi.noMatch': 'Niets onderweg komt overeen met “{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} van {total} onderweg',
|
||||
'roadtrip.poi.truncated': 'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.',
|
||||
'roadtrip.poi.search': 'Langs de route zoeken',
|
||||
'roadtrip.poi.searching': 'Zoeken {done} van {total}',
|
||||
'roadtrip.poi.capped': 'De route is lang — alleen het eerste deel is doorzocht.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'na {distance}',
|
||||
'roadtrip.summary.distance': 'Afstand',
|
||||
'roadtrip.summary.driving': 'Rijtijd',
|
||||
'roadtrip.alt.ask': 'Andere routes',
|
||||
'roadtrip.alt.title': 'Routes voor dit traject',
|
||||
'roadtrip.alt.loading': 'Router wordt geraadpleegd…',
|
||||
'roadtrip.alt.onlyOne': 'Er is maar één zinnige route voor dit traject.',
|
||||
'roadtrip.alt.failed': 'De routeserver antwoordt op dit moment niet.',
|
||||
'roadtrip.alt.current': 'Huidig',
|
||||
'roadtrip.alt.fastest': 'Snelste',
|
||||
'roadtrip.alt.slower': '{time} langzamer',
|
||||
'roadtrip.alt.quicker': '{time} sneller',
|
||||
'roadtrip.day': 'Dag {number}',
|
||||
'roadtrip.quietDay.empty': 'Nog geen stops — laat er hier een vallen',
|
||||
'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen',
|
||||
'roadtrip.leg.driveText': '{distance} in {time}',
|
||||
'roadtrip.day.stopCount': '{count} stops',
|
||||
'roadtrip.stop.stayShort': 'Duur',
|
||||
'roadtrip.leg.pending': 'Geen route',
|
||||
'roadtrip.stop.addTitle': 'Als stop toevoegen',
|
||||
'roadtrip.stop.kind': 'Soort stop',
|
||||
'roadtrip.stop.landsOn': 'Dag {day}, als stop {position}',
|
||||
'roadtrip.stop.duplicate': '“{name}” staat al in deze reis.',
|
||||
'roadtrip.stop.moreDetails': 'Meer details',
|
||||
'roadtrip.stop.stay': 'Tijd bij deze stop',
|
||||
'roadtrip.stop.pinned': 'Door jou gezette tijd',
|
||||
'roadtrip.stop.computed': 'Berekend uit de rit',
|
||||
'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen',
|
||||
'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
|
||||
'roadtrip.warn.overnight': 'Volgende dag',
|
||||
'roadtrip.summary.stops': 'Stops',
|
||||
'roadtrip.stay.add': 'Verblijf instellen',
|
||||
'roadtrip.stay.none': 'Geen verblijf',
|
||||
'roadtrip.stay.custom': 'Minuten',
|
||||
'roadtrip.stay.arrive': 'Aankomst',
|
||||
'roadtrip.stay.leave': 'Vertrek',
|
||||
'roadtrip.alt.noMotorway': 'Zonder snelweg',
|
||||
'roadtrip.alt.noToll': 'Zonder tol',
|
||||
'roadtrip.summary.partial': 'De rest van de rit wordt nog berekend',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Kaartsjabloon',
|
||||
'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
|
||||
'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
|
||||
'settings.routingBase': 'Eigen routeserver',
|
||||
'settings.routingBaseHint': 'Een eigen OSRM-server. Leeg gebruikt de publieke servers, die ongeveer één verzoek per seconde toestaan — genoeg voor een dag, krap voor een roadtrip. Werkt na een herstart van de server.',
|
||||
'settings.mapHint': 'URL-sjabloon voor kaarttegels',
|
||||
'settings.mapProvider': 'Kaartprovider',
|
||||
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Jedzenie',
|
||||
'roadtrip.poi.sights': 'Atrakcje',
|
||||
'roadtrip.poi.within': 'W promieniu',
|
||||
'roadtrip.poi.filter': 'Filtruj wedug nazwy',
|
||||
'roadtrip.poi.noMatch': 'Nic po drodze nie pasuje do „{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} po drodze',
|
||||
'roadtrip.poi.truncated': 'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.',
|
||||
'roadtrip.poi.search': 'Szukaj wzdłuż trasy',
|
||||
'roadtrip.poi.searching': 'Szukanie {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa jest długa — przeszukano tylko pierwszy odcinek.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'po {distance}',
|
||||
'roadtrip.summary.distance': 'Dystans',
|
||||
'roadtrip.summary.driving': 'Czas jazdy',
|
||||
'roadtrip.alt.ask': 'Inne trasy',
|
||||
'roadtrip.alt.title': 'Trasy tego odcinka',
|
||||
'roadtrip.alt.loading': 'Pytanie serwera tras…',
|
||||
'roadtrip.alt.onlyOne': 'Ten odcinek ma tylko jedną sensowną trasę.',
|
||||
'roadtrip.alt.failed': 'Serwer tras teraz nie odpowiada.',
|
||||
'roadtrip.alt.current': 'Obecna',
|
||||
'roadtrip.alt.fastest': 'Najszybsza',
|
||||
'roadtrip.alt.slower': '{time} dłużej',
|
||||
'roadtrip.alt.quicker': '{time} krócej',
|
||||
'roadtrip.day': 'Dzień {number}',
|
||||
'roadtrip.quietDay.empty': 'Brak przystanków — upuść tutaj',
|
||||
'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek',
|
||||
'roadtrip.leg.driveText': '{distance} w {time}',
|
||||
'roadtrip.day.stopCount': '{count} przystanków',
|
||||
'roadtrip.stop.stayShort': 'Postój',
|
||||
'roadtrip.leg.pending': 'Brak trasy',
|
||||
'roadtrip.stop.addTitle': 'Dodaj jako przystanek',
|
||||
'roadtrip.stop.kind': 'Rodzaj przystanku',
|
||||
'roadtrip.stop.landsOn': 'Dzień {day}, jako przystanek {position}',
|
||||
'roadtrip.stop.duplicate': '„{name}” już jest w tej podróży.',
|
||||
'roadtrip.stop.moreDetails': 'Więcej szczegółów',
|
||||
'roadtrip.stop.stay': 'Czas na tym przystanku',
|
||||
'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
|
||||
'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
|
||||
'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć',
|
||||
'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
|
||||
'roadtrip.warn.overnight': 'Następny dzień',
|
||||
'roadtrip.summary.stops': 'Przystanki',
|
||||
'roadtrip.stay.add': 'Ustaw czas postoju',
|
||||
'roadtrip.stay.none': 'Bez postoju',
|
||||
'roadtrip.stay.custom': 'Minuty',
|
||||
'roadtrip.stay.arrive': 'Przyjazd',
|
||||
'roadtrip.stay.leave': 'Odjazd',
|
||||
'roadtrip.alt.noMotorway': 'Bez autostrady',
|
||||
'roadtrip.alt.noToll': 'Bez oplat',
|
||||
'roadtrip.summary.partial': 'Reszta trasy jest jeszcze obliczana',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Szablon mapy',
|
||||
'settings.mapTemplatePlaceholder.select': 'Wybierz szablon...',
|
||||
'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)',
|
||||
'settings.routingBase': 'Własny silnik tras',
|
||||
'settings.routingBaseHint': 'Własna instancja OSRM. Puste używa serwerów publicznych, które pozwalają na około jedno zapytanie na sekundę — wystarczy na dzień, mało na road trip. Działa po restarcie serwera.',
|
||||
'settings.mapHint': 'Szablon URL dla kafelków mapy',
|
||||
'settings.mapProvider': 'Dostawca mapy',
|
||||
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Еда',
|
||||
'roadtrip.poi.sights': 'Достопримечательности',
|
||||
'roadtrip.poi.within': 'В радиусе',
|
||||
'roadtrip.poi.filter': 'Фильтр по названию',
|
||||
'roadtrip.poi.noMatch': 'По пути ничего не соответствует «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} из {total} по пути',
|
||||
'roadtrip.poi.truncated': 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.',
|
||||
'roadtrip.poi.search': 'Искать вдоль маршрута',
|
||||
'roadtrip.poi.searching': 'Поиск {done} из {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут длинный — просмотрен только первый участок.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'через {distance}',
|
||||
'roadtrip.summary.distance': 'Расстояние',
|
||||
'roadtrip.summary.driving': 'Время в пути',
|
||||
'roadtrip.alt.ask': 'Другие маршруты',
|
||||
'roadtrip.alt.title': 'Маршруты этого участка',
|
||||
'roadtrip.alt.loading': 'Запрос к маршрутизатору…',
|
||||
'roadtrip.alt.onlyOne': 'У этого участка только один разумный маршрут.',
|
||||
'roadtrip.alt.failed': 'Маршрутизатор сейчас не отвечает.',
|
||||
'roadtrip.alt.current': 'Текущий',
|
||||
'roadtrip.alt.fastest': 'Самый быстрый',
|
||||
'roadtrip.alt.slower': 'на {time} дольше',
|
||||
'roadtrip.alt.quicker': 'на {time} быстрее',
|
||||
'roadtrip.day': 'День {number}',
|
||||
'roadtrip.quietDay.empty': 'Остановок пока нет — перетащите сюда',
|
||||
'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку',
|
||||
'roadtrip.leg.driveText': '{distance} за {time}',
|
||||
'roadtrip.day.stopCount': 'остановок: {count}',
|
||||
'roadtrip.stop.stayShort': 'Стоянка',
|
||||
'roadtrip.leg.pending': 'Нет маршрута',
|
||||
'roadtrip.stop.addTitle': 'Добавить как остановку',
|
||||
'roadtrip.stop.kind': 'Тип остановки',
|
||||
'roadtrip.stop.landsOn': 'День {day}, остановка {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» уже есть в этой поездке.',
|
||||
'roadtrip.stop.moreDetails': 'Подробнее',
|
||||
'roadtrip.stop.stay': 'Время на остановке',
|
||||
'roadtrip.stop.pinned': 'Заданное вами время',
|
||||
'roadtrip.stop.computed': 'Рассчитано по времени в пути',
|
||||
'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить',
|
||||
'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
|
||||
'roadtrip.warn.overnight': 'Следующий день',
|
||||
'roadtrip.summary.stops': 'Остановки',
|
||||
'roadtrip.stay.add': 'Задать время остановки',
|
||||
'roadtrip.stay.none': 'Без остановки',
|
||||
'roadtrip.stay.custom': 'Минуты',
|
||||
'roadtrip.stay.arrive': 'Прибытие',
|
||||
'roadtrip.stay.leave': 'Отправление',
|
||||
'roadtrip.alt.noMotorway': 'Без автомагистрали',
|
||||
'roadtrip.alt.noToll': 'Без платных дорог',
|
||||
'roadtrip.summary.partial': 'Остальная часть маршрута ещё рассчитывается',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Шаблон карты',
|
||||
'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...',
|
||||
'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
|
||||
'settings.routingBase': 'Собственный сервер маршрутов',
|
||||
'settings.routingBaseHint': 'Собственный экземпляр OSRM. Пусто — используются публичные серверы с ограничением около одного запроса в секунду: на день хватает, для автопутешествия мало. Вступает в силу после перезапуска сервера.',
|
||||
'settings.mapHint': 'URL-шаблон для тайлов карты',
|
||||
'settings.mapProvider': 'Провайдер карты',
|
||||
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Mat',
|
||||
'roadtrip.poi.sights': 'Sevärt',
|
||||
'roadtrip.poi.within': 'Inom',
|
||||
'roadtrip.poi.filter': 'Filtrera på namn',
|
||||
'roadtrip.poi.noMatch': 'Inget längs vägen matchar ”{name}”.',
|
||||
'roadtrip.poi.foundFiltered': '{count} av {total} längs vägen',
|
||||
'roadtrip.poi.truncated': 'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.',
|
||||
'roadtrip.poi.search': 'Sök längs rutten',
|
||||
'roadtrip.poi.searching': 'Söker {done} av {total}',
|
||||
'roadtrip.poi.capped': 'Rutten är lång – bara första sträckan genomsöktes.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'efter {distance}',
|
||||
'roadtrip.summary.distance': 'Sträcka',
|
||||
'roadtrip.summary.driving': 'Körtid',
|
||||
'roadtrip.alt.ask': 'Andra vägar',
|
||||
'roadtrip.alt.title': 'Vägar för den här sträckan',
|
||||
'roadtrip.alt.loading': 'Frågar ruttservern…',
|
||||
'roadtrip.alt.onlyOne': 'Det finns bara en rimlig väg för den här sträckan.',
|
||||
'roadtrip.alt.failed': 'Ruttservern svarar inte just nu.',
|
||||
'roadtrip.alt.current': 'Nuvarande',
|
||||
'roadtrip.alt.fastest': 'Snabbast',
|
||||
'roadtrip.alt.slower': '{time} långsammare',
|
||||
'roadtrip.alt.quicker': '{time} snabbare',
|
||||
'roadtrip.day': 'Dag {number}',
|
||||
'roadtrip.quietDay.empty': 'Inga stopp än – släpp ett här',
|
||||
'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här',
|
||||
'roadtrip.leg.driveText': '{distance} på {time}',
|
||||
'roadtrip.day.stopCount': '{count} stopp',
|
||||
'roadtrip.stop.stayShort': 'Stopp',
|
||||
'roadtrip.leg.pending': 'Ingen rutt',
|
||||
'roadtrip.stop.addTitle': 'Lägg till som stopp',
|
||||
'roadtrip.stop.kind': 'Typ av stopp',
|
||||
'roadtrip.stop.landsOn': 'Dag {day}, som stopp {position}',
|
||||
'roadtrip.stop.duplicate': '”{name}” finns redan i den här resan.',
|
||||
'roadtrip.stop.moreDetails': 'Fler detaljer',
|
||||
'roadtrip.stop.stay': 'Tid på stoppet',
|
||||
'roadtrip.stop.pinned': 'Tid du har satt',
|
||||
'roadtrip.stop.computed': 'Beräknad utifrån körningen',
|
||||
'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort',
|
||||
'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
|
||||
'roadtrip.warn.overnight': 'Nästa dag',
|
||||
'roadtrip.summary.stops': 'Stopp',
|
||||
'roadtrip.stay.add': 'Ange vistelse',
|
||||
'roadtrip.stay.none': 'Ingen vistelse',
|
||||
'roadtrip.stay.custom': 'Minuter',
|
||||
'roadtrip.stay.arrive': 'Ankomst',
|
||||
'roadtrip.stay.leave': 'Avfard',
|
||||
'roadtrip.alt.noMotorway': 'Utan motorvag',
|
||||
'roadtrip.alt.noToll': 'Utan vagavgifter',
|
||||
'roadtrip.summary.partial': 'Resten av resan beräknas fortfarande',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Kartmall',
|
||||
'settings.mapTemplatePlaceholder.select': 'Välj mall...',
|
||||
'settings.mapDefaultHint': 'Lämna fältet tomt för OpenStreetMap (standard)',
|
||||
'settings.routingBase': 'Egen ruttmotor',
|
||||
'settings.routingBaseHint': 'En egen OSRM-instans. Tomt använder de publika servrarna, som tillåter ungefär en förfrågan per sekund – nog för en dag, knappt för en bilresa. Träder i kraft efter omstart av servern.',
|
||||
'settings.mapHint': 'URL-mall för kartrutor',
|
||||
'settings.mapProvider': 'Kartleverantör',
|
||||
'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Yemek',
|
||||
'roadtrip.poi.sights': 'Görülecek yerler',
|
||||
'roadtrip.poi.within': 'Mesafe',
|
||||
'roadtrip.poi.filter': 'Ada göre süz',
|
||||
'roadtrip.poi.noMatch': 'Yol boyunca “{name}” ile eşleşen bir şey yok.',
|
||||
'roadtrip.poi.foundFiltered': 'yol üzerinde {total} içinden {count}',
|
||||
'roadtrip.poi.truncated': '{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.',
|
||||
'roadtrip.poi.search': 'Rota boyunca ara',
|
||||
'roadtrip.poi.searching': 'Aranıyor {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Rota uzun — yalnızca ilk bölüm arandı.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': '{distance} sonra',
|
||||
'roadtrip.summary.distance': 'Mesafe',
|
||||
'roadtrip.summary.driving': 'Sürüş süresi',
|
||||
'roadtrip.alt.ask': 'Başka yollar',
|
||||
'roadtrip.alt.title': 'Bu etap için yollar',
|
||||
'roadtrip.alt.loading': 'Rota sunucusuna soruluyor…',
|
||||
'roadtrip.alt.onlyOne': 'Bu etap için mantıklı tek bir yol var.',
|
||||
'roadtrip.alt.failed': 'Rota sunucusu şu anda yanıt vermiyor.',
|
||||
'roadtrip.alt.current': 'Mevcut',
|
||||
'roadtrip.alt.fastest': 'En hızlı',
|
||||
'roadtrip.alt.slower': '{time} daha uzun',
|
||||
'roadtrip.alt.quicker': '{time} daha kısa',
|
||||
'roadtrip.day': '{number}. gün',
|
||||
'roadtrip.quietDay.empty': 'Henüz mola yok — buraya bırakın',
|
||||
'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın',
|
||||
'roadtrip.leg.driveText': '{time} içinde {distance}',
|
||||
'roadtrip.day.stopCount': '{count} mola',
|
||||
'roadtrip.stop.stayShort': 'Süre',
|
||||
'roadtrip.leg.pending': 'Rota yok',
|
||||
'roadtrip.stop.addTitle': 'Mola olarak ekle',
|
||||
'roadtrip.stop.kind': 'Mola türü',
|
||||
'roadtrip.stop.landsOn': '{day}. gün, {position}. mola',
|
||||
'roadtrip.stop.duplicate': '“{name}” bu seyahatte zaten var.',
|
||||
'roadtrip.stop.moreDetails': 'Daha fazla ayrıntı',
|
||||
'roadtrip.stop.stay': 'Bu duraktaki süre',
|
||||
'roadtrip.stop.pinned': 'Belirlediğiniz saat',
|
||||
'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
|
||||
'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın',
|
||||
'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
|
||||
'roadtrip.warn.overnight': 'Ertesi gün',
|
||||
'roadtrip.summary.stops': 'Duraklar',
|
||||
'roadtrip.stay.add': 'Mola suresi ekle',
|
||||
'roadtrip.stay.none': 'Mola yok',
|
||||
'roadtrip.stay.custom': 'Dakika',
|
||||
'roadtrip.stay.arrive': 'Varis',
|
||||
'roadtrip.stay.leave': 'Kalkis',
|
||||
'roadtrip.alt.noMotorway': 'Otoyolsuz',
|
||||
'roadtrip.alt.noToll': 'Ucretsiz',
|
||||
'roadtrip.summary.partial': 'Yolun geri kalanı hâlâ hesaplanıyor',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapTemplate': 'Harita Şablonu',
|
||||
'settings.mapTemplatePlaceholder.select': 'Şablon seçin...',
|
||||
'settings.mapDefaultHint': 'OpenStreetMap için boş bırakın (varsayılan)',
|
||||
'settings.routingBase': 'Kendi rota motoru',
|
||||
'settings.routingBaseHint': 'Kendi OSRM sunucunuz. Boş bırakılırsa saniyede yaklaşık bir istek veren genel sunucular kullanılır: bir gün için yeter, uzun yolculuk için az. Sunucu yeniden başlatıldığında etkin olur.',
|
||||
'settings.mapHint': 'Harita kutucukları için URL şablonu',
|
||||
'settings.mapProvider': 'Harita Sağlayıcısı',
|
||||
'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.',
|
||||
|
||||
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.food': 'Їжа',
|
||||
'roadtrip.poi.sights': 'Пам’ятки',
|
||||
'roadtrip.poi.within': 'У радіусі',
|
||||
'roadtrip.poi.filter': 'Фільтр за назвою',
|
||||
'roadtrip.poi.noMatch': 'Дорогою ніщо не відповідає «{name}».',
|
||||
'roadtrip.poi.foundFiltered': '{count} з {total} дорогою',
|
||||
'roadtrip.poi.truncated': 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.',
|
||||
'roadtrip.poi.search': 'Шукати вздовж маршруту',
|
||||
'roadtrip.poi.searching': 'Пошук {done} з {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут довгий — переглянуто лише перший відрізок.',
|
||||
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.alongRoute': 'через {distance}',
|
||||
'roadtrip.summary.distance': 'Відстань',
|
||||
'roadtrip.summary.driving': 'Час у дорозі',
|
||||
'roadtrip.alt.ask': 'Інші маршрути',
|
||||
'roadtrip.alt.title': 'Маршрути цього відрізка',
|
||||
'roadtrip.alt.loading': 'Запит до маршрутизатора…',
|
||||
'roadtrip.alt.onlyOne': 'Для цього відрізка є лише один розумний маршрут.',
|
||||
'roadtrip.alt.failed': 'Маршрутизатор зараз не відповідає.',
|
||||
'roadtrip.alt.current': 'Поточний',
|
||||
'roadtrip.alt.fastest': 'Найшвидший',
|
||||
'roadtrip.alt.slower': 'на {time} довше',
|
||||
'roadtrip.alt.quicker': 'на {time} швидше',
|
||||
'roadtrip.day': 'День {number}',
|
||||
'roadtrip.quietDay.empty': 'Зупинок ще немає — перетягніть сюди',
|
||||
'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку',
|
||||
'roadtrip.leg.driveText': '{distance} за {time}',
|
||||
'roadtrip.day.stopCount': 'зупинок: {count}',
|
||||
'roadtrip.stop.stayShort': 'Стоянка',
|
||||
'roadtrip.leg.pending': 'Немає маршруту',
|
||||
'roadtrip.stop.addTitle': 'Додати як зупинку',
|
||||
'roadtrip.stop.kind': 'Тип зупинки',
|
||||
'roadtrip.stop.landsOn': 'День {day}, зупинка {position}',
|
||||
'roadtrip.stop.duplicate': '«{name}» уже є в цій подорожі.',
|
||||
'roadtrip.stop.moreDetails': 'Докладніше',
|
||||
'roadtrip.stop.stay': 'Час на зупинці',
|
||||
'roadtrip.stop.pinned': 'Час, який ви задали',
|
||||
'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
|
||||
'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити',
|
||||
'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
|
||||
'roadtrip.warn.overnight': 'Наступний день',
|
||||
'roadtrip.summary.stops': 'Зупинки',
|
||||
'roadtrip.stay.add': 'Задати час зупинки',
|
||||
'roadtrip.stay.none': 'Без зупинки',
|
||||
'roadtrip.stay.custom': 'Хвилини',
|
||||
'roadtrip.stay.arrive': 'Прибуття',
|
||||
'roadtrip.stay.leave': 'Відʼїзд',
|
||||
'roadtrip.alt.noMotorway': 'Без автомагістралі',
|
||||
'roadtrip.alt.noToll': 'Без платних доріг',
|
||||
'roadtrip.summary.partial': 'Решта маршруту ще обчислюється',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user