feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band

Four things that only showed up once it was on screen.

The range budget treated a petrol station and a charger as the same thing, so an electric
car pausing for coffee at a filling station had its battery refilled on paper: the
warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can
now be named, and then only what actually refills it counts — and the search asks for that
kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the
old behaviour, which is what everybody who never opens the dialog is on.

The band was its own shape in a rail made of drive bands. It is one now: the same rail,
the same height, the same type size, a warning tint instead of a ring, and the figures
where a drive band keeps them. The buttons are icons of the same 18px square the shuffle
mark uses, so the band stays one line beside its neighbours.

The old range warning stood down where the band shows. Both at once said the same thing
twice in the wrong order — the answer above the problem — because the warning marks the
stop somebody finds out at while the band sits on the leg the fuel ends on.

Two measured corrections to the search itself. The server turns a box into a circle of
half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35
and came back flagged as narrowed EVERY time, which read on screen as "we could not
check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before
the dry point rather than on it, because only road already driven is any use — centred on
the point, half the circle was spent on road nobody can reach.

An answer that finds nothing now leaves the button rather than only a close. The place
search is a shared public service that does time out, and "it did not answer" with no way
to ask again reads as broken rather than as busy.
This commit is contained in:
Maurice
2026-09-09 21:59:36 +02:00
committed by Maurice
parent 95d1752a3d
commit 5b147af0b6
38 changed files with 518 additions and 125 deletions
@@ -57,3 +57,30 @@ describe('useMergedMapPois', () => {
expect(result.current).toBe(first)
})
})
describe('useMergedMapPois — the refuel offers', () => {
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
// In road trip mode this is the only channel a POI reaches the map through, so
// without it somebody is asked to accept a stop they cannot see.
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
})
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
})
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
const corridor = [poi('a')]
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
initialProps: { o: [] as Poi[] },
})
const first = result.current
rerender({ o: [] })
expect(result.current).toBe(first)
expect(result.current).toBe(corridor)
})
})
+22 -8
View File
@@ -13,16 +13,30 @@ import type { Poi } from './poiCategories'
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
* copy wins because it carries how far along the drive it sits.
*
* `offered` is the third case: the handful of filling stations proposed where a tank runs
* out. They belong on the map for a plain reason — somebody is being asked to accept a
* stop, and a stop you cannot see is not one you can judge — but they are not a search
* result and must not survive the panel being closed, which is why they arrive separately
* rather than being folded into the corridor's own list.
*
* Returns the input array itself whenever there is nothing to merge. The map redraws off
* this reference, so handing it a fresh array on every render would rebuild every pin.
*/
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] {
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
return useMemo(() => {
if (!corridor) return explore
if (!explore.length) return corridor
if (!corridor.length) return explore
const seen = new Set(corridor.map(p => p.osm_id))
const extra = explore.filter(p => !seen.has(p.osm_id))
return extra.length ? [...corridor, ...extra] : corridor
}, [corridor, explore])
const base = corridor === null ? explore : merge(corridor, explore)
return merge(base, offered)
}, [corridor, explore, offered])
}
/** Nothing offered, as one stable array, so the default never changes identity. */
const NONE: Poi[] = []
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
function merge(a: Poi[], b: Poi[]): Poi[] {
if (!b.length) return a
if (!a.length) return b
const seen = new Set(a.map(p => p.osm_id))
const extra = b.filter(p => !seen.has(p.osm_id))
return extra.length ? [...a, ...extra] : a
}
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance } from '../../utils/units'
@@ -103,6 +104,13 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
)
}
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
const VEHICLES: { key: 'combustion' | 'electric' | null; labelKey: string; Icon: typeof Car }[] = [
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
]
export default function RoadtripLimitsCard({ onSave }: {
/**
* Persists one setting. Absent leaves the dialog read-only.
@@ -123,6 +131,7 @@ export default function RoadtripLimitsCard({ onSave }: {
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
// an unknown word here would become a costing option the router does not have.
const vehicle = settings.roadtrip_vehicle ?? ''
const avoiding = parseAvoid(settings.roadtrip_avoid)
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
// car profile is built without excludable classes on every public host.
@@ -234,6 +243,25 @@ export default function RoadtripLimitsCard({ onSave }: {
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
{/* Above the range, because it decides what the range even means: with no
vehicle named, a petrol station and a charger both fill the tank, which
is arithmetic that is wrong for everybody who drives just one of them. */}
<label className="flex items-center gap-3">
<Car size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.limit.vehicleLabel')}</span>
<span className="w-[132px] shrink-0">
<CustomSelect
value={vehicle}
onChange={next => onSave?.('roadtrip_vehicle', String(next))}
size="sm"
options={VEHICLES.map(({ key, labelKey, Icon }) => ({
value: key ?? '',
label: t(labelKey),
icon: <Icon size={14} aria-hidden />,
}))}
/>
</span>
</label>
<LimitRow
icon={Fuel}
label={t('roadtrip.limit.rangeLabel')}
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle,
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban,
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X,
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
@@ -327,81 +327,110 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const key = `${dayId}:${dry.legIndex}`
const open = refuel.openFor === key
const settled = open && !refuel.loading && refuel.outcome
return (
<div className="my-1 ms-[30px] flex flex-col gap-1.5 rounded-lg border border-edge-faint bg-surface-secondary px-2.5 py-2">
<div className="flex items-center gap-2">
<Fuel size={12} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-content-secondary" style={{ fontSize: FS.label }}>
{t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })}
</span>
{open ? (
<button
type="button"
onClick={refuel.close}
className="shrink-0 rounded px-1.5 py-0.5 text-content-faint transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
style={{ fontSize: FS.label }}
>
{t('common.close')}
</button>
) : (
<button
type="button"
onClick={onAsk}
className="shrink-0 rounded bg-surface-card px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
style={{ fontSize: FS.label }}
>
{t('roadtrip.refuel.find')}
</button>
)}
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} />
</span>
<div className="min-w-0">
{/* Built as a drive band, because that is what it belongs to: the leg the fuel
runs out on. Same height, same type, same rail. The warning ring is the only
difference, and it is the whole message — this is the band where the tank
ends. */}
<div className="my-1.5 flex flex-col gap-1 rounded-lg bg-warning-soft px-2 py-1">
<div className="flex items-center gap-1.5">
<Fuel size={12} strokeWidth={1.7} className="shrink-0 text-warning" aria-hidden />
<span className="min-w-0 truncate font-medium text-content" style={{ fontSize: FS.meta }}>
{t('roadtrip.refuel.dry')}
</span>
{/* How far INTO this leg, where a drive band keeps its figures. Not the range
that was crossed: that is the traveller's own setting, says nothing about
where, and made every band on a day read the same number. */}
<span className="ms-auto shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })}
</span>
{/* An icon, the size of the shuffle mark a drive band carries, because the
band has to stay one line high beside its neighbours. What it does is in
the tooltip and in the label a screen reader gets. */}
{open && (refuel.loading || refuel.results.length) ? (
<Tooltip label={t('common.close')}>
<button
type="button"
onClick={refuel.close}
aria-label={t('common.close')}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md text-content-muted transition-colors hover:bg-surface-card hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={12} aria-hidden />
</button>
</Tooltip>
) : (
/* An answer that found nothing leaves the button, not a dead end. The place
search is a shared public service that does time out, and "it did not
answer" with no way to ask again reads as broken rather than as busy. */
<Tooltip label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}>
<button
type="button"
onClick={onAsk}
aria-label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{settled && refuel.outcome !== 'found'
? <RotateCcw size={11} aria-hidden />
: <Search size={11} aria-hidden />}
</button>
</Tooltip>
)}
</div>
{open && refuel.loading ? (
<span className="text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.refuel.looking')}</span>
) : null}
{settled ? (
refuel.results.length ? (
<ul className="flex flex-col gap-0.5">
{/* Three at most. This is an offer beside a plan, not a list to browse;
the corridor panel is where somebody goes to see all of them. */}
{refuel.results.slice(0, 3).map(poi => (
<li key={poi.osm_id} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-content" style={{ fontSize: FS.meta }}>
{poi.name}
</span>
<span className="shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
{/* What is left when the car draws level, the figure that decides
whether this one is any use. The detour is already in it. */}
{t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
</span>
{onAccept ? (
<button
type="button"
onClick={() => onAccept(poi)}
aria-label={t('roadtrip.refuel.add', { name: poi.name })}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={11} aria-hidden />
</button>
) : null}
</li>
))}
</ul>
) : (
/* 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, which is worse than saying nothing. */
<span className="text-content-muted" style={{ fontSize: FS.meta }}>
{refuel.outcome === 'none'
? t('roadtrip.refuel.none')
: refuel.outcome === 'incomplete'
? t('roadtrip.refuel.incomplete')
: t('roadtrip.refuel.failed')}
</span>
)
) : null}
</div>
</div>
{open && refuel.loading ? (
<span className="text-content-faint" style={{ fontSize: FS.label }}>{t('roadtrip.refuel.looking')}</span>
) : null}
{open && !refuel.loading && refuel.outcome ? (
refuel.results.length ? (
<ul className="flex flex-col gap-1">
{/* Three at most. This is an offer beside a plan, not a list to browse; the
corridor panel is where somebody goes to see all of them. */}
{refuel.results.slice(0, 3).map(poi => (
<li key={poi.osm_id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-content" style={{ fontSize: FS.label }}>
{poi.name}
</span>
<span className="shrink-0 tabular-nums text-content-faint" style={{ fontSize: FS.label }}>
{/* What is left when the car draws level, which is the figure that
decides whether this one is any use. The detour is in it already. */}
{t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
</span>
{onAccept ? (
<button
type="button"
onClick={() => onAccept(poi)}
aria-label={t('roadtrip.refuel.add', { name: poi.name })}
className="shrink-0 rounded bg-surface-card px-1.5 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
style={{ fontSize: FS.label }}
>
+
</button>
) : null}
</li>
))}
</ul>
) : (
/* 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. */
<span className="text-content-faint" style={{ fontSize: FS.label }}>
{refuel.outcome === 'none'
? t('roadtrip.refuel.none')
: refuel.outcome === 'incomplete'
? t('roadtrip.refuel.incomplete')
: t('roadtrip.refuel.failed')}
</span>
)
) : null}
</div>
)
}
@@ -956,6 +985,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const last = day.stops.length - 1
/**
* The findings a stop wears, minus the one the refuel band already says better.
*
* The range warning marks the stop somebody FINDS OUT at and counts the whole tank;
* the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both
* at once says the same thing twice, in the wrong order — the answer above the problem
* — so the warning stands down wherever the band is showing.
*/
const showingBand = !!refuel && !loading && !!day.dryPoints?.length
const findingsFor = (i: number) =>
day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range'))
// The running number a stop wears, with the service stops passed over — so a day with a
// charger halfway through still counts one, two, three the way its map pins do.
let counted = 0
@@ -1091,7 +1131,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
driveFindings={day.driveWarnings.filter(w => w.index === i)}
driveFindings={findingsFor(i)}
selected={selectedAssignmentId === stop.assignmentId}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
@@ -1103,7 +1143,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
number={counted}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
driveFindings={day.driveWarnings.filter(w => w.index === i)}
driveFindings={findingsFor(i)}
selected={selectedAssignmentId === stop.assignmentId}
continues={i < last}
starts={i === 0}
@@ -77,8 +77,11 @@ describe('outcomeOf', () => {
// 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')
// `clamped` is not the same thing: it means the server searched a smaller circle
// than the box described, and the caller sizes the box so that never happens.
// Treating it as "could not check" turned every empty answer into a shrug.
expect(outcomeOf([], { clamped: true })).toBe('none')
})
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
@@ -86,7 +86,10 @@ export function outcomeOf(
): RefuelOutcome {
if (!answer) return 'failed'
if (candidates.length) return 'found'
return answer.truncated || answer.clamped ? 'incomplete' : 'none'
// `truncated` alone. `clamped` only means the server searched a smaller circle than
// the box described, which the caller now sizes so it never happens — and reporting it
// as "we could not check" turned every empty answer into a shrug.
return answer.truncated ? 'incomplete' : 'none'
}
/**
@@ -11,6 +11,7 @@ import {
parseClock,
splitIntoRuns,
sumLegSeconds,
refuelStopTypeFor,
} from './roadtripModel'
describe('formatDurationShort', () => {
@@ -687,3 +688,41 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => {
expect(out.carryKm).toBeNull()
})
})
describe('refuelsRange — what fills which tank', () => {
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
// What TREK did before the setting existed, and the right answer for somebody who
// never opened the dialog. Anything else would quietly change their warnings.
expect(refuelsRange('fuel')).toBe(true)
expect(refuelsRange('charging')).toBe(true)
expect(refuelsRange('fuel', null)).toBe(true)
expect(refuelsRange('charging', null)).toBe(true)
})
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
// The bug this exists for: an electric car pausing at a petrol station had its
// battery refilled on paper, the warnings went quiet for the rest of the day, and
// the driver was told nothing.
expect(refuelsRange('charging', 'electric')).toBe(true)
expect(refuelsRange('fuel', 'electric')).toBe(false)
})
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
expect(refuelsRange('fuel', 'combustion')).toBe(true)
expect(refuelsRange('charging', 'combustion')).toBe(false)
})
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
for (const vehicle of [null, 'combustion', 'electric'] as const) {
expect(refuelsRange('rest_area', vehicle)).toBe(false)
expect(refuelsRange('restaurant', vehicle)).toBe(false)
expect(refuelsRange(null, vehicle)).toBe(false)
}
})
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
})
})
@@ -123,9 +123,32 @@ export interface ScheduleWarning {
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/** Whether stopping here starts the range budget over. */
export function refuelsRange(stopType: string | null | undefined): boolean {
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
/**
* What the traveller drives, which decides what actually fills the tank.
*
* Absent means both count, which is what TREK did before the setting existed and is the
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
* a petrol station does not charge a battery, and a charger does not fill a tank.
*/
export type VehicleKind = 'combustion' | 'electric'
/** The stop kind that refills the named vehicle, for filtering a search. */
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
if (vehicle === 'combustion') return ['fuel']
if (vehicle === 'electric') return ['charging']
return REFUELLING_STOP_TYPES
}
/**
* Whether stopping here starts the range budget over.
*
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
* named the other stops counting, because the alternative is arithmetic that is simply
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
* the warnings went quiet for the rest of the day, and the driver was told nothing.
*/
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
}
/**
@@ -1,8 +1,10 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, 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 { useSettingsStore } from '../../store/settingsStore'
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
import { boxAround, pointAtMeters, type LatLng } from './corridor'
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
/**
@@ -21,8 +23,27 @@ import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome }
* One request, one day, no shared state touched.
*/
/** How far around the dry point to look, in kilometres. */
const LOOK_KM = 25
/**
* How far around the search point to look, in kilometres.
*
* Fourteen and not more, because the server turns a box into a circle of half its
* diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which
* is the largest question it answers in full — ±25 asked for 35 and came back flagged
* as narrowed every single time, which then read on screen as "we could not check".
*/
const LOOK_KM = 14
/**
* How far BEFORE the dry point to centre that circle, in kilometres.
*
* Only the road already driven is any use: a station past the point is on the far side
* of an empty tank. Centring on the dry point spends half the circle on road nobody can
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
*/
const LOOK_BACK_KM = 12
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
const NONE: RefuelCandidate[] = []
export interface RefuelSearch {
/** Which dry point is being answered, as `<dayId>:<legIndex>`, or null when idle. */
@@ -30,6 +51,15 @@ export interface RefuelSearch {
loading: boolean
outcome: RefuelOutcome | null
results: RefuelCandidate[]
/**
* The ones on offer, for the map to draw.
*
* The same three the rail lists and no more: somebody is being asked to accept a stop,
* and a stop that cannot be seen is not one that can be judged. Empty while nothing is
* open, and the same array each time, because the map rebuilds every pin when this
* reference changes.
*/
offered: 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<void>
close: () => void
@@ -37,6 +67,9 @@ export interface RefuelSearch {
export function useRefuelSearch(): RefuelSearch {
const { locale } = useTranslation()
// What to look for. Somebody who said they drive an electric car has no use for a
// petrol station in the list, and the other way round; with nothing said, both.
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
const [openFor, setOpenFor] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
@@ -76,7 +109,14 @@ export function useRefuelSearch(): RefuelSearch {
setLoading(true)
try {
const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal)
const kind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
const wanted = refuelStopTypeFor(kind).join(',')
// Pulled back along the road, because only what lies before the dry point can be
// reached. `at` stays the dry point itself: it is what the band names and what the
// map is asked to show.
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
if (controller.signal.aborted) return
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
setResults(candidates)
@@ -86,7 +126,9 @@ export function useRefuelSearch(): RefuelSearch {
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}, [locale])
}, [locale, vehicle])
return { openFor, loading, outcome, results, ask, close }
const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results])
return { openFor, loading, outcome, results, offered, ask, close }
}
@@ -3,7 +3,7 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
import { resolveLegMode } from '../Planner/legMode'
import {
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel'
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel'
import { projectOntoRoute, pointAtMeters} from './corridor'
import { useSettingsStore } from '../../store/settingsStore'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
@@ -283,8 +283,13 @@ export function useRoadtripRoutes(
* any value — and an unknown word here would become a costing option the router does
* not have.
*/
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
// Validated rather than trusted, like the avoidance above: a per-user setting gets no
// server-side check, and an unknown word here would silently stop both kinds counting.
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
const avoidKey = avoid.join(',')
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
// same thing, so it is folded here rather than guarded at every reading.
@@ -546,7 +551,7 @@ export function useRoadtripRoutes(
legs,
// One longer than the legs: the last stop of the day counts too, because filling
// up on arrival is what makes the next morning start full.
stops.map(s => refuelsRange(s.stopType)),
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
limits,
carryKm,
)
+6 -3
View File
@@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
setRoadtripStopKind,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
@@ -325,7 +325,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the
// two answers are drawn side by side rather than one hiding the other.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois)
// The refuel offers ride the same channel: in road trip mode this is the only way a
// POI reaches the map, so without them somebody is asked to accept a stop they cannot
// see. They vanish with the offer rather than lingering as a search result.
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered)
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
@@ -443,7 +446,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
alternativeRoutes={alternativeOverlays}
focusPoints={alternativeFocusPoints}
focusPoints={mapFocusPoints}
clusterLoosely={roadtripActive}
activeAlternative={highlightedAlternative}
onChooseAlternative={chooseRouteAlternative}
@@ -150,6 +150,9 @@ function baseState(): HookState {
roadtripActive: false,
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
roadtripViaCounts: {},
stopDraft: null,
+16 -1
View File
@@ -1075,6 +1075,21 @@ export function useTripPlanner() {
[alternativeOverlays],
)
/**
* What the map should bring into view.
*
* Refuel offers win while they are open, and for the reason they exist at all: somebody
* is being asked to accept a stop, and a stop off the edge of the map cannot be judged.
* They are the newer, smaller and more specific answer, so they take the view from the
* alternatives rather than being averaged with them into a frame that shows neither.
*/
const mapFocusPoints = useMemo<[number, number][]>(
() => (refuel.offered.length
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
: alternativeFocusPoints),
[refuel.offered, alternativeFocusPoints],
)
/** Asks the router for other ways of driving one leg of one day. */
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
@@ -1870,7 +1885,7 @@ export function useTripPlanner() {
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
refuel, askRefuel, acceptRefuel,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
+2
View File
@@ -64,6 +64,8 @@ export const DEFAULT_SETTINGS: Settings = {
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
// case no second engine is asked at all. See valhallaBase().
valhalla_base_url: '',
// Empty = not said, and then a stop of either kind fills up. See refuelsRange().
roadtrip_vehicle: '',
dark_mode: false,
// Empty = no personal display currency, so Costs falls back to the trip's own.
default_currency: '',
+8
View File
@@ -159,6 +159,14 @@ export interface Settings {
* because it goes to the router as one request either way.
*/
roadtrip_avoid?: string
/**
* What the traveller drives: 'combustion', 'electric', or absent for "did not say".
*
* Absent has to mean BOTH kinds refill, which is what the range budget did before this
* existed. Anything else would quietly change the warnings of every traveller who never
* opened the dialog.
*/
roadtrip_vehicle?: string
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
carto_api_key?: string
mapbox_access_token?: string
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': 'تجنُّب {count}',
'roadtrip.avoid.missed': 'لا مفر من {classes}',
'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.',
'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}',
'roadtrip.refuel.dry': 'ينفد الخزان هنا',
'roadtrip.refuel.after': 'بعد {distance}',
'roadtrip.limit.vehicleLabel': 'بماذا تقود',
'roadtrip.limit.vehicleAny': 'كلاهما',
'roadtrip.limit.vehicleCombustion': 'وقود',
'roadtrip.limit.vehicleElectric': 'كهرباء',
'roadtrip.refuel.find': 'ابحث عن وقود',
'roadtrip.refuel.again': 'حاول مجددًا',
'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…',
'roadtrip.refuel.spare': 'يتبقى {distance}',
'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود',
+7 -1
View File
@@ -38,8 +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.dry': 'Aqui o tanque acaba',
'roadtrip.refuel.after': 'após {distance}',
'roadtrip.limit.vehicleLabel': 'O que você dirige',
'roadtrip.limit.vehicleAny': 'Ambos',
'roadtrip.limit.vehicleCombustion': 'Combustível',
'roadtrip.limit.vehicleElectric': 'Elétrico',
'roadtrip.refuel.find': 'Buscar combustível',
'roadtrip.refuel.again': 'Tentar de novo',
'roadtrip.refuel.looking': 'Procurando ao longo da rota…',
'roadtrip.refuel.spare': 'restam {distance}',
'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento',
+7 -1
View File
@@ -38,8 +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.dry': 'Aquí s’esgota el dipòsit',
'roadtrip.refuel.after': 'després de {distance}',
'roadtrip.limit.vehicleLabel': 'Què condueixes',
'roadtrip.limit.vehicleAny': 'Tots dos',
'roadtrip.limit.vehicleCombustion': 'Benzina',
'roadtrip.limit.vehicleElectric': 'Elèctric',
'roadtrip.refuel.find': 'Cerca combustible',
'roadtrip.refuel.again': 'Torna-ho a provar',
'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…',
'roadtrip.refuel.spare': 'queden {distance}',
'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible',
+7 -1
View File
@@ -38,8 +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.dry': 'Tady dojde palivo',
'roadtrip.refuel.after': 'po {distance}',
'roadtrip.limit.vehicleLabel': 'Čím jezdíš',
'roadtrip.limit.vehicleAny': 'Obojí',
'roadtrip.limit.vehicleCombustion': 'Palivo',
'roadtrip.limit.vehicleElectric': 'Elektro',
'roadtrip.refuel.find': 'Najít palivo',
'roadtrip.refuel.again': 'Zkusit znovu',
'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',
+7 -1
View File
@@ -38,8 +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.dry': 'Hier ist der Tank leer',
'roadtrip.refuel.after': 'nach {distance}',
'roadtrip.limit.vehicleLabel': 'Womit du fährst',
'roadtrip.limit.vehicleAny': 'Beides',
'roadtrip.limit.vehicleCombustion': 'Sprit',
'roadtrip.limit.vehicleElectric': 'Strom',
'roadtrip.refuel.find': 'Tankstelle suchen',
'roadtrip.refuel.again': 'Nochmal',
'roadtrip.refuel.looking': 'Suche entlang der Strecke…',
'roadtrip.refuel.spare': 'noch {distance}',
'roadtrip.refuel.add': '{name} als Tankstopp übernehmen',
+7 -1
View File
@@ -38,8 +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.dry': 'Tank runs out here',
'roadtrip.refuel.after': 'after {distance}',
'roadtrip.limit.vehicleLabel': 'What you drive',
'roadtrip.limit.vehicleAny': 'Either',
'roadtrip.limit.vehicleCombustion': 'Petrol',
'roadtrip.limit.vehicleElectric': 'Electric',
'roadtrip.refuel.find': 'Find fuel',
'roadtrip.refuel.again': 'Try again',
'roadtrip.refuel.looking': 'Looking along the route…',
'roadtrip.refuel.spare': '{distance} to spare',
'roadtrip.refuel.add': 'Add {name} as a fuel stop',
+7 -1
View File
@@ -38,8 +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.dry': 'Aquí se vacía el depósito',
'roadtrip.refuel.after': 'tras {distance}',
'roadtrip.limit.vehicleLabel': 'Qué conduces',
'roadtrip.limit.vehicleAny': 'Ambos',
'roadtrip.limit.vehicleCombustion': 'Gasolina',
'roadtrip.limit.vehicleElectric': 'Eléctrico',
'roadtrip.refuel.find': 'Buscar combustible',
'roadtrip.refuel.again': 'Reintentar',
'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',
+7 -1
View File
@@ -38,8 +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.dry': 'Réservoir vide ici',
'roadtrip.refuel.after': 'après {distance}',
'roadtrip.limit.vehicleLabel': 'Ce que vous conduisez',
'roadtrip.limit.vehicleAny': 'Les deux',
'roadtrip.limit.vehicleCombustion': 'Carburant',
'roadtrip.limit.vehicleElectric': 'Électrique',
'roadtrip.refuel.find': 'Chercher du carburant',
'roadtrip.refuel.again': 'Réessayer',
'roadtrip.refuel.looking': 'Recherche le long du trajet…',
'roadtrip.refuel.spare': 'reste {distance}',
'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} αποφεύγονται',
'roadtrip.avoid.missed': '{classes} αναπόφευκτα',
'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.',
'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}',
'roadtrip.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ',
'roadtrip.refuel.after': 'μετά από {distance}',
'roadtrip.limit.vehicleLabel': 'Τι οδηγείς',
'roadtrip.limit.vehicleAny': 'Και τα δύο',
'roadtrip.limit.vehicleCombustion': 'Βενζίνη',
'roadtrip.limit.vehicleElectric': 'Ηλεκτρικό',
'roadtrip.refuel.find': 'Εύρεση καυσίμων',
'roadtrip.refuel.again': 'Ξανά',
'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…',
'roadtrip.refuel.spare': 'μένουν {distance}',
'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού',
+7 -1
View File
@@ -38,8 +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.dry': 'Itt fogy ki a tank',
'roadtrip.refuel.after': '{distance} után',
'roadtrip.limit.vehicleLabel': 'Mivel utazol',
'roadtrip.limit.vehicleAny': 'Mindkettő',
'roadtrip.limit.vehicleCombustion': 'Üzemanyag',
'roadtrip.limit.vehicleElectric': 'Elektromos',
'roadtrip.refuel.find': 'Üzemanyag keresése',
'roadtrip.refuel.again': 'Újra',
'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',
+7 -1
View File
@@ -38,8 +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.dry': 'Tangki habis di sini',
'roadtrip.refuel.after': 'setelah {distance}',
'roadtrip.limit.vehicleLabel': 'Kamu mengemudi apa',
'roadtrip.limit.vehicleAny': 'Keduanya',
'roadtrip.limit.vehicleCombustion': 'Bensin',
'roadtrip.limit.vehicleElectric': 'Listrik',
'roadtrip.refuel.find': 'Cari bahan bakar',
'roadtrip.refuel.again': 'Coba lagi',
'roadtrip.refuel.looking': 'Mencari di sepanjang rute…',
'roadtrip.refuel.spare': 'sisa {distance}',
'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar',
+7 -1
View File
@@ -38,8 +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.dry': 'Qui il serbatoio si esaurisce',
'roadtrip.refuel.after': 'dopo {distance}',
'roadtrip.limit.vehicleLabel': 'Cosa guidi',
'roadtrip.limit.vehicleAny': 'Entrambi',
'roadtrip.limit.vehicleCombustion': 'Benzina',
'roadtrip.limit.vehicleElectric': 'Elettrico',
'roadtrip.refuel.find': 'Cerca carburante',
'roadtrip.refuel.again': 'Riprova',
'roadtrip.refuel.looking': 'Ricerca lungo il percorso…',
'roadtrip.refuel.spare': 'restano {distance}',
'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} 件を回避',
'roadtrip.avoid.missed': '{classes} は回避できず',
'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。',
'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)',
'roadtrip.refuel.dry': 'ここで燃料切れ',
'roadtrip.refuel.after': '{distance} 後',
'roadtrip.limit.vehicleLabel': '何で走るか',
'roadtrip.limit.vehicleAny': '両方',
'roadtrip.limit.vehicleCombustion': 'ガソリン',
'roadtrip.limit.vehicleElectric': '電気',
'roadtrip.refuel.find': '給油所を探す',
'roadtrip.refuel.again': 'もう一度',
'roadtrip.refuel.looking': 'ルート沿いを検索中…',
'roadtrip.refuel.spare': '残り {distance}',
'roadtrip.refuel.add': '{name} を給油の停車地として追加',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count}개 회피',
'roadtrip.avoid.missed': '{classes} 회피 불가',
'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.',
'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)',
'roadtrip.refuel.dry': '여기서 연료 소진',
'roadtrip.refuel.after': '{distance} 뒤',
'roadtrip.limit.vehicleLabel': '무엇으로 이동하나요',
'roadtrip.limit.vehicleAny': '둘 다',
'roadtrip.limit.vehicleCombustion': '연료',
'roadtrip.limit.vehicleElectric': '전기',
'roadtrip.refuel.find': '주유소 찾기',
'roadtrip.refuel.again': '다시 시도',
'roadtrip.refuel.looking': '경로를 따라 찾는 중…',
'roadtrip.refuel.spare': '{distance} 여유',
'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가',
+7 -1
View File
@@ -38,8 +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.dry': 'Hier is de tank leeg',
'roadtrip.refuel.after': 'na {distance}',
'roadtrip.limit.vehicleLabel': 'Wat je rijdt',
'roadtrip.limit.vehicleAny': 'Beide',
'roadtrip.limit.vehicleCombustion': 'Benzine',
'roadtrip.limit.vehicleElectric': 'Elektrisch',
'roadtrip.refuel.find': 'Tankstation zoeken',
'roadtrip.refuel.again': 'Opnieuw',
'roadtrip.refuel.looking': 'Zoeken langs de route…',
'roadtrip.refuel.spare': 'nog {distance}',
'roadtrip.refuel.add': '{name} als tankstop toevoegen',
+7 -1
View File
@@ -38,8 +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.dry': 'Tu kończy się paliwo',
'roadtrip.refuel.after': 'po {distance}',
'roadtrip.limit.vehicleLabel': 'Czym jeździsz',
'roadtrip.limit.vehicleAny': 'Oba',
'roadtrip.limit.vehicleCombustion': 'Paliwo',
'roadtrip.limit.vehicleElectric': 'Elektryk',
'roadtrip.refuel.find': 'Znajdź paliwo',
'roadtrip.refuel.again': 'Ponów',
'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…',
'roadtrip.refuel.spare': 'zostaje {distance}',
'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} в обход',
'roadtrip.avoid.missed': '{classes} не обойти',
'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.',
'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}',
'roadtrip.refuel.dry': 'Здесь кончится топливо',
'roadtrip.refuel.after': 'через {distance}',
'roadtrip.limit.vehicleLabel': 'На чём вы едете',
'roadtrip.limit.vehicleAny': 'Оба',
'roadtrip.limit.vehicleCombustion': 'Бензин',
'roadtrip.limit.vehicleElectric': 'Электро',
'roadtrip.refuel.find': 'Найти заправку',
'roadtrip.refuel.again': 'Ещё раз',
'roadtrip.refuel.looking': 'Ищу вдоль маршрута…',
'roadtrip.refuel.spare': 'останется {distance}',
'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки',
+7 -1
View File
@@ -38,8 +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.dry': 'Här tar tanken slut',
'roadtrip.refuel.after': 'efter {distance}',
'roadtrip.limit.vehicleLabel': 'Vad du kör',
'roadtrip.limit.vehicleAny': 'Båda',
'roadtrip.limit.vehicleCombustion': 'Bensin',
'roadtrip.limit.vehicleElectric': 'El',
'roadtrip.refuel.find': 'Hitta bränsle',
'roadtrip.refuel.again': 'Försök igen',
'roadtrip.refuel.looking': 'Söker längs rutten…',
'roadtrip.refuel.spare': '{distance} kvar',
'roadtrip.refuel.add': 'Lägg till {name} som tankstopp',
+7 -1
View File
@@ -38,8 +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.dry': 'Depo burada bitiyor',
'roadtrip.refuel.after': '{distance} sonra',
'roadtrip.limit.vehicleLabel': 'Ne kullanıyorsun',
'roadtrip.limit.vehicleAny': 'İkisi de',
'roadtrip.limit.vehicleCombustion': 'Benzin',
'roadtrip.limit.vehicleElectric': 'Elektrik',
'roadtrip.refuel.find': 'Yakıt bul',
'roadtrip.refuel.again': 'Yeniden dene',
'roadtrip.refuel.looking': 'Rota boyunca aranıyor…',
'roadtrip.refuel.spare': '{distance} kalır',
'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '{count} в обхід',
'roadtrip.avoid.missed': '{classes} не обійти',
'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.',
'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}',
'roadtrip.refuel.dry': 'Тут закінчиться пальне',
'roadtrip.refuel.after': 'через {distance}',
'roadtrip.limit.vehicleLabel': 'Чим ви їдете',
'roadtrip.limit.vehicleAny': 'Обидва',
'roadtrip.limit.vehicleCombustion': 'Бензин',
'roadtrip.limit.vehicleElectric': 'Електро',
'roadtrip.refuel.find': 'Знайти заправку',
'roadtrip.refuel.again': 'Ще раз',
'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…',
'roadtrip.refuel.spare': 'лишиться {distance}',
'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки',
+7 -1
View File
@@ -38,8 +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.dry': 'Hết xăng ở đây',
'roadtrip.refuel.after': 'sau {distance}',
'roadtrip.limit.vehicleLabel': 'Bạn lái xe gì',
'roadtrip.limit.vehicleAny': 'Cả hai',
'roadtrip.limit.vehicleCombustion': 'Xăng',
'roadtrip.limit.vehicleElectric': 'Điện',
'roadtrip.refuel.find': 'Tìm trạm xăng',
'roadtrip.refuel.again': 'Thử lại',
'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',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '避開 {count} 項',
'roadtrip.avoid.missed': '無法避開{classes}',
'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。',
'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後',
'roadtrip.refuel.dry': '此處將耗盡油量',
'roadtrip.refuel.after': '{distance} 後',
'roadtrip.limit.vehicleLabel': '你開什麼車',
'roadtrip.limit.vehicleAny': '兩者',
'roadtrip.limit.vehicleCombustion': '燃油',
'roadtrip.limit.vehicleElectric': '電動',
'roadtrip.refuel.find': '尋找加油站',
'roadtrip.refuel.again': '重試',
'roadtrip.refuel.looking': '正在沿路線尋找…',
'roadtrip.refuel.spare': '剩餘 {distance}',
'roadtrip.refuel.add': '將 {name} 加入為加油停靠點',
+7 -1
View File
@@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = {
'roadtrip.avoid.badge': '避开 {count} 项',
'roadtrip.avoid.missed': '无法避开{classes}',
'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。',
'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后',
'roadtrip.refuel.dry': '此处将耗尽油量',
'roadtrip.refuel.after': '{distance} 后',
'roadtrip.limit.vehicleLabel': '你开什么车',
'roadtrip.limit.vehicleAny': '两者',
'roadtrip.limit.vehicleCombustion': '燃油',
'roadtrip.limit.vehicleElectric': '电动',
'roadtrip.refuel.find': '查找加油站',
'roadtrip.refuel.again': '重试',
'roadtrip.refuel.looking': '正在沿路线查找…',
'roadtrip.refuel.spare': '剩余 {distance}',
'roadtrip.refuel.add': '将 {name} 添加为加油停靠点',