From c9e0fe52d54a9a630c50c99f564295df5cbc2dff Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 20:16:13 +0200 Subject: [PATCH] wip(roadtrip): the point where the tank runs dry, as a place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/Roadtrip/corridor.test.ts | 59 ++++++++++ client/src/components/Roadtrip/corridor.ts | 53 +++++++++ .../components/Roadtrip/roadtripModel.test.ts | 108 ++++++++++++++++++ .../src/components/Roadtrip/roadtripModel.ts | 52 ++++++++- .../components/Roadtrip/useRoadtripRoutes.ts | 28 ++++- 5 files changed, 295 insertions(+), 5 deletions(-) diff --git a/client/src/components/Roadtrip/corridor.test.ts b/client/src/components/Roadtrip/corridor.test.ts index 4a2df1a92..97b8b1013 100644 --- a/client/src/components/Roadtrip/corridor.test.ts +++ b/client/src/components/Roadtrip/corridor.test.ts @@ -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) + }) +}) diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts index a00a32760..1e769b111 100644 --- a/client/src/components/Roadtrip/corridor.ts +++ b/client/src/components/Roadtrip/corridor.ts @@ -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, + } +} diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 9dfc297d2..5d9d2a2e4 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -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() + }) +}) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 753eee654..85e834fa5 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -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 { diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 77728647f..4dbc83d02 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -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, })