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, })