mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
e11ae6f056
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.
113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
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 }
|
|
}
|