From 95d1752a3ddf45d767d9b84e377e2b5278b5996a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 20:41:56 +0200 Subject: [PATCH] feat(roadtrip): suggest where to fill up, not just warn that you cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The range budget has always been able to say "you have driven 245 km on this tank" and never "and here is where to do something about it". The review put it plainly: the value is in the suggestion, not in the warning. The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The fuel runs out earlier, often most of a leg earlier, and a filling station offered at the warning is one the car cannot reach. So the dry point is computed where it actually is, a search runs around it on request, and what comes back is filtered to what can be reached. Three filters, each one there because its absence gives a wrong answer rather than an untidy one. Only what lies before the tank empties, because a pump past it is on the far side of an empty tank. The detour counts double, because 8 km off the road is 16 km of driving. And a reserve is kept back, because the range figure is a round number from a manual, not a measurement. Sorted by the detour rather than by how far it gets. Measured on a real day between Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one kilometre more of range, while one 0.9 km off sat almost as late. Deduplicated by name and half a kilometre, which also came out of that measurement. OSM maps a charging site as one node per socket: the first three offers were the same "autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m apart with 450 m between the pairs, while genuinely different branches of one chain were 18 to 36 km apart — so anything from 500 m upwards separates the two cases. An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after a request that failed or was cut short states something that was never checked, and the server flags both cases itself. Offline it does not offer at all: the place write queues offline but the day assignment does not, so accepting one would leave an orphan. Nothing is searched until it is asked for, and one press is one request against a small circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to the same popup a corridor hit goes through, so the stop kind, the dwell time, the position, the via re-anchoring and the re-route are the paths that already work. The dry point counts DRIVING metres only. 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 would overshoot by the length of any crossing and put the marker out at sea. --- .../components/Roadtrip/RoadtripSidebar.tsx | 145 +++++++++++++++++- .../Roadtrip/refuelSuggestion.test.ts | 121 +++++++++++++++ .../components/Roadtrip/refuelSuggestion.ts | 120 +++++++++++++++ .../components/Roadtrip/useRefuelSearch.ts | 92 +++++++++++ .../components/Roadtrip/useRoadtripRoutes.ts | 12 +- client/src/pages/TripPlannerPage.tsx | 5 +- .../src/pages/tripPlanner/useTripPlanner.ts | 50 +++++- shared/src/i18n/ar/roadtrip.ts | 8 + shared/src/i18n/br/roadtrip.ts | 8 + shared/src/i18n/ca/roadtrip.ts | 8 + shared/src/i18n/cs/roadtrip.ts | 8 + shared/src/i18n/de/roadtrip.ts | 8 + shared/src/i18n/en/roadtrip.ts | 8 + shared/src/i18n/es/roadtrip.ts | 8 + shared/src/i18n/fr/roadtrip.ts | 8 + shared/src/i18n/gr/roadtrip.ts | 8 + shared/src/i18n/hu/roadtrip.ts | 8 + shared/src/i18n/id/roadtrip.ts | 8 + shared/src/i18n/it/roadtrip.ts | 8 + shared/src/i18n/ja/roadtrip.ts | 8 + shared/src/i18n/ko/roadtrip.ts | 8 + shared/src/i18n/nl/roadtrip.ts | 8 + shared/src/i18n/pl/roadtrip.ts | 8 + shared/src/i18n/ru/roadtrip.ts | 8 + shared/src/i18n/sv/roadtrip.ts | 8 + shared/src/i18n/tr/roadtrip.ts | 8 + shared/src/i18n/uk/roadtrip.ts | 8 + shared/src/i18n/vi/roadtrip.ts | 8 + shared/src/i18n/zh-TW/roadtrip.ts | 8 + shared/src/i18n/zh/roadtrip.ts | 8 + 30 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 client/src/components/Roadtrip/refuelSuggestion.test.ts create mode 100644 client/src/components/Roadtrip/refuelSuggestion.ts create mode 100644 client/src/components/Roadtrip/useRefuelSearch.ts diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index fd44ac820..5571b133e 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -5,6 +5,9 @@ import { type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' +import type { RefuelSearch } from './useRefuelSearch' +import type { RefuelCandidate } from './refuelSuggestion' +import type { DryPoint } from './roadtripModel' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' @@ -35,6 +38,15 @@ interface RoadtripSidebarProps { onMoveStopToDay?: (fromDayId: number, assignmentId: number, toDayId: number, toIndex: number) => void /** Asks for other ways of driving one leg (#1797). */ onAskAlternatives?: (dayId: number, legIndex: number) => void + /** + * The one-shot search for somewhere to fill up before the tank runs out. + * + * Absent leaves the range findings as they were, a warning and nothing else — which is + * also what a viewer sees, since accepting one writes a stop. + */ + refuel?: RefuelSearch + onAskRefuel?: (dayId: number, dry: DryPoint & { lat: number; lng: number }) => void + onAcceptRefuel?: (dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => void /** Which leg's alternatives are on show, so the rail can mark it. */ openAlternatives?: { dayId: number; index: number } | null /** @@ -292,6 +304,108 @@ function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement * distance and the time belong together, and it leaves the row's right edge for the * button instead of a second number. */ + +/** + * Where the tank runs out on this leg, and somewhere to do something about it. + * + * Sits under the drive band rather than on the stop that carries the range warning, + * because those are two different places: the warning is filed where somebody finds out, + * this is where the fuel actually ends. A filling station offered at the warning is one + * the car cannot reach. + * + * Nothing is searched until it is asked for. Every press is a real request against a + * shared service, which is the same reason the corridor search next door is manual. + */ +function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { + dry: DryPoint & { lat: number; lng: number } + refuel: RefuelSearch + dayId: number + onAsk: () => void + onAccept?: (poi: RefuelCandidate) => void +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const key = `${dayId}:${dry.legIndex}` + const open = refuel.openFor === key + + return ( +
+
+ + + {t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })} + + {open ? ( + + ) : ( + + )} +
+ + {open && refuel.loading ? ( + {t('roadtrip.refuel.looking')} + ) : null} + + {open && !refuel.loading && refuel.outcome ? ( + refuel.results.length ? ( + + ) : ( + /* Three different sentences for three different facts. "Nothing on this + stretch" after a request that failed or was cut short states something that + was never checked, and that is worse than saying nothing. */ + + {refuel.outcome === 'none' + ? t('roadtrip.refuel.none') + : refuel.outcome === 'incomplete' + ? t('roadtrip.refuel.incomplete') + : t('roadtrip.refuel.failed')} + + ) + ) : null} +
+ ) +} + function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { leg: RouteSegment | undefined /** Asks for other ways of driving this leg. Absent means the route is not editable. */ @@ -813,7 +927,7 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React * (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across * the trip would put "17" beside a pin the map calls "3". */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: { day: RoadtripDay selectedAssignmentId?: number | null onSelectStop?: (placeId: number, assignmentId: number) => void @@ -828,6 +942,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on onFollowTrack?: RoadtripSidebarProps['onFollowTrack'] viaCount?: number trackName?: string + refuel?: RefuelSearch + onAskRefuel?: RoadtripSidebarProps['onAskRefuel'] + onAcceptRefuel?: RoadtripSidebarProps['onAcceptRefuel'] + /** True while any day is still routing; the range findings are not settled until then. */ + loading?: boolean }): React.ReactElement { const { from, setFrom, dropAt, setDropAt } = drag const dragging = from?.dayId === day.dayId ? from.index : null @@ -1002,6 +1121,24 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} /> ) : null} + {/* Only on the leg the fuel actually runs out on, and only once the day has + finished routing: the warnings are republished after every routing task + and the early ones are wrong, so an offer that appears and moves while + the trip loads reads as a fault. */} + {refuel && !loading + ? (day.dryPoints ?? []) + .filter(dry => dry.legIndex === i) + .map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} + /> + )) + : null} {/* After the band, because a plugin halt happens on the drive it describes rather than before setting off. */} {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( @@ -1100,7 +1237,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: { */ export default function RoadtripSidebar({ routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, - onSetStopKind, onFollowTrack, viaCounts, trackNames, + onSetStopKind, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, }: RoadtripSidebarProps): React.ReactElement { const { t } = useTranslation() // One drag state for the whole rail rather than one per day: a stop that cannot leave @@ -1155,6 +1292,10 @@ export default function RoadtripSidebar({ onFollowTrack={onFollowTrack} viaCount={viaCounts?.[day.dayId] ?? 0} trackName={trackNames?.[day.dayId]} + refuel={refuel} + onAskRefuel={onAskRefuel} + onAcceptRefuel={onAcceptRefuel} + loading={routes.loading} /> ))} {routes.quietDays.map(day => ( diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts new file mode 100644 index 000000000..befbfc46e --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { reachableRefuels, outcomeOf } from './refuelSuggestion' +import type { LatLng } from './corridor' +import type { Poi } from '../Map/poiCategories' + +// A straight run due east at 52° north, where a degree of longitude is about 68.5 km. +const LINE: LatLng[] = [ + { lat: 52, lng: 13 }, + { lat: 52, lng: 14 }, + { lat: 52, lng: 15 }, +] + +const poi = (name: string, lat: number, lng: number): Poi => + ({ osm_id: `node:${name}`, name, lat, lng } as Poi) + +describe('reachableRefuels', () => { + it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => { + // Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side + // of an empty tank, and offering it is offering a walk. + const early = poi('early', 52, 13.73) // ~50 km + const late = poi('late', 52, 14.9) // ~130 km + const out = reachableRefuels([early, late], LINE, 100) + expect(out.map(p => p.name)).toEqual(['early']) + }) + + it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => { + // Both sit at the same point along the road, 60 km in, with 100 km of range. One is + // on the road; the other is 20 km off it, so 40 km of driving, which no longer fits + // in the 40 km that are left once the reserve is kept back. + const onRoad = poi('on-road', 52, 13.876) + const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north + const out = reachableRefuels([onRoad, offRoad], LINE, 100) + expect(out.map(p => p.name)).toEqual(['on-road']) + }) + + it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => { + // 8 km before the dry point is inside the ten kilometre reserve. + const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km + expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([]) + }) + + it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => { + // Measured on a real day: the station that got furthest was 9.2 km off the road for + // one kilometre more of range. Everything in this list is already reachable, so what + // separates them is what the stop costs. + const nearRoad = poi('near-road', 52, 13.15) // on the line, early + const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later + const out = reachableRefuels([bigDetour, nearRoad], LINE, 100) + expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour']) + }) + + it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => { + // Filling up early wastes the tank that was already paid for. + const early = poi('early', 52, 13.15) + const later = poi('later', 52, 14.16) + const out = reachableRefuels([early, later], LINE, 100) + expect(out.map(p => p.name)).toEqual(['later', 'early']) + }) + + it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => { + // Matched on position, not on id: the same station added by hand or from another + // source carries a different osm_id. + const pump = poi('pump', 52, 13.73) + const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] }) + expect(out).toEqual([]) + }) + + it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => { + const wayOff = poi('way-off', 49, 13.5) + expect(reachableRefuels([wayOff], LINE, 100)).toEqual([]) + }) +}) + +describe('outcomeOf', () => { + it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => { + // The distinction the whole band hangs on. Saying "none on this stretch" after a + // failed request states a fact that was never checked. + expect(outcomeOf([], null)).toBe('failed') + expect(outcomeOf([], { truncated: true })).toBe('incomplete') + expect(outcomeOf([], { clamped: true })).toBe('incomplete') + expect(outcomeOf([], {})).toBe('none') + }) + + it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => { + const found = [{ name: 'x' }] as never + expect(outcomeOf(found, { truncated: true })).toBe('found') + }) +}) + +describe('reachableRefuels — one entry per station', () => { + const at = (name: string, lat: number, lng: number): Poi => + ({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi) + + it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => { + // Measured on a real day: the top three offers were the same "autostrom plus GmbH" + // three times over, because OSM maps every socket as its own node. + // The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m + // between the pairs. + const sockets = [ + at('autostrom plus GmbH', 52, 13.3), + at('autostrom plus GmbH', 52.00005, 13.30005), + at('autostrom plus GmbH', 52.004, 13.3005), + at('autostrom plus GmbH', 52.00405, 13.30045), + ] + const out = reachableRefuels(sockets, LINE, 100) + expect(out).toHaveLength(1) + }) + + it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => { + // Name alone is not identity: a chain has a branch everywhere. + // Measured: genuinely different branches were 18 to 36 km apart. + const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100) + expect(out).toHaveLength(2) + }) + + it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => { + // Position alone is not identity either. + const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100) + expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell']) + }) +}) diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts new file mode 100644 index 000000000..21721c413 --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.ts @@ -0,0 +1,120 @@ +import { haversineKm, projectOntoRoute, type LatLng } from './corridor' +import type { Poi } from '../Map/poiCategories' + +/** + * Picking a filling station for a tank that is about to run out. + * + * Pure: no React, no network. The caller does the one request and hands the answers + * here, which keeps the judgement — which of these can the car actually reach — testable + * without a server. + */ + +/** A candidate, with the two figures that decide whether it is any use. */ +export interface RefuelCandidate extends Poi { + /** Kilometres along the day's driving line, in the same space as the dry point. */ + alongKm: number + /** How far off the road it sits. */ + offRouteKm: number + /** Kilometres of range still in the tank when the car draws level with it. */ + spareKm: number +} + +/** + * How much of the last drop to keep back, in kilometres. + * + * Arriving at a pump with nothing left is not a plan, and the range figure a traveller + * types is a round number from a manual rather than a measurement. Ten kilometres is + * small enough not to move the suggestion and large enough not to promise the + * impossible. + */ +const RESERVE_KM = 10 + +/** + * The stations that can actually be reached, nearest to the dry point first. + * + * Three filters, and each one exists because its absence produces a wrong answer rather + * than an untidy one: + * + * - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the + * far side of an empty tank, and offering it is offering a walk. + * - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a + * tank this close to empty that is the difference between arriving and not. + * - What is left has to fit in the range that remains, minus a reserve. + * + * Sorted by the detour, not by how far along the road it sits. Measured against a real + * day: the station that got furthest was 9.2 km off the road for one kilometre more of + * range, while one 0.9 km off sat almost as late. Everything in this list is already + * reachable, so what separates them is what the stop costs, and that is the detour. Ties + * go to the later one, because filling up early wastes the tank you already paid for. + */ +export function reachableRefuels( + pois: Poi[], + line: LatLng[], + dryAlongKm: number, + { existing = [] }: { existing?: { lat: number; lng: number }[] } = {}, +): RefuelCandidate[] { + const out: RefuelCandidate[] = [] + for (const poi of pois) { + const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line) + if (!hit) continue + // Reached before the tank empties, once the detour out and back is paid for. + const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM + if (spareKm < 0) continue + // Already planned. Matched on position rather than on id: the same pump added by + // hand, or from another source, carries a different osm_id and would otherwise be + // suggested next to itself. + if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue + out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm }) + } + return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm)) +} + +/** + * What a search actually established, as opposed to what it returned. + * + * An empty list means one of two very different things, and saying the wrong one is + * worse than saying nothing: either there is no filling station on this stretch, or we + * never managed to look. The server flags the second case itself (`truncated` when it + * hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the + * third. Only `none` is a statement about the road. + */ +export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed' + +export function outcomeOf( + candidates: RefuelCandidate[], + answer: { truncated?: boolean; clamped?: boolean } | null, +): RefuelOutcome { + if (!answer) return 'failed' + if (candidates.length) return 'found' + return answer.truncated || answer.clamped ? 'incomplete' : 'none' +} + +/** + * One entry per filling station, not one per pump. + * + * A charging site is mapped in OSM as a node per socket and a forecourt often as one per + * pump, all with the same name a few metres apart. Deduplicating by id keeps every one of + * them, which is right on a map and wrong in a list of three offers: measured on a real + * day, the top three were the same "autostrom plus GmbH" three times over. + * + * Same name and within half a kilometre counts as the same place. Both halves matter: + * not position alone, because two operators do share a motorway services, and not name + * alone, because a chain has a branch in every town. + * + * Half a kilometre comes from the data rather than from taste. In one 25 km search the + * four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between + * the pairs — while genuinely different branches of the same chain were 18 to 36 km + * apart. Anything from 500 m to a few kilometres separates those two cases; the lower end + * is the safer place to stand. + */ +const SAME_PLACE_KM = 0.5 + +function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] { + const kept: RefuelCandidate[] = [] + for (const poi of sorted) { + const twin = kept.some(k => + k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM) + if (!twin) kept.push(poi) + } + return kept +} diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts new file mode 100644 index 000000000..faf83735e --- /dev/null +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -0,0 +1,92 @@ +import { useCallback, useRef, useState } from 'react' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { isEffectivelyOffline } from '../../sync/networkMode' +import { boxAround, type LatLng } from './corridor' +import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion' + +/** + * Somewhere to fill up before the tank runs out, asked for once, when asked for. + * + * Deliberately NOT automatic, for the same reason the corridor search next door is not: + * every run is a real request against a shared service. It is also not the corridor + * search itself, although it looks like a smaller version of it. Three reasons, all + * about behaviour rather than tidiness: + * + * - that search is bound to the day the panel has selected, so it cannot answer a + * warning on day five while the panel shows day one; + * - starting it clears the panel's results and the traveller's own name filter; + * - it asks up to sixteen boxes along a whole day. This question needs one small circle. + * + * One request, one day, no shared state touched. + */ + +/** How far around the dry point to look, in kilometres. */ +const LOOK_KM = 25 + +export interface RefuelSearch { + /** Which dry point is being answered, as `:`, or null when idle. */ + openFor: string | null + loading: boolean + outcome: RefuelOutcome | null + results: RefuelCandidate[] + /** Runs the one request. `key` identifies the dry point, so only one is open at a time. */ + ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise + close: () => void +} + +export function useRefuelSearch(): RefuelSearch { + const { locale } = useTranslation() + const [openFor, setOpenFor] = useState(null) + const [loading, setLoading] = useState(false) + const [outcome, setOutcome] = useState(null) + const [results, setResults] = useState([]) + const abortRef = useRef(null) + + const close = useCallback(() => { + abortRef.current?.abort() + abortRef.current = null + setOpenFor(null) + setResults([]) + setOutcome(null) + setLoading(false) + }, []) + + const ask = useCallback(async ( + key: string, + at: LatLng, + line: LatLng[], + dryAlongKm: number, + existing: LatLng[], + ) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setOpenFor(key) + setResults([]) + setOutcome(null) + + // Offline the answer would be a list of places that cannot be saved: the place write + // queues offline but the day assignment does not, so accepting one would leave an + // orphan. Say so instead of offering it. + if (isEffectivelyOffline()) { + setOutcome('failed') + return + } + + setLoading(true) + try { + const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal) + if (controller.signal.aborted) return + const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }) + setResults(candidates) + setOutcome(outcomeOf(candidates, answer)) + } catch { + if (!controller.signal.aborted) setOutcome('failed') + } finally { + if (!controller.signal.aborted) setLoading(false) + } + }, [locale]) + + return { openFor, loading, outcome, results, ask, close } +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 4dbc83d02..b0d1e310a 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -68,6 +68,11 @@ export interface RoadtripDay { * not route — the budget gives up rather than guessing, and so does this. */ dryPoints?: (DryPoint & { lat: number; lng: number })[] + /** + * `geometry` with the non-driving runs left out, which is what `dryPoints` are measured + * along. The same array as `geometry` on a day that drives the whole way. + */ + drivingGeometry?: [number, 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 @@ -561,8 +566,13 @@ export function useRoadtripRoutes( return at ? { ...dry, lat: at.lat, lng: at.lng } : null }) .filter((d): d is DryPoint & { lat: number; lng: number } => d !== null) + // The same line the dry points are measured along, so anything offered against + // them is projected in the same space. Handed on as the SAME array when the day + // drives all the way through, which is nearly every day — only a day carrying a + // ferry or a walk pays for a second copy. + const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine out.push({ - ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, + ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry, driveWarnings: drive.warnings, dayWarning: drive.day, }) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index f13264469..514999e89 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopKind, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, - routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, @@ -549,6 +549,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined} viaCounts={roadtripViaCounts} trackNames={followTrack.namesByDay} + refuel={refuel} + onAskRefuel={askRefuel} + onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined} /> ) : ( diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index dce17731d..5a87b0139 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -24,6 +24,8 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes' import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' +import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' +import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion' import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack' import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives' import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays' @@ -34,8 +36,7 @@ import { reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, - reanchorAfterReorder, -} from '../../components/Roadtrip/roadtripModel' + reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -539,6 +540,7 @@ export function useTripPlanner() { // Passing no days while the mode is off keeps it inert — no routing requests, no state. const roadtripActive = !!enabledAddons.roadtrip && roadtripMode const roadtripVias = useRoadtripVias(tripId, roadtripActive) + const refuel = useRefuelSearch() const roadtripRoutes = useRoadtripRoutes( tripId, roadtripActive ? days : EMPTY_DAYS, @@ -1204,6 +1206,49 @@ export function useTripPlanner() { } }, [anchorFor, roadtripVias, can, trip, toast, t]) + /** + * Somewhere to fill up before this tank runs out. + * + * Measured against the day's DRIVING line, the same one the dry point was placed on, so + * a station's distance along the road is comparable with the distance the fuel lasts. + * The day's own places go in as well: a pump already on the plan should not be offered + * beside itself. + */ + const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => { + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) + if (line.length < 2) return + void refuel.ask( + `${dayId}:${dry.legIndex}`, + { lat: dry.lat, lng: dry.lng }, + line, + dry.drivenMeters / 1000, + day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })), + ) + }, [roadtripRoutes.days, refuel]) + + /** + * Accepting one hands it to the same popup a corridor hit goes through. + * + * Deliberately not a direct write: the popup is where the stop kind and the time spent + * are decided, it defaults both from the category, and every step after it — the place, + * the assignment at the right position, the via re-anchoring, the re-route — is already + * correct there and pinned by tests. A second path to the same end would be a second + * place for it to go wrong. + */ + const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => { + if (!can('day_edit', trip)) return + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + // Before the stop the tank would have run out on, which is the leg the dry point + // names. A station reached after the day's last stop is tomorrow's problem, and + // clamping it onto the final leg would re-route the arrival through it. + const position = Math.min(dry.legIndex + 1, day.stops.length - 1) + refuel.close() + setStopDraft({ poi, dayId, position, dayNumber: day.dayNumber }) + }, [roadtripRoutes.days, refuel, can, trip]) + /** Removing a via lets the drive take the direct road again. */ const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { if (!can('day_edit', trip)) return @@ -1824,6 +1869,7 @@ export function useTripPlanner() { setRoadtripStopKind, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, + refuel, askRefuel, acceptRefuel, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 986ea8e64..8dc5a24d1 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'تجنُّب {count}', 'roadtrip.avoid.missed': 'لا مفر من {classes}', 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', + 'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}', + 'roadtrip.refuel.find': 'ابحث عن وقود', + 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…', + 'roadtrip.refuel.spare': 'يتبقى {distance}', + 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود', + 'roadtrip.refuel.none': 'لم يُعثر على شيء في المسافة التي يمكن بلوغها.', + 'roadtrip.refuel.incomplete': 'اقتُطع البحث، لذا لم تُفحص هذه المسافة بالكامل.', + 'roadtrip.refuel.failed': 'لم يستجب البحث عن الأماكن.', 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 792bfe69b..4333b3b7c 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitável', 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', + 'roadtrip.refuel.dry': 'O tanque acaba neste trecho, após {distance}', + 'roadtrip.refuel.find': 'Buscar combustível', + 'roadtrip.refuel.looking': 'Procurando ao longo da rota…', + 'roadtrip.refuel.spare': 'restam {distance}', + 'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento', + 'roadtrip.refuel.none': 'Nada encontrado no trecho alcançável.', + 'roadtrip.refuel.incomplete': 'A busca foi cortada, então este trecho não foi verificado por completo.', + 'roadtrip.refuel.failed': 'A busca de lugares não respondeu.', 'roadtrip.stop.none': 'Um destino', 'roadtrip.stay.mode.pause': 'Pausa', 'roadtrip.stay.mode.night': 'Pernoite', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index f4d692377..910f9018b 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitats', 'roadtrip.avoid.missed': '{classes} inevitable', 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', + 'roadtrip.refuel.dry': 'El dipòsit s’esgota en aquesta etapa, després de {distance}', + 'roadtrip.refuel.find': 'Cerca combustible', + 'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…', + 'roadtrip.refuel.spare': 'queden {distance}', + 'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible', + 'roadtrip.refuel.none': 'No s’ha trobat res al tram accessible.', + 'roadtrip.refuel.incomplete': 'La cerca s’ha tallat, així que aquest tram no s’ha comprovat del tot.', + 'roadtrip.refuel.failed': 'La cerca de llocs no ha respost.', 'roadtrip.stop.none': 'Una destinació', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 5782bfab8..9e9a9568d 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vynecháno', 'roadtrip.avoid.missed': '{classes} nelze objet', 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', + 'roadtrip.refuel.dry': 'Na tomto úseku dojde palivo, po {distance}', + 'roadtrip.refuel.find': 'Najít palivo', + 'roadtrip.refuel.looking': 'Hledám podél trasy…', + 'roadtrip.refuel.spare': 'zbývá {distance}', + 'roadtrip.refuel.add': 'Přidat {name} jako zastávku na palivo', + 'roadtrip.refuel.none': 'Na dosažitelném úseku nic nenalezeno.', + 'roadtrip.refuel.incomplete': 'Hledání bylo zkráceno, tento úsek tedy není zkontrolovaný celý.', + 'roadtrip.refuel.failed': 'Vyhledávání míst neodpovědělo.', 'roadtrip.stop.none': 'Cíl cesty', 'roadtrip.stay.mode.pause': 'Pauza', 'roadtrip.stay.mode.night': 'Nocleh', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index fcf9d6679..9d45278c3 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} gemieden', 'roadtrip.avoid.missed': '{classes} unvermeidbar', 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', + 'roadtrip.refuel.dry': 'Auf dieser Etappe geht der Tank leer, nach {distance}', + 'roadtrip.refuel.find': 'Tankstelle suchen', + 'roadtrip.refuel.looking': 'Suche entlang der Strecke…', + 'roadtrip.refuel.spare': 'noch {distance}', + 'roadtrip.refuel.add': '{name} als Tankstopp übernehmen', + 'roadtrip.refuel.none': 'Auf der erreichbaren Strecke nichts gefunden.', + 'roadtrip.refuel.incomplete': 'Die Suche wurde abgeschnitten, die Strecke ist also nicht vollständig geprüft.', + 'roadtrip.refuel.failed': 'Die Ortssuche hat nicht geantwortet.', 'roadtrip.stop.none': 'Ein Ziel', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Übernachten', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index f98714b89..249dfbc1a 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} avoided', 'roadtrip.avoid.missed': '{classes} unavoidable', 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', + 'roadtrip.refuel.dry': 'The tank runs out on this leg, {distance} in', + 'roadtrip.refuel.find': 'Find fuel', + 'roadtrip.refuel.looking': 'Looking along the route…', + 'roadtrip.refuel.spare': '{distance} to spare', + 'roadtrip.refuel.add': 'Add {name} as a fuel stop', + 'roadtrip.refuel.none': 'Nothing found on the reachable stretch.', + 'roadtrip.refuel.incomplete': 'The search was cut short, so this stretch was not fully checked.', + 'roadtrip.refuel.failed': 'The place search did not answer.', 'roadtrip.stop.none': 'A destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Overnight', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 14f32415b..b9d907570 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitable(s)', 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', + 'roadtrip.refuel.dry': 'El depósito se vacía en este tramo, tras {distance}', + 'roadtrip.refuel.find': 'Buscar combustible', + 'roadtrip.refuel.looking': 'Buscando a lo largo de la ruta…', + 'roadtrip.refuel.spare': 'quedan {distance}', + 'roadtrip.refuel.add': 'Añadir {name} como parada de repostaje', + 'roadtrip.refuel.none': 'No se encontró nada en el tramo alcanzable.', + 'roadtrip.refuel.incomplete': 'La búsqueda se cortó, así que este tramo no se comprobó por completo.', + 'roadtrip.refuel.failed': 'La búsqueda de lugares no respondió.', 'roadtrip.stop.none': 'Un destino', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 245059321..6b40b2232 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} évité(s)', 'roadtrip.avoid.missed': '{classes} inévitable(s)', 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', + 'roadtrip.refuel.dry': 'Le réservoir se vide sur cette étape, après {distance}', + 'roadtrip.refuel.find': 'Chercher du carburant', + 'roadtrip.refuel.looking': 'Recherche le long du trajet…', + 'roadtrip.refuel.spare': 'reste {distance}', + 'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant', + 'roadtrip.refuel.none': 'Rien trouvé sur la portion accessible.', + 'roadtrip.refuel.incomplete': 'La recherche a été tronquée, cette portion n’a donc pas été entièrement vérifiée.', + 'roadtrip.refuel.failed': 'La recherche de lieux n’a pas répondu.', 'roadtrip.stop.none': 'Une destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Nuitée', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 369c33372..87ce149a4 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} αποφεύγονται', 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', + 'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}', + 'roadtrip.refuel.find': 'Εύρεση καυσίμων', + 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…', + 'roadtrip.refuel.spare': 'μένουν {distance}', + 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού', + 'roadtrip.refuel.none': 'Δεν βρέθηκε τίποτα στο προσβάσιμο τμήμα.', + 'roadtrip.refuel.incomplete': 'Η αναζήτηση κόπηκε, οπότε το τμήμα δεν ελέγχθηκε πλήρως.', + 'roadtrip.refuel.failed': 'Η αναζήτηση τοποθεσιών δεν απάντησε.', 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 81cc19e3c..e3445d086 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kerülve', 'roadtrip.avoid.missed': '{classes} nem kerülhető', 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', + 'roadtrip.refuel.dry': 'Ezen a szakaszon fogy ki a tank, {distance} után', + 'roadtrip.refuel.find': 'Üzemanyag keresése', + 'roadtrip.refuel.looking': 'Keresés az útvonal mentén…', + 'roadtrip.refuel.spare': 'marad {distance}', + 'roadtrip.refuel.add': '{name} hozzáadása tankolási megállóként', + 'roadtrip.refuel.none': 'Az elérhető szakaszon nincs találat.', + 'roadtrip.refuel.incomplete': 'A keresés csonkult, így ez a szakasz nincs teljesen ellenőrizve.', + 'roadtrip.refuel.failed': 'A helykeresés nem válaszolt.', 'roadtrip.stop.none': 'Úti cél', 'roadtrip.stay.mode.pause': 'Szünet', 'roadtrip.stay.mode.night': 'Éjszakázás', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 9b36fafab..f6071b000 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} dihindari', 'roadtrip.avoid.missed': '{classes} tak terhindarkan', 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', + 'roadtrip.refuel.dry': 'Tangki habis di ruas ini, setelah {distance}', + 'roadtrip.refuel.find': 'Cari bahan bakar', + 'roadtrip.refuel.looking': 'Mencari di sepanjang rute…', + 'roadtrip.refuel.spare': 'sisa {distance}', + 'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar', + 'roadtrip.refuel.none': 'Tidak ada yang ditemukan di ruas yang terjangkau.', + 'roadtrip.refuel.incomplete': 'Pencarian terpotong, jadi ruas ini belum diperiksa sepenuhnya.', + 'roadtrip.refuel.failed': 'Pencarian tempat tidak menjawab.', 'roadtrip.stop.none': 'Sebuah tujuan', 'roadtrip.stay.mode.pause': 'Istirahat', 'roadtrip.stay.mode.night': 'Menginap', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 9a9127bb4..ba741a196 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitati', 'roadtrip.avoid.missed': '{classes} inevitabili', 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', + 'roadtrip.refuel.dry': 'Il serbatoio si esaurisce in questa tappa, dopo {distance}', + 'roadtrip.refuel.find': 'Cerca carburante', + 'roadtrip.refuel.looking': 'Ricerca lungo il percorso…', + 'roadtrip.refuel.spare': 'restano {distance}', + 'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante', + 'roadtrip.refuel.none': 'Niente trovato nel tratto raggiungibile.', + 'roadtrip.refuel.incomplete': 'La ricerca è stata troncata, quindi questo tratto non è stato controllato del tutto.', + 'roadtrip.refuel.failed': 'La ricerca dei luoghi non ha risposto.', 'roadtrip.stop.none': 'Una destinazione', 'roadtrip.stay.mode.pause': 'Sosta', 'roadtrip.stay.mode.night': 'Pernottamento', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 8de6f1eb1..70808dd28 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} 件を回避', 'roadtrip.avoid.missed': '{classes} は回避できず', 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', + 'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)', + 'roadtrip.refuel.find': '給油所を探す', + 'roadtrip.refuel.looking': 'ルート沿いを検索中…', + 'roadtrip.refuel.spare': '残り {distance}', + 'roadtrip.refuel.add': '{name} を給油の停車地として追加', + 'roadtrip.refuel.none': '到達できる範囲には見つかりませんでした。', + 'roadtrip.refuel.incomplete': '検索が途中で打ち切られたため、この区間は完全には調べられていません。', + 'roadtrip.refuel.failed': '場所の検索が応答しませんでした。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index d05b81de7..780ca16c7 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count}개 회피', 'roadtrip.avoid.missed': '{classes} 회피 불가', 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', + 'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)', + 'roadtrip.refuel.find': '주유소 찾기', + 'roadtrip.refuel.looking': '경로를 따라 찾는 중…', + 'roadtrip.refuel.spare': '{distance} 여유', + 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가', + 'roadtrip.refuel.none': '도달 가능한 구간에서 찾지 못했습니다.', + 'roadtrip.refuel.incomplete': '검색이 중단되어 이 구간을 완전히 확인하지 못했습니다.', + 'roadtrip.refuel.failed': '장소 검색이 응답하지 않았습니다.', 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 6219aac38..007ab2e76 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vermeden', 'roadtrip.avoid.missed': '{classes} onvermijdelijk', 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', + 'roadtrip.refuel.dry': 'De tank raakt leeg op dit traject, na {distance}', + 'roadtrip.refuel.find': 'Tankstation zoeken', + 'roadtrip.refuel.looking': 'Zoeken langs de route…', + 'roadtrip.refuel.spare': 'nog {distance}', + 'roadtrip.refuel.add': '{name} als tankstop toevoegen', + 'roadtrip.refuel.none': 'Niets gevonden op het bereikbare stuk.', + 'roadtrip.refuel.incomplete': 'De zoekopdracht werd afgekapt, dit stuk is dus niet volledig gecontroleerd.', + 'roadtrip.refuel.failed': 'De plaatszoekopdracht gaf geen antwoord.', 'roadtrip.stop.none': 'Een bestemming', 'roadtrip.stay.mode.pause': 'Pauze', 'roadtrip.stay.mode.night': 'Overnachten', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 9bba0abfe..398b36ed2 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} omijane', 'roadtrip.avoid.missed': '{classes} nie do ominięcia', 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', + 'roadtrip.refuel.dry': 'Na tym odcinku kończy się paliwo, po {distance}', + 'roadtrip.refuel.find': 'Znajdź paliwo', + 'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…', + 'roadtrip.refuel.spare': 'zostaje {distance}', + 'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo', + 'roadtrip.refuel.none': 'Nic nie znaleziono na dostępnym odcinku.', + 'roadtrip.refuel.incomplete': 'Wyszukiwanie zostało ucięte, więc odcinek nie jest sprawdzony w całości.', + 'roadtrip.refuel.failed': 'Wyszukiwarka miejsc nie odpowiedziała.', 'roadtrip.stop.none': 'Cel podróży', 'roadtrip.stay.mode.pause': 'Postój', 'roadtrip.stay.mode.night': 'Nocleg', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 194aa6885..f57ca820d 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обход', 'roadtrip.avoid.missed': '{classes} не обойти', 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', + 'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}', + 'roadtrip.refuel.find': 'Найти заправку', + 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…', + 'roadtrip.refuel.spare': 'останется {distance}', + 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки', + 'roadtrip.refuel.none': 'На доступном участке ничего не найдено.', + 'roadtrip.refuel.incomplete': 'Поиск был обрезан, поэтому участок проверен не полностью.', + 'roadtrip.refuel.failed': 'Поиск мест не ответил.', 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index f466fe0d6..8d48aa86e 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} undviks', 'roadtrip.avoid.missed': '{classes} går inte att undvika', 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', + 'roadtrip.refuel.dry': 'Tanken tar slut på den här etappen, efter {distance}', + 'roadtrip.refuel.find': 'Hitta bränsle', + 'roadtrip.refuel.looking': 'Söker längs rutten…', + 'roadtrip.refuel.spare': '{distance} kvar', + 'roadtrip.refuel.add': 'Lägg till {name} som tankstopp', + 'roadtrip.refuel.none': 'Inget hittat på den nåbara sträckan.', + 'roadtrip.refuel.incomplete': 'Sökningen kapades, så sträckan är inte helt genomsökt.', + 'roadtrip.refuel.failed': 'Platssökningen svarade inte.', 'roadtrip.stop.none': 'Ett resmål', 'roadtrip.stay.mode.pause': 'Paus', 'roadtrip.stay.mode.night': 'Övernattning', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 04eecbfd1..235c83036 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kaçınılıyor', 'roadtrip.avoid.missed': '{classes} kaçınılmaz', 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', + 'roadtrip.refuel.dry': 'Bu etapta depo bitiyor, {distance} sonra', + 'roadtrip.refuel.find': 'Yakıt bul', + 'roadtrip.refuel.looking': 'Rota boyunca aranıyor…', + 'roadtrip.refuel.spare': '{distance} kalır', + 'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin', + 'roadtrip.refuel.none': 'Ulaşılabilir bölümde bir şey bulunamadı.', + 'roadtrip.refuel.incomplete': 'Arama kesildi, bu yüzden bu bölüm tam olarak kontrol edilmedi.', + 'roadtrip.refuel.failed': 'Yer araması yanıt vermedi.', 'roadtrip.stop.none': 'Bir varış noktası', 'roadtrip.stay.mode.pause': 'Mola', 'roadtrip.stay.mode.night': 'Konaklama', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 23c3ae7d0..9f4fa7552 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обхід', 'roadtrip.avoid.missed': '{classes} не обійти', 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', + 'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}', + 'roadtrip.refuel.find': 'Знайти заправку', + 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…', + 'roadtrip.refuel.spare': 'лишиться {distance}', + 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки', + 'roadtrip.refuel.none': 'На доступній ділянці нічого не знайдено.', + 'roadtrip.refuel.incomplete': 'Пошук було обрізано, тож ділянку перевірено не повністю.', + 'roadtrip.refuel.failed': 'Пошук місць не відповів.', 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 5723bf4aa..3bc408bce 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'tránh {count}', 'roadtrip.avoid.missed': '{classes} không tránh được', 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', + 'roadtrip.refuel.dry': 'Hết xăng ở chặng này, sau {distance}', + 'roadtrip.refuel.find': 'Tìm trạm xăng', + 'roadtrip.refuel.looking': 'Đang tìm dọc tuyến đường…', + 'roadtrip.refuel.spare': 'còn {distance}', + 'roadtrip.refuel.add': 'Thêm {name} làm điểm đổ xăng', + 'roadtrip.refuel.none': 'Không tìm thấy gì trên đoạn có thể tới được.', + 'roadtrip.refuel.incomplete': 'Việc tìm kiếm bị cắt ngắn nên đoạn này chưa được kiểm tra đầy đủ.', + 'roadtrip.refuel.failed': 'Tìm kiếm địa điểm không phản hồi.', 'roadtrip.stop.none': 'Một điểm đến', 'roadtrip.stay.mode.pause': 'Nghỉ', 'roadtrip.stay.mode.night': 'Qua đêm', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 6f64cdf3a..60c93ce28 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避開 {count} 項', 'roadtrip.avoid.missed': '無法避開{classes}', 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', + 'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後', + 'roadtrip.refuel.find': '尋找加油站', + 'roadtrip.refuel.looking': '正在沿路線尋找…', + 'roadtrip.refuel.spare': '剩餘 {distance}', + 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點', + 'roadtrip.refuel.none': '在可到達的路段上沒有找到。', + 'roadtrip.refuel.incomplete': '搜尋被截斷,因此這段路線沒有完全檢查。', + 'roadtrip.refuel.failed': '地點搜尋沒有回應。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 160f0db68..81012d2c6 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避开 {count} 项', 'roadtrip.avoid.missed': '无法避开{classes}', 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', + 'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后', + 'roadtrip.refuel.find': '查找加油站', + 'roadtrip.refuel.looking': '正在沿路线查找…', + 'roadtrip.refuel.spare': '剩余 {distance}', + 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点', + 'roadtrip.refuel.none': '在可到达的路段上没有找到。', + 'roadtrip.refuel.incomplete': '搜索被截断,因此这段路线没有完全检查。', + 'roadtrip.refuel.failed': '地点搜索没有响应。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜',