wip(roadtrip): the point where the tank runs dry, as a place

deriveDriveWarnings knew the distance and threw the position away. The warning is filed
against the stop the car ARRIVES at, which can be most of a leg past the point the fuel
actually ran out — fine for a warning, useless for a suggestion, because a filling
station offered at the warning is one the car cannot reach.

emptyAt is a fourth field on the return object rather than a field on each warning, for
two reasons. Five tests assert warnings with toEqual and an extra defined property fails
them all. And a dry point is once per TANK while a warning is once per stop: a long
stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning
would stack three identical offers for one fill-up down a single day.

drivenMeters counts driving legs only, which is the whole reason it is counted here
rather than derived later. The day's drawn line concatenates every run, ferries and walks
included, while the budget skips those — so walking that line to the same figure
overshoots by the length of any crossing the day contains and puts the marker out at sea.
pointAtMeters walks the driving-only line instead.
This commit is contained in:
Maurice
2026-09-09 20:16:13 +02:00
committed by Maurice
parent 59b7c35e87
commit c9e0fe52d5
5 changed files with 295 additions and 5 deletions
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest'
import {
boxAround,
corridorTiles,
distanceToSegmentKm,
haversineKm,
pointAtMeters,
projectOntoRoute,
simplifyLine,
type LatLng,
@@ -155,3 +157,60 @@ describe('simplifyLine', () => {
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
})
})
describe('pointAtMeters', () => {
// A straight run due east along one parallel, so the arithmetic is checkable by hand:
// at 52° north a degree of longitude is about 68.5 km.
const WEST: LatLng = { lat: 52, lng: 13 }
const EAST: LatLng = { lat: 52, lng: 14 }
const LEG = haversineKm(WEST, EAST) * 1000
it('lands halfway along a single segment', () => {
const mid = pointAtMeters([WEST, EAST], LEG / 2)!
expect(mid.lat).toBeCloseTo(52, 5)
expect(mid.lng).toBeCloseTo(13.5, 3)
})
it('walks past the first segment into the second', () => {
const line = [WEST, EAST, { lat: 52, lng: 15 }]
const at = pointAtMeters(line, LEG * 1.5)!
// One and a half segments in, so halfway through the second.
expect(at.lng).toBeGreaterThan(14)
expect(at.lng).toBeLessThan(15)
})
it('returns the last point past the end rather than nothing', () => {
// A tank that runs out after the day's final stop still ran out somewhere; the
// caller decides what to do about that, so this must not answer null.
expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST)
})
it('answers the first point for zero, and nothing for an empty line', () => {
expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST)
expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST)
expect(pointAtMeters([], 100)).toBeNull()
expect(pointAtMeters([WEST], 100)).toEqual(WEST)
})
it('survives a repeated vertex without dividing by zero', () => {
// A route line concatenated from several runs does contain these.
const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!
expect(at.lng).toBeCloseTo(13.5, 3)
})
})
describe('boxAround', () => {
it('reaches the asked radius on all four sides', () => {
const box = boxAround(BERLIN, 10)
// North and south are the easy ones: a degree of latitude is constant.
expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0)
expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0)
})
it('widens the longitude span the further north it sits', () => {
// The same radius is more degrees of longitude in Tromsø than in Berlin.
const berlin = boxAround(BERLIN, 10)
const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10)
expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west)
})
})
@@ -232,3 +232,56 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
const right = simplifyLine(line.slice(index), toleranceKm)
return [...left.slice(0, -1), ...right]
}
/**
* The point that lies `metres` along a line, measured the way a car drives it.
*
* Every other helper in this file goes the other way: given a point, where on the line
* is it. This is the inverse, and it exists because the range budget produces a distance
* ("the tank is empty 340 km in") with no idea where that is on the map.
*
* Walked segment by segment rather than approximated, and interpolated inside the
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
* end it returns the last point rather than null: a tank that runs out after the day's
* final stop still ran out somewhere, and the caller decides what to do about that.
*/
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
if (!line.length) return null
if (line.length === 1 || metres <= 0) return line[0]
let covered = 0
for (let i = 1; i < line.length; i++) {
const step = haversineKm(line[i - 1], line[i]) * 1000
if (covered + step >= metres) {
// How far into this segment, as a fraction. A zero-length segment (two identical
// vertices, which a concatenated route line does produce) would divide by zero,
// and its start point is the right answer anyway.
const t = step > 0 ? (metres - covered) / step : 0
return {
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
}
}
covered += step
}
return line[line.length - 1]
}
/**
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
*
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
* r·√2 of reach and finds things in the corners that are further away than they look.
* Sized from the radius rather than guessed, and the caller still has to read the
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
*/
export function boxAround(point: LatLng, radiusKm: number): Bbox {
const dLat = radiusKm / KM_PER_DEG_LAT
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
return {
south: point.lat - dLat,
west: point.lng - dLng,
north: point.lat + dLat,
east: point.lng + dLng,
}
}
@@ -579,3 +579,111 @@ describe('deriveDriveWarnings — what counts as driving', () => {
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
})
})
describe('deriveDriveWarnings — where the tank actually runs dry', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
const ferry = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' })
it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => {
// 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in;
// the tank was empty 100 km earlier, halfway through the second leg. Suggesting a
// filling station at the warning would suggest one the car cannot reach.
const out = deriveDriveWarnings(
[drive(120, 200), drive(120, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }])
expect(out.emptyAt).toEqual([
{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => {
// A long run with nothing on it warns at every stop on purpose, because a warning is
// not a fill-up. One refuel offer per warning would stack three identical offers for
// a single tank down one day.
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
expect(out.warnings).toHaveLength(2)
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 })
})
it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => {
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
// Empty once before the fuel stop, then once more after it.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0].drivenMeters).toBe(600000)
})
it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => {
// The trap this field exists for. The budget skips a ferry, so the dry point must be
// counted in DRIVING metres only; measuring along the day's drawn line instead would
// overshoot by the whole crossing and put the marker out at sea.
const out = deriveDriveWarnings(
[drive(60, 200), ferry(120, 50), drive(60, 200)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([
{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => {
const out = deriveDriveWarnings(
[drive(60, 200), drive(60, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
250,
)
// Only 50 km left on arrival, so it empties a quarter into the first leg.
expect(out.emptyAt).toEqual([
{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => {
const noLimit = deriveDriveWarnings(
[drive(60, 900)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: null },
0,
)
expect(noLimit.emptyAt).toEqual([])
const withinRange = deriveDriveWarnings(
[drive(60, 100)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(withinRange.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => {
// The budget goes null and stays null, so there is no honest dry point to offer.
const out = deriveDriveWarnings(
[drive(60, 200), undefined, drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([])
expect(out.carryKm).toBeNull()
})
})
@@ -341,6 +341,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
* town. A point past the end lands in the last leg rather than nowhere, because that is
* a rounding edge and not a missing leg.
*/
/**
* Where a tank runs dry, in the day's own driving coordinates.
*
* A separate thing from the range WARNING, and the difference is the whole point. The
* warning is filed against the stop the car arrives at, which is where somebody finds
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
* is the only place worth suggesting a filling station.
*
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
* line: that line also carries ferry and walking runs. Converting it to a coordinate
* means walking the driving legs, which `dryPointOn` does.
*/
export interface DryPoint {
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
legIndex: number
/** How far into that leg, in kilometres. */
intoLegKm: number
/** Metres of driving from the start of the day to that point. */
drivenMeters: number
/** The range limit that was crossed, which is what the traveller set. */
sinceKm: number
}
/**
* The findings about the driving itself: too long at the wheel, too long in one day, and
* the tank running out before anywhere to fill it.
@@ -377,10 +400,16 @@ export function deriveDriveWarnings(
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
carryKm: number | null,
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
const warnings: ScheduleWarning[] = []
const emptyAt: DryPoint[] = []
let budget = carryKm
let totalSeconds = 0
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
// line carries every run, and a ferry or a walk in the middle of a day adds to it
// without adding to this. Whatever converts a dry point back into a coordinate has to
// walk the driving legs only, which is why this is counted here rather than derived.
let drivenMeters = 0
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
@@ -403,7 +432,25 @@ export function deriveDriveWarnings(
if (typeof metres !== 'number') {
budget = null
} else if (budget !== null) {
const before = budget
budget += metres / 1000
// Where the tank actually runs dry, as opposed to where somebody notices. The
// warning below sits on the arriving stop, which can be a hundred kilometres past
// the point the fuel ran out; a suggestion has to be offered at the point, or it
// suggests filling up somewhere the car cannot reach.
//
// Once per tank, not once per warning. A long stretch with nothing on it produces
// a run of warnings on purpose (see the note above), and one refuel offer per
// warning would stack three identical offers down one day for a single fill-up.
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
const intoLegKm = limits.rangeKm - before
emptyAt.push({
legIndex: i,
intoLegKm,
drivenMeters: drivenMeters + intoLegKm * 1000,
sinceKm: Math.round(limits.rangeKm),
})
}
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
// the plan working, not a problem, and a warning there would sit on the one stop
// that answers it. No reset either — a warning is not a fill-up, and the figure has
@@ -411,6 +458,7 @@ export function deriveDriveWarnings(
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
}
drivenMeters += metres
}
}
// The last stop of the day counts too: filling up on arrival is what makes the next
@@ -422,7 +470,7 @@ export function deriveDriveWarnings(
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
: null
return { warnings, day, carryKm: budget }
return { warnings, day, carryKm: budget, emptyAt }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
@@ -3,8 +3,8 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
import { resolveLegMode } from '../Planner/legMode'
import {
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid,} from './roadtripModel'
import { projectOntoRoute } from './corridor'
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel'
import { projectOntoRoute, pointAtMeters} from './corridor'
import { useSettingsStore } from '../../store/settingsStore'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { spurFor } from './accessSpur'
@@ -61,6 +61,13 @@ export interface RoadtripDay {
* has to be able to say so instead of wearing a label the road disproves.
*/
avoidMissed?: RouteAvoidClass[]
/**
* Where the tank runs dry on this day, once per fill-up, with a coordinate.
*
* Empty when no range limit is set, when the day never crosses it, or when a leg did
* not route — the budget gives up rather than guessing, and so does this.
*/
dryPoints?: (DryPoint & { lat: number; lng: number })[]
/**
* The roads actually driven that day, as [lat, lng] — not the straight lines between
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
@@ -539,8 +546,23 @@ export function useRoadtripRoutes(
carryKm,
)
carryKm = drive.carryKm
// Where each tank runs dry, as a place rather than a distance.
//
// Walked along the DRIVING legs only. `geometry` above is every run end to end,
// ferries and walks included, while the budget counts none of those — so walking
// the drawn line to the same figure overshoots by the whole length of any crossing
// the day happens to contain, and the marker lands a ferry's width off the road.
const drivingLine = routed
.filter(l => l && l.seg.mode !== undefined && l.seg.mode === 'driving')
.flatMap(l => l?.line ?? [])
const dryPoints = drive.emptyAt
.map(dry => {
const at = pointAtMeters(drivingLine.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters)
return at ? { ...dry, lat: at.lat, lng: at.lng } : null
})
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null)
out.push({
...day, stops, legs, legVias, schedule, geometry, distance, duration,
...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints,
driveWarnings: drive.warnings,
dayWarning: drive.day,
})