mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge pull request #2390 from liketrek/fix/roadtrip-day-routes
fix(roadtrip): a stop on a dead end took the whole day's routing with it
This commit is contained in:
@@ -744,7 +744,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
|
|
||||||
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
|
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
|
||||||
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
|
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
|
||||||
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string; settled_at?: string | null }
|
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; created_at?: string; settled_at?: string | null }
|
||||||
|
|
||||||
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
|
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
|
||||||
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
|
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
|
||||||
|
|||||||
@@ -975,3 +975,77 @@ describe('calculateAlternatives', () => {
|
|||||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── turning round at a stop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gouffre de Padirac snaps onto `Route du Puits au Salvage`, which leads nowhere else.
|
||||||
|
* OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked, and it
|
||||||
|
* refuses the ENTIRE request when it cannot obey, so a road-trip day lost every leg it
|
||||||
|
* had over one cave car park, while each of its pairs routed perfectly on its own.
|
||||||
|
*/
|
||||||
|
describe('turning round at a stop', () => {
|
||||||
|
const askedFor = async (
|
||||||
|
call: (points: { lat: number; lng: number }[]) => Promise<unknown>,
|
||||||
|
count: number,
|
||||||
|
): Promise<string> => {
|
||||||
|
let asked = ''
|
||||||
|
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||||
|
asked = request.url
|
||||||
|
return HttpResponse.json(buildOsrmRouteResponse())
|
||||||
|
}))
|
||||||
|
await call(freshWaypoints(count))
|
||||||
|
return asked
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-055: a drive that stops on the way may turn round where it stopped', async () => {
|
||||||
|
expect(await askedFor(p => calculateRoute(p), 3)).toContain('continue_straight=false')
|
||||||
|
expect(await askedFor(p => calculateRouteWithLegs(p), 3)).toContain('continue_straight=false')
|
||||||
|
expect(await askedFor(p => calculateSegments(p), 3)).toContain('continue_straight=false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-056: two points have no middle, so the request is the one it always was', async () => {
|
||||||
|
expect(await askedFor(p => calculateRoute(p), 2)).not.toContain('continue_straight')
|
||||||
|
expect(await askedFor(p => calculateRouteWithLegs(p), 2)).not.toContain('continue_straight')
|
||||||
|
expect(await askedFor(p => calculateSegments(p), 2)).not.toContain('continue_straight')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-057: a router that has never heard of the permission is asked without it, once and then always', async () => {
|
||||||
|
// OSRM refuses an unknown parameter outright instead of ignoring it, and says which
|
||||||
|
// it is refusing: `InvalidQuery` names the query string, `NoRoute` names the road.
|
||||||
|
// Without this a router that predates the parameter would refuse every drive with a
|
||||||
|
// stop in the middle, which is a far worse fault than the one it fixes.
|
||||||
|
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://old-osrm.example.org' } }))
|
||||||
|
const asked: string[] = []
|
||||||
|
server.use(http.get('https://old-osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||||
|
asked.push(request.url)
|
||||||
|
return new URL(request.url).searchParams.has('continue_straight')
|
||||||
|
? HttpResponse.json({ code: 'InvalidQuery', message: 'Query string malformed' }, { status: 400 })
|
||||||
|
: HttpResponse.json(buildOsrmRouteResponse())
|
||||||
|
}))
|
||||||
|
|
||||||
|
const first = await calculateRoute(freshWaypoints(3))
|
||||||
|
expect(first.coordinates).toHaveLength(2)
|
||||||
|
expect(asked).toHaveLength(2)
|
||||||
|
expect(asked[1]).not.toContain('continue_straight')
|
||||||
|
|
||||||
|
// Remembered per host, so the second drive costs one request rather than two.
|
||||||
|
await calculateRoute(freshWaypoints(3))
|
||||||
|
expect(asked).toHaveLength(3)
|
||||||
|
expect(asked[2]).not.toContain('continue_straight')
|
||||||
|
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-058: a refusal about the road is still a refusal, not a second request', async () => {
|
||||||
|
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://noroute-osrm.example.org' } }))
|
||||||
|
let calls = 0
|
||||||
|
server.use(http.get('https://noroute-osrm.example.org/route/v1/driving/:coords', () => {
|
||||||
|
calls += 1
|
||||||
|
return HttpResponse.json({ code: 'NoRoute', message: 'No route found between points' }, { status: 400 })
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(calculateRoute(freshWaypoints(3))).rejects.toThrow()
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
|||||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission to turn around at a stop, for requests that have stops to turn around at.
|
||||||
|
*
|
||||||
|
* OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked otherwise,
|
||||||
|
* and a refusal is not local: the whole request comes back `400 NoRoute`, every leg of it,
|
||||||
|
* however many were fine. A stop on a dead end is enough. Gouffre de Padirac snaps onto
|
||||||
|
* `Route du Puits au Salvage`, which goes nowhere else, so a day that visits it in the
|
||||||
|
* middle lost all its legs at once while each of its pairs routed perfectly on its own.
|
||||||
|
* That is also why the fault looked like it depended on how the stops were spread across
|
||||||
|
* days: a pair on a day boundary is asked for on its own, and a request with nothing in
|
||||||
|
* the middle has nothing to refuse.
|
||||||
|
*
|
||||||
|
* Correct as well as convenient here. TREK's waypoints are places somebody stops at, not
|
||||||
|
* shape hints, and the way out of a dead end IS the way back in, and the constraint was
|
||||||
|
* buying a detour even where it did not refuse outright.
|
||||||
|
*
|
||||||
|
* Empty for two waypoints, which have no middle, so the many two-point callers
|
||||||
|
* (alternatives, the day connectors, booking geometry) keep the URL they had.
|
||||||
|
*/
|
||||||
|
const U_TURN_PARAM = '&continue_straight=false'
|
||||||
|
|
||||||
|
function uTurnParam(waypoints: readonly Waypoint[]): string {
|
||||||
|
return waypoints.length > 2 ? U_TURN_PARAM : ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `fetch`, with the request counted.
|
* `fetch`, with the request counted.
|
||||||
*
|
*
|
||||||
@@ -79,10 +104,16 @@ async function routedFetch(
|
|||||||
let km = 0
|
let km = 0
|
||||||
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
|
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
|
||||||
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
|
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
|
||||||
|
const asked = withoutUTurnIfRefused(url)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { signal })
|
const response = await fetch(asked, { signal })
|
||||||
countRoute({ ...sample, failed: !response.ok })
|
countRoute({ ...sample, failed: !response.ok })
|
||||||
return response
|
if (response.ok || !asked.includes(U_TURN_PARAM)) return response
|
||||||
|
const plain = await droppedUTurn(asked, response)
|
||||||
|
if (!plain) return response
|
||||||
|
const second = await fetch(plain, { signal })
|
||||||
|
countRoute({ ...sample, failed: !second.ok })
|
||||||
|
return second
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
||||||
countRoute({ ...sample, failed: true })
|
countRoute({ ...sample, failed: true })
|
||||||
@@ -91,6 +122,49 @@ async function routedFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts that answered `InvalidQuery` to `continue_straight`, by origin.
|
||||||
|
*
|
||||||
|
* OSRM refuses an unknown query parameter outright rather than ignoring it, so a router
|
||||||
|
* that has never heard of this one would refuse EVERY request with a stop in the middle,
|
||||||
|
* which is a far worse fault than the one it fixes. `osrm-routed` has understood it
|
||||||
|
* since 5.6, so this is about something else answering under `routing_base_url`, and the
|
||||||
|
* first real request is the probe: there is no separate one.
|
||||||
|
*
|
||||||
|
* By origin rather than per profile, because a router either knows the parameter or does
|
||||||
|
* not; and in a Set rather than a setting, so pointing the instance at a router that does
|
||||||
|
* know it starts asking again without a reload. Same shape and the same reasoning as
|
||||||
|
* `excludeUnsupported` below.
|
||||||
|
*/
|
||||||
|
const uTurnUnsupported = new Set<string>()
|
||||||
|
|
||||||
|
const originOf = (url: string): string => {
|
||||||
|
try { return new URL(url).origin } catch { return url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The url as it should go out, with the permission dropped for a host that refused it. */
|
||||||
|
function withoutUTurnIfRefused(url: string): string {
|
||||||
|
if (!url.includes(U_TURN_PARAM)) return url
|
||||||
|
return uTurnUnsupported.has(originOf(url)) ? url.replace(U_TURN_PARAM, '') : url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same url without the u-turn permission, once it is clear the host objects to the
|
||||||
|
* PARAMETER and not to the route. Null when the refusal was about the route itself.
|
||||||
|
*
|
||||||
|
* OSRM says which it is: `InvalidQuery` names the query string, `NoRoute` names the road.
|
||||||
|
* Reading the body is safe here because this response is about to be thrown away either
|
||||||
|
* way, and it only happens on a 400 for a request that carried the parameter.
|
||||||
|
*/
|
||||||
|
async function droppedUTurn(url: string, response: Response): Promise<string | null> {
|
||||||
|
if (response.status !== 400) return null
|
||||||
|
let code: unknown
|
||||||
|
try { code = (await response.clone().json())?.code } catch { return null }
|
||||||
|
if (code !== 'InvalidQuery') return null
|
||||||
|
uTurnUnsupported.add(originOf(url))
|
||||||
|
return url.replace(U_TURN_PARAM, '')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||||
*
|
*
|
||||||
@@ -153,7 +227,7 @@ export async function calculateRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false${uTurnParam(waypoints)}`
|
||||||
|
|
||||||
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -360,7 +434,7 @@ export async function calculateSegments(
|
|||||||
if (!waypoints || waypoints.length < 2) return []
|
if (!waypoints || waypoints.length < 2) return []
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
const url = `${routeBaseFor('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${uTurnParam(waypoints)}`
|
||||||
|
|
||||||
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
||||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||||
@@ -488,7 +562,7 @@ export async function calculateRouteWithLegs(
|
|||||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration${uTurnParam(waypoints)}`
|
||||||
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
|
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
|
||||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||||
|
|
||||||
|
|||||||
@@ -1772,6 +1772,16 @@ describe('PlaceFormModal as a road trip service stop', () => {
|
|||||||
expect(screen.getAllByText('Add as a stop').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Add as a stop').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-085: it opens on the kind the corridor was looking for, dwell and all', () => {
|
||||||
|
// The panel seeds itself to charging on an electric car, and a form opening on a
|
||||||
|
// fuel pump under a picker that says Charging is the fault this carries the answer to.
|
||||||
|
render(<PlaceFormModal {...defaultProps} serviceStop={serviceStop({ defaultKind: 'charging' })} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Charging' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: /30 min/ })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-076: picking a kind also picks how long that kind takes', async () => {
|
it('FE-PLANNER-PLACEFORM-076: picking a kind also picks how long that kind takes', async () => {
|
||||||
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
||||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
||||||
|
|||||||
@@ -254,10 +254,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
// path exists to avoid. Read out of the closure rather than watched, because the
|
// path exists to avoid. Read out of the closure rather than watched, because the
|
||||||
// mode is fixed for the life of one opening and a rebuilt drive must not reset a
|
// mode is fixed for the life of one opening and a rebuilt drive must not reset a
|
||||||
// half-filled form.
|
// half-filled form.
|
||||||
|
// What the corridor panel was looking for when the button was pressed, because
|
||||||
|
// that is the traveller's own answer to what they are adding; only a panel with
|
||||||
|
// nothing selected falls back to the constant. The dwell follows the kind, the
|
||||||
|
// same way picking one by hand moves it.
|
||||||
|
const kind = serviceStop.defaultKind ?? DEFAULT_SERVICE_KIND
|
||||||
setForm({
|
setForm({
|
||||||
...DEFAULT_FORM,
|
...DEFAULT_FORM,
|
||||||
stop_type: DEFAULT_SERVICE_KIND,
|
stop_type: kind,
|
||||||
duration_minutes: STOP_KIND_BY_KEY[DEFAULT_SERVICE_KIND].defaultMinutes,
|
duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
|
|||||||
@@ -441,6 +441,35 @@ describe('RoadtripCorridorPanel', () => {
|
|||||||
// nothing is added until a place has been found and saved in there.
|
// nothing is added until a place has been found and saved in there.
|
||||||
fireEvent.click(manual)
|
fireEvent.click(manual)
|
||||||
expect(onAddManual).toHaveBeenCalledTimes(1)
|
expect(onAddManual).toHaveBeenCalledTimes(1)
|
||||||
|
// The kind, never the MouseEvent: the categories above the button are the answer to
|
||||||
|
// what is being added, and a form that ignored them opened every manual stop on fuel.
|
||||||
|
expect(onAddManual).toHaveBeenCalledWith('fuel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-PANEL-035: the manual add opens on what the panel is looking for', () => {
|
||||||
|
const open = (categories: string[]): unknown => {
|
||||||
|
const onAddManual = vi.fn()
|
||||||
|
const { unmount } = wrap(
|
||||||
|
<RoadtripCorridorPanel
|
||||||
|
corridor={corridor({ categories })}
|
||||||
|
routes={routes([day(1, 1)])}
|
||||||
|
onAddPoi={vi.fn()}
|
||||||
|
onAddManual={onAddManual}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(searchRow()[1])
|
||||||
|
unmount()
|
||||||
|
return onAddManual.mock.calls[0][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// An electric car seeds the panel to charging, and this is the case the fault was
|
||||||
|
// reported on: the panel said Charging and the form opened on Fuel.
|
||||||
|
expect(open(['charging'])).toBe('charging')
|
||||||
|
expect(open(['restaurant'])).toBe('restaurant')
|
||||||
|
// Multi-select: the first the panel LISTS wins, rather than a kind nobody switched on.
|
||||||
|
expect(open(['sights', 'rest_area'])).toBe('rest_area')
|
||||||
|
// Nothing selected is no answer, and the form falls back on its own.
|
||||||
|
expect(open([])).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-ROADTRIP-PANEL-026: with no drive on the trip there is nowhere to add one by hand', () => {
|
it('FE-ROADTRIP-PANEL-026: with no drive on the trip there is nowhere to add one by hand', () => {
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import CustomSelect from '../shared/CustomSelect'
|
|||||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||||
import { serviceColor } from './roadtripModel'
|
import { serviceColor } from './roadtripModel'
|
||||||
import { alongLabel, offRouteLabel } from './corridorSearchModel'
|
import { alongLabel, offRouteLabel } from './corridorSearchModel'
|
||||||
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
|
import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds'
|
||||||
import { FS } from './typeScale'
|
import { FS } from './typeScale'
|
||||||
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
||||||
|
import type { RoadtripStopType } from '@trek/shared'
|
||||||
import type { CorridorPoi } from './useCorridorPois'
|
import type { CorridorPoi } from './useCorridorPois'
|
||||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||||
|
|
||||||
@@ -38,8 +39,12 @@ interface RoadtripCorridorPanelProps {
|
|||||||
/**
|
/**
|
||||||
* Opens the place form for a stop the search never found. Given only to somebody who
|
* Opens the place form for a stop the search never found. Given only to somebody who
|
||||||
* may add places, which is what keeps the button off a reader's panel.
|
* may add places, which is what keeps the button off a reader's panel.
|
||||||
|
*
|
||||||
|
* Carries the kind the panel is looking for, so the form opens on it rather than on a
|
||||||
|
* constant: the categories above the button are the traveller's own answer to "what am
|
||||||
|
* I adding", and ignoring them made every manual stop start life as a fuel stop.
|
||||||
*/
|
*/
|
||||||
onAddManual?: () => void
|
onAddManual?: (kind: RoadtripStopType | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -476,7 +481,9 @@ export default function RoadtripCorridorPanel({
|
|||||||
{onAddPoi && onAddManual && corridor.day ? (
|
{onAddPoi && onAddManual && corridor.day ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAddManual}
|
// Wrapped rather than handed the prop directly: `onClick` would otherwise
|
||||||
|
// pass the MouseEvent as the kind.
|
||||||
|
onClick={() => onAddManual(manualStopKindFor(corridor.categories))}
|
||||||
// The full sentence is the accessible name and the tooltip; the face of
|
// The full sentence is the accessible name and the tooltip; the face of
|
||||||
// the button carries the one word that fits beside "Search".
|
// the button carries the one word that fits beside "Search".
|
||||||
aria-label={t('roadtrip.poi.addManual')}
|
aria-label={t('roadtrip.poi.addManual')}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export interface ServiceStopDay {
|
|||||||
|
|
||||||
/** Everything the form needs to ask where a service stop belongs, and nothing else. */
|
/** Everything the form needs to ask where a service stop belongs, and nothing else. */
|
||||||
export interface ServiceStopMode {
|
export interface ServiceStopMode {
|
||||||
|
/**
|
||||||
|
* The kind the form opens on, from what the corridor panel is looking for.
|
||||||
|
*
|
||||||
|
* Absent or null when the panel had nothing selected, and then `DEFAULT_SERVICE_KIND`
|
||||||
|
* stands in. Optional so a caller with no panel behind it stays unchanged.
|
||||||
|
*/
|
||||||
|
defaultKind?: RoadtripStopType | null
|
||||||
days: ServiceStopDay[]
|
days: ServiceStopDay[]
|
||||||
/** Where a stop goes at the end of the day the panel is on, routed or not. */
|
/** Where a stop goes at the end of the day the panel is on, routed or not. */
|
||||||
appendDay: { dayId: number; dayNumber: number; position: number } | null
|
appendDay: { dayId: number; dayNumber: number; position: number } | null
|
||||||
@@ -99,12 +106,13 @@ export interface ServiceStopChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The kind the form opens on.
|
* The kind the form opens on when nothing better is known.
|
||||||
*
|
*
|
||||||
* The panel it is reached from is headed by pumps and chargers, and one click is the
|
* Nothing better means the panel had no category switched on at all; normally the form
|
||||||
* whole distance to any of the others. Opening on nothing would be worse than a guess: a
|
* opens on what the corridor is looking for (`manualStopKindFor`), because that is the
|
||||||
* stop left without a kind is a numbered destination that counts in every total, which
|
* traveller's own answer rather than a guess. Opening on nothing would be worse than
|
||||||
* is the very thing this path exists to avoid.
|
* either: a stop left without a kind is a numbered destination that counts in every
|
||||||
|
* total, which is the very thing this path exists to avoid.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_SERVICE_KIND: RoadtripStopType = 'fuel'
|
export const DEFAULT_SERVICE_KIND: RoadtripStopType = 'fuel'
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { roadtripStopTypeSchema } from '@trek/shared'
|
|||||||
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
|
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
|
||||||
import {
|
import {
|
||||||
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
|
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
|
||||||
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
|
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory, manualStopKindFor,
|
||||||
} from './stopKinds'
|
} from './stopKinds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,4 +77,19 @@ describe('stop kinds', () => {
|
|||||||
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
|
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-STOPKIND-008: the manual add opens on what the corridor is looking for', () => {
|
||||||
|
// A stop added by hand used to open on fuel whatever the panel above it said, which
|
||||||
|
// on an electric car meant a picker reading Charging over a form reading Fuel.
|
||||||
|
for (const category of CORRIDOR_CATEGORIES) {
|
||||||
|
expect(manualStopKindFor([category.key])).toBe(category.stopKind)
|
||||||
|
}
|
||||||
|
// Multi-select, so the first the panel LISTS wins: falling back would open on a kind
|
||||||
|
// that is not among the ones switched on, which is a worse answer than any of them.
|
||||||
|
const [first, second] = CORRIDOR_CATEGORY_KEYS
|
||||||
|
expect(manualStopKindFor([second, first])).toBe(CORRIDOR_CATEGORY_BY_KEY[first].stopKind)
|
||||||
|
// Nothing selected is no answer at all, and the caller decides what to do with that.
|
||||||
|
expect(manualStopKindFor([])).toBeNull()
|
||||||
|
expect(manualStopKindFor(['not-a-category'])).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object
|
|||||||
/** The keys the corridor search offers, in the order the panel shows them. */
|
/** The keys the corridor search offers, in the order the panel shows them. */
|
||||||
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
|
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind a stop added by hand opens on, read off what the corridor is looking for.
|
||||||
|
*
|
||||||
|
* The categories are what the traveller has just told the search to find, so they are
|
||||||
|
* also the best reading of what they are about to add themselves, and on an electric
|
||||||
|
* car the panel seeds itself to charging, which is precisely the case where a form
|
||||||
|
* opening on a fuel pump read as a fault.
|
||||||
|
*
|
||||||
|
* The picker is multi-select, so the first category the panel LISTS wins. Falling back
|
||||||
|
* instead would open on a kind that is not among the ones switched on, which is a worse
|
||||||
|
* answer than any of them. Null only when nothing is selected at all; the caller decides
|
||||||
|
* what that means, because a form must never open on no kind (see `DEFAULT_SERVICE_KIND`).
|
||||||
|
*/
|
||||||
|
export function manualStopKindFor(categories: readonly string[]): RoadtripStopType | null {
|
||||||
|
for (const category of CORRIDOR_CATEGORIES) {
|
||||||
|
if (categories.includes(category.key) && category.stopKind) return category.stopKind
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** Categories whose hit becomes a night rather than a pause. */
|
/** Categories whose hit becomes a night rather than a pause. */
|
||||||
export function isOvernightCategory(category: string | null | undefined): boolean {
|
export function isOvernightCategory(category: string | null | undefined): boolean {
|
||||||
// Campsite is both: it is a service stop today and stays one, but a night can be
|
// Campsite is both: it is a service stop today and stays one, but a night can be
|
||||||
|
|||||||
@@ -278,6 +278,42 @@ describe('useRoadtripRoutes', () => {
|
|||||||
expect(result.current.totalDistance).toBe(100000)
|
expect(result.current.totalDistance).toBe(100000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-028: a day the router refuses outright is asked for again one pair at a time', async () => {
|
||||||
|
const days = [day(1, 1)]
|
||||||
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
||||||
|
// The Padirac case: one stop in the middle the router will not turn round at, and
|
||||||
|
// what comes back is a refusal of the WHOLE chain rather than of that one leg.
|
||||||
|
calculateRouteWithLegs
|
||||||
|
.mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
||||||
|
.mockResolvedValue(routed(1))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 5000 })
|
||||||
|
|
||||||
|
// The run once, refused, then a request per pair, and no retry of the run, because
|
||||||
|
// the same coordinates earn the same refusal.
|
||||||
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
||||||
|
expect(calculateRouteWithLegs.mock.calls[1][0]).toHaveLength(2)
|
||||||
|
expect(calculateRouteWithLegs.mock.calls[2][0]).toHaveLength(2)
|
||||||
|
expect(result.current.days[0].legs.filter(Boolean)).toHaveLength(2)
|
||||||
|
expect(result.current.totalDistance).toBe(200000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-029: a rate limit is waited out, never split into more requests', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const days = [day(1, 1)]
|
||||||
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
||||||
|
calculateRouteWithLegs.mockRejectedValue(new RoutingRefusedError(429, null))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(20000) })
|
||||||
|
|
||||||
|
// Three attempts at the run and nothing after them: answering a host that asked for
|
||||||
|
// less traffic with two more requests would be the opposite of backing off.
|
||||||
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
||||||
|
expect(result.current.days[0].legs).toEqual([undefined, undefined])
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-ROADTRIP-ROUTES-009: renaming a place does not re-ask the router', async () => {
|
it('FE-ROADTRIP-ROUTES-009: renaming a place does not re-ask the router', async () => {
|
||||||
const days = [day(1, 1)]
|
const days = [day(1, 1)]
|
||||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||||
|
|||||||
@@ -278,6 +278,110 @@ export function useRoadtripRoutes(
|
|||||||
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
||||||
const tasks: (() => Promise<void>)[] = []
|
const tasks: (() => Promise<void>)[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for one run, and when the router refuses the whole of it, ask for its legs
|
||||||
|
* one at a time instead.
|
||||||
|
*
|
||||||
|
* A run travels as a single request because the answer carries a leg per waypoint
|
||||||
|
* pair, which is exactly what the rail wants, and one request is one slot of a
|
||||||
|
* host that allows about one a second. What that thrift costs is that a refusal is
|
||||||
|
* never local: OSRM answers `400 NoRoute` for the entire chain when one stop in the
|
||||||
|
* MIDDLE of it sits where it may not turn around, so a day lost every leg it had
|
||||||
|
* over a single cave car park while each of its pairs routed perfectly alone.
|
||||||
|
* `uTurnParam` in RouteCalculator removes the usual cause; this removes the SHAPE of
|
||||||
|
* the failure, so a stop that really cannot be reached costs its own two legs and
|
||||||
|
* not the day around them.
|
||||||
|
*
|
||||||
|
* The split is enqueued, not awaited, so the pairs queue up behind everything else
|
||||||
|
* and keep the same spacing as any other request: a day falling back must not turn
|
||||||
|
* into a burst at the one host that refused it.
|
||||||
|
*/
|
||||||
|
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
|
||||||
|
/** The same stops, as one request per pair. A pair has nothing left to split. */
|
||||||
|
const splitIntoPairs = (): void => {
|
||||||
|
if (run.length <= 2) return
|
||||||
|
for (let i = 0; i < run.length - 1; i++) enqueueRun(day, dayLegs, [run[i], run[i + 1]], mode)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// A via is filed against the day it was dropped on and the position it sits
|
||||||
|
// after IN THAT DAY. Since a chain can hold stops from an earlier day (see
|
||||||
|
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
|
||||||
|
// index — this used to be the position within the chain, so on any day that
|
||||||
|
// received a night drive every via matched nothing and quietly stopped
|
||||||
|
// shaping the road.
|
||||||
|
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||||
|
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
|
||||||
|
.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(
|
||||||
|
waypoints,
|
||||||
|
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
||||||
|
)
|
||||||
|
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
||||||
|
// vias threaded in between are skipped: a via is a shape handle, not a
|
||||||
|
// destination, and a dashed spur hanging off one reads as a fault.
|
||||||
|
if (r.snapped) {
|
||||||
|
const daySnaps = (collectedSnaps[day.dayId] ??= {})
|
||||||
|
run.forEach((stop, i) => {
|
||||||
|
const s = r.snapped?.[stopAt[i]]
|
||||||
|
if (s) daySnaps[stopKey(stop)] = s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// What was asked for against what the road turned out to be. Only
|
||||||
|
// the second engine reports it, and only when it answered: an OSRM
|
||||||
|
// fallback leaves `avoidance` absent, which is the honest reading
|
||||||
|
// of "the weighting never happened". Collected here because the
|
||||||
|
// rail plans in the browser and never sees the server's own copy
|
||||||
|
// of this field — without it the "not honoured" badge could not
|
||||||
|
// appear at all, and a motorway-free drive that is not one read as
|
||||||
|
// if the setting had held.
|
||||||
|
if (r.avoidance) {
|
||||||
|
const missed = r.avoidance.asked.filter(cls => !r.avoidance!.achieved.includes(cls))
|
||||||
|
if (missed.length) {
|
||||||
|
collectedMisses[day.dayId] = [...new Set([...(collectedMisses[day.dayId] ?? []), ...missed])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
const refusal = err instanceof RoutingRefusedError ? err : null
|
||||||
|
// Almost always a rate limit on the shared routing host, or a connection
|
||||||
|
// that dropped: back off and try again. A refusal the host MEANT is the
|
||||||
|
// one thing not worth repeating: it objects to these coordinates, and
|
||||||
|
// asking twice more only spends five seconds earning the same 400.
|
||||||
|
const delay = refusal && !refusal.isRateLimit ? undefined : RETRY_DELAYS_MS[attempt]
|
||||||
|
if (delay !== undefined) {
|
||||||
|
// When the host says how long to wait, waiting less is just a second refusal.
|
||||||
|
await sleep(Math.max(delay, (refusal?.isRateLimit ? refusal.retryAfterMs : null) ?? 0), controller.signal)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Nothing left to try. A run of three or more stops asks again one pair at
|
||||||
|
// a time, so a stop the router will not route THROUGH costs its own two
|
||||||
|
// legs instead of every leg of the day around it. Never after a rate limit:
|
||||||
|
// the answer to a host asking for less traffic is not four more requests.
|
||||||
|
if (!refusal?.isRateLimit) splitIntoPairs()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for (const day of plan) {
|
for (const day of plan) {
|
||||||
const dayLegs: Record<string, RoutedLeg> = {}
|
const dayLegs: Record<string, RoutedLeg> = {}
|
||||||
collected[day.dayId] = dayLegs
|
collected[day.dayId] = dayLegs
|
||||||
@@ -290,77 +394,7 @@ export function useRoadtripRoutes(
|
|||||||
dfMode,
|
dfMode,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
for (const { stops: run, mode } of runs) enqueueRun(day, dayLegs, run, mode)
|
||||||
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
|
|
||||||
// A via is filed against the day it was dropped on and the position it sits
|
|
||||||
// after IN THAT DAY. Since a chain can hold stops from an earlier day (see
|
|
||||||
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
|
|
||||||
// index — this used to be the position within the chain, so on any day that
|
|
||||||
// received a night drive every via matched nothing and quietly stopped
|
|
||||||
// shaping the road.
|
|
||||||
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
|
||||||
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
|
|
||||||
.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(
|
|
||||||
waypoints,
|
|
||||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
|
||||||
)
|
|
||||||
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
|
||||||
// vias threaded in between are skipped: a via is a shape handle, not a
|
|
||||||
// destination, and a dashed spur hanging off one reads as a fault.
|
|
||||||
if (r.snapped) {
|
|
||||||
const daySnaps = (collectedSnaps[day.dayId] ??= {})
|
|
||||||
run.forEach((stop, i) => {
|
|
||||||
const s = r.snapped?.[stopAt[i]]
|
|
||||||
if (s) daySnaps[stopKey(stop)] = s
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// What was asked for against what the road turned out to be. Only
|
|
||||||
// the second engine reports it, and only when it answered: an OSRM
|
|
||||||
// fallback leaves `avoidance` absent, which is the honest reading
|
|
||||||
// of "the weighting never happened". Collected here because the
|
|
||||||
// rail plans in the browser and never sees the server's own copy
|
|
||||||
// of this field — without it the "not honoured" badge could not
|
|
||||||
// appear at all, and a motorway-free drive that is not one read as
|
|
||||||
// if the setting had held.
|
|
||||||
if (r.avoidance) {
|
|
||||||
const missed = r.avoidance.asked.filter(cls => !r.avoidance!.achieved.includes(cls))
|
|
||||||
if (missed.length) {
|
|
||||||
collectedMisses[day.dayId] = [...new Set([...(collectedMisses[day.dayId] ?? []), ...missed])]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
|
|
||||||
return
|
|
||||||
} 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
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One at a time, spaced out. The day sidebar can afford a small pool because it
|
// One at a time, spaced out. The day sidebar can afford a small pool because it
|
||||||
@@ -402,7 +436,12 @@ export function useRoadtripRoutes(
|
|||||||
const allLegs = useMemo(() => {
|
const allLegs = useMemo(() => {
|
||||||
const out: Record<string, RoutedLeg> = {}
|
const out: Record<string, RoutedLeg> = {}
|
||||||
for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {})
|
for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {})
|
||||||
return { ...out, ...seamLegs }
|
// Seams first, this day's own runs over the top. `legsByDay` is replaced whole on
|
||||||
|
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on
|
||||||
|
// a day boundary and now sits inside one day keeps an answer nobody re-asks for, and
|
||||||
|
// that older answer was shaped by the vias of a stop that has since moved. Where both
|
||||||
|
// exist the day run is the newer of the two, so it is the one to believe.
|
||||||
|
return { ...seamLegs, ...out }
|
||||||
}, [plan, legsByDay, seamLegs])
|
}, [plan, legsByDay, seamLegs])
|
||||||
|
|
||||||
// Which date each stop is actually reached on. Nothing is written to make it so; see
|
// Which date each stop is actually reached on. Nothing is written to make it so; see
|
||||||
|
|||||||
@@ -315,6 +315,14 @@ export function useTripPlanner() {
|
|||||||
* form they have always been.
|
* form they have always been.
|
||||||
*/
|
*/
|
||||||
const [serviceStopForm, setServiceStopForm] = useState(false)
|
const [serviceStopForm, setServiceStopForm] = useState(false)
|
||||||
|
/**
|
||||||
|
* The kind that form opens on, taken from what the corridor panel was looking for.
|
||||||
|
*
|
||||||
|
* Beside the flag rather than inside it, because it is written by the same click and
|
||||||
|
* read by the same memo, and a second piece of state is cheaper to follow than a flag
|
||||||
|
* that is sometimes a boolean and sometimes an object.
|
||||||
|
*/
|
||||||
|
const [serviceStopKind, setServiceStopKind] = useState<RoadtripStopType | null>(null)
|
||||||
/**
|
/**
|
||||||
* The corridor hit waiting to become a stop, while the small popup is open.
|
* The corridor hit waiting to become a stop, while the small popup is open.
|
||||||
*
|
*
|
||||||
@@ -1628,7 +1636,7 @@ export function useTripPlanner() {
|
|||||||
* typed-ahead search, which is the whole reason to use it, and where the stop belongs
|
* typed-ahead search, which is the whole reason to use it, and where the stop belongs
|
||||||
* is worked out at the save, from what the save carries.
|
* is worked out at the save, from what the save carries.
|
||||||
*/
|
*/
|
||||||
const openManualRoadtripStop = useCallback(() => {
|
const openManualRoadtripStop = useCallback((kind: RoadtripStopType | null = null) => {
|
||||||
if (!can('place_edit', trip)) return
|
if (!can('place_edit', trip)) return
|
||||||
setEditingPlace(null)
|
setEditingPlace(null)
|
||||||
setEditingAssignmentId(null)
|
setEditingAssignmentId(null)
|
||||||
@@ -1637,6 +1645,9 @@ export function useTripPlanner() {
|
|||||||
// form opens; this one cannot, because nothing has been chosen yet.
|
// form opens; this one cannot, because nothing has been chosen yet.
|
||||||
setPlaceFormDayId(null)
|
setPlaceFormDayId(null)
|
||||||
setPlaceFormPosition(null)
|
setPlaceFormPosition(null)
|
||||||
|
// Set in the same batch as the flag below, so the kind is already there when the
|
||||||
|
// form's opening effect reads the mode out of its closure.
|
||||||
|
setServiceStopKind(kind)
|
||||||
setServiceStopForm(true)
|
setServiceStopForm(true)
|
||||||
setShowPlaceForm(true)
|
setShowPlaceForm(true)
|
||||||
}, [can, trip])
|
}, [can, trip])
|
||||||
@@ -1653,6 +1664,7 @@ export function useTripPlanner() {
|
|||||||
if (!serviceStopForm) return null
|
if (!serviceStopForm) return null
|
||||||
const panelDay = roadtripCorridor.day
|
const panelDay = roadtripCorridor.day
|
||||||
return {
|
return {
|
||||||
|
defaultKind: serviceStopKind,
|
||||||
// A day that has not routed has no order to place anything in.
|
// A day that has not routed has no order to place anything in.
|
||||||
days: roadtripRoutes.days
|
days: roadtripRoutes.days
|
||||||
.filter(day => day.geometry.length > 1)
|
.filter(day => day.geometry.length > 1)
|
||||||
@@ -1680,7 +1692,7 @@ export function useTripPlanner() {
|
|||||||
: null,
|
: null,
|
||||||
targetFor: manualStopTargetFor,
|
targetFor: manualStopTargetFor,
|
||||||
}
|
}
|
||||||
}, [serviceStopForm, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor])
|
}, [serviceStopForm, serviceStopKind, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor])
|
||||||
|
|
||||||
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
|
||||||
if (!can('day_edit', trip)) return
|
if (!can('day_edit', trip)) return
|
||||||
|
|||||||
@@ -244,8 +244,16 @@ export class RoadtripRouterService {
|
|||||||
const origin =
|
const origin =
|
||||||
base ||
|
base ||
|
||||||
`https://routing.openstreetmap.de/routed-${mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'car'}`;
|
`https://routing.openstreetmap.de/routed-${mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'car'}`;
|
||||||
|
// `continue_straight=false` because a stop is somewhere the traveller stops, not a
|
||||||
|
// shape hint. OSRM's car profile otherwise forbids a u-turn at an INTERMEDIATE
|
||||||
|
// waypoint, and it refuses the WHOLE request rather than the one leg it cannot
|
||||||
|
// obey on: a single stop on a dead end (Gouffre de Padirac snaps onto a cave
|
||||||
|
// access road that leads nowhere else) turned a whole routed day into a 400. The
|
||||||
|
// browser sends the same thing, see `uTurnParam` in client RouteCalculator.ts.
|
||||||
|
// Only with something in the middle to turn round at.
|
||||||
|
const uTurn = points.length > 2 ? '&continue_straight=false' : '';
|
||||||
const payload = await this.request(
|
const payload = await this.request(
|
||||||
`${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false`,
|
`${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false${uTurn}`,
|
||||||
);
|
);
|
||||||
const parsed = osrmSchema.parse(payload);
|
const parsed = osrmSchema.parse(payload);
|
||||||
const route = parsed.routes[0];
|
const route = parsed.routes[0];
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ describe('server Roadtrip router', () => {
|
|||||||
expect((await pending).avoidMissed).toEqual(['ferry']);
|
expect((await pending).avoidMissed).toEqual(['ferry']);
|
||||||
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
it('lets a drive turn round at a stop it passes through, and asks nothing extra of a single leg', async () => {
|
||||||
|
// OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked, and
|
||||||
|
// refuses the WHOLE request when it cannot obey: one stop on a dead end used to
|
||||||
|
// cost a routed day all of its legs at once.
|
||||||
|
const { router } = setup();
|
||||||
|
await router.route(1, 1, 1, points, 'driving', []);
|
||||||
|
expect(safeFetchAdminConfigured).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('continue_straight=false'),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({
|
||||||
|
code: 'Ok',
|
||||||
|
routes: [{ distance: 1000, duration: 100, geometry: { coordinates: [[10, 48], [11, 49]] }, legs: [{ distance: 1000, duration: 100 }] }],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const pair = router.route(2, 1, 1, points.slice(0, 2), 'driving', []);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await pair;
|
||||||
|
expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(
|
||||||
|
expect.not.stringContaining('continue_straight'),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
it('does not apply car avoidance to walking routes', async () => {
|
it('does not apply car avoidance to walking routes', async () => {
|
||||||
const route = await setup().router.route(1, 1, 1, points, 'walking', ['toll']);
|
const route = await setup().router.route(1, 1, 1, points, 'walking', ['toll']);
|
||||||
expect(route.avoidMissed).toEqual([]);
|
expect(route.avoidMissed).toEqual([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user