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': '过夜',