mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): draw corridor hits on the map and build the day in one request
Three things the first cut got wrong. Switching into road trip mode took its time: every leg was its own routing request, paced a second apart so the shared hosts would answer at all. The router already returns a leg per consecutive pair of the waypoints it is handed, so a day is now one request — split only where the travel mode changes, because one request carries one mode. Answers that come from the cache are no longer paced at all, which is what made returning to the view feel broken. A list of petrol stations is half an answer: which one is on your side of the road is the other half. The corridor search therefore moved out of the panel into the planner, and its hits are handed to the map like any other place suggestion — grey, because they are not part of the trip until you add one. The explore pill stands down while the mode is on: it draws into the same channel and answers the same question for a drive. The panel itself was a stack of controls without an order. It now reads as header, search, results: what is being searched (with the day's distance and driving time), what is being looked for, how far off the route, then the hits grouped by kind with a count — and a progress bar while the boxes are being worked through.
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Search, Plus, RotateCw, AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Search, Plus, RotateCw,
|
||||
AlertTriangle, MapPin, type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useCorridorPois, type CorridorPoi } from './useCorridorPois'
|
||||
import type { LatLng } from './corridor'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
interface RoadtripCorridorPanelProps {
|
||||
corridor: RoadtripCorridor
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, optionally on a day. */
|
||||
onAddPoi?: (
|
||||
@@ -17,39 +22,27 @@ interface RoadtripCorridorPanelProps {
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What a drive needs, rather than what a city visit does. Deliberately a short list:
|
||||
* every extra category is another round of requests against a shared Overpass mirror.
|
||||
*/
|
||||
const CORRIDOR_CATEGORIES: { key: string; labelKey: string; Icon: LucideIcon }[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent },
|
||||
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils },
|
||||
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera },
|
||||
]
|
||||
const CATEGORY_META: Record<string, { labelKey: string; Icon: LucideIcon }> = {
|
||||
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
|
||||
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
|
||||
rest_area: { labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare },
|
||||
campsite: { labelKey: 'roadtrip.poi.campsite', Icon: Tent },
|
||||
restaurant: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
|
||||
sights: { labelKey: 'roadtrip.poi.sights', Icon: Camera },
|
||||
}
|
||||
|
||||
const CATEGORY_ICON: Record<string, LucideIcon> = Object.fromEntries(
|
||||
CORRIDOR_CATEGORIES.map(c => [c.key, c.Icon]),
|
||||
)
|
||||
|
||||
const WIDTHS_KM = [2, 5, 10]
|
||||
const LABEL = 'text-caption font-medium text-content-faint uppercase tracking-wide'
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const Icon = CATEGORY_ICON[poi.category] ?? Camera
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-surface-hover">
|
||||
<Icon size={15} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<li className="group flex items-center gap-2.5 rounded-xl px-2.5 py-2 hover:bg-surface-hover">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body text-content truncate">{poi.name}</div>
|
||||
<div className="truncate text-body text-content">{poi.name}</div>
|
||||
<div className="text-caption text-content-faint tabular-nums">
|
||||
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
<span className="mx-1">·</span>
|
||||
{/* A candidate beside the first stop projects to the very start of the line;
|
||||
printing that as "after 0 m" reads like a bug rather than "right here". */}
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
@@ -59,13 +52,39 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="shrink-0 flex items-center gap-1 rounded-lg px-2 py-1 text-caption text-content-secondary hover:text-content hover:bg-surface-selected"
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg border border-edge bg-surface-card px-2 py-1 text-caption font-medium text-content-secondary opacity-0 transition-opacity hover:text-content focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t('roadtrip.poi.add')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
|
||||
function ResultGroup({ category, pois, dayId, onAddPoi }: {
|
||||
category: string
|
||||
pois: CorridorPoi[]
|
||||
dayId: number | null
|
||||
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2 px-4 py-1.5">
|
||||
<Icon size={13} className="text-content-faint" aria-hidden />
|
||||
<span className={LABEL}>{meta ? t(meta.labelKey) : category}</span>
|
||||
<span className="ml-auto text-caption text-content-faint tabular-nums">{pois.length}</span>
|
||||
</header>
|
||||
<ul className="px-1.5">
|
||||
{pois.map(poi => (
|
||||
<ResultRow key={poi.osm_id} poi={poi} onAdd={onAddPoi ? () => onAddPoi(poi, dayId) : undefined} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,77 +92,100 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
* "What is on the way" — the right column while road trip mode is on.
|
||||
*
|
||||
* Searching is a button, not a side effect of panning: one run is several Overpass
|
||||
* requests and the mirrors are shared infrastructure. The scope is one day's drive by
|
||||
* default, which keeps a run to a handful of boxes and makes "add this" unambiguous —
|
||||
* the stop lands on the day whose route it was found along.
|
||||
* requests and the mirrors are shared infrastructure. The scope is one day's drive, which
|
||||
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
|
||||
* the day whose route it was found along. Hits are drawn on the map at the same time, so
|
||||
* the list answers "which of these" and the map answers "which side of the road".
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [dayId, setDayId] = useState<string>(() => String(routes.days[0]?.dayId ?? ''))
|
||||
const [active, setActive] = useState<string[]>(['fuel'])
|
||||
const [widthKm, setWidthKm] = useState<number>(5)
|
||||
|
||||
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
|
||||
const line = useMemo<LatLng[]>(() => {
|
||||
if (!day) return []
|
||||
// The stops themselves are enough of a spine: the corridor is kilometres wide, and
|
||||
// the routed geometry per leg is not kept once its numbers are read.
|
||||
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day])
|
||||
|
||||
const search = useCorridorPois(line, active, widthKm)
|
||||
const { search } = corridor
|
||||
|
||||
const dayOptions = routes.days.map(d => ({
|
||||
value: String(d.dayId),
|
||||
label: t('roadtrip.day', { number: d.dayNumber }),
|
||||
}))
|
||||
|
||||
const toggle = (key: string): void =>
|
||||
setActive(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
|
||||
const grouped = CORRIDOR_CATEGORY_KEYS
|
||||
.map(key => ({ key, pois: search.results.filter(p => p.category === key) }))
|
||||
.filter(g => g.pois.length > 0)
|
||||
|
||||
const canSearch = !search.loading && corridor.categories.length > 0 && (corridor.day?.stops.length ?? 0) > 1
|
||||
const progressPct = search.progress.total
|
||||
? Math.round((search.progress.done / search.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-3 pt-3 pb-2 flex flex-col gap-2.5">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header — the rhythm of the places sidebar this replaces. */}
|
||||
<div className="flex-shrink-0 border-b border-edge-faint px-4 pb-3 pt-3.5">
|
||||
<h2 className="text-subtitle font-semibold text-content">{t('roadtrip.poi.title')}</h2>
|
||||
<p className="mt-0.5 text-caption text-content-muted">{t('roadtrip.poi.hint')}</p>
|
||||
{corridor.day ? (
|
||||
// Under the title rather than beside it: the rail is narrow enough that a
|
||||
// second element on the heading line pushes the heading into two.
|
||||
<p className="mt-1.5 text-caption text-content-faint tabular-nums">
|
||||
{formatDistance(corridor.day.distance / 1000, distanceUnit)}
|
||||
<span className="mx-1">·</span>
|
||||
{formatDurationShort(corridor.day.duration)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="flex flex-shrink-0 flex-col gap-3 border-b border-edge-faint px-4 py-3">
|
||||
{dayOptions.length > 1 ? (
|
||||
<CustomSelect value={dayId} onChange={value => setDayId(String(value))} options={dayOptions} size="sm" />
|
||||
<div>
|
||||
<span className={LABEL}>{t('roadtrip.poi.scope')}</span>
|
||||
<div className="mt-1">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CORRIDOR_CATEGORIES.map(({ key, labelKey, Icon }) => {
|
||||
const on = active.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => toggle(key)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
|
||||
on
|
||||
? 'bg-accent text-accent-text border-transparent'
|
||||
: 'bg-surface-card text-content-secondary border-edge hover:border-edge-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<span className={LABEL}>{t('roadtrip.poi.looking')}</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{CORRIDOR_CATEGORY_KEYS.map(key => {
|
||||
const { labelKey, Icon } = CATEGORY_META[key]
|
||||
const on = corridor.categories.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => corridor.toggleCategory(key)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium transition-colors ${
|
||||
on
|
||||
? 'border-transparent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:border-edge-secondary hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption text-content-faint">{t('roadtrip.poi.within')}</span>
|
||||
<div className="flex gap-1 p-0.5 rounded-lg bg-surface-tertiary border border-edge-faint">
|
||||
{WIDTHS_KM.map(km => (
|
||||
<span className={LABEL}>{t('roadtrip.poi.within')}</span>
|
||||
<div className="ml-auto flex gap-0.5 rounded-lg border border-edge-faint bg-surface-tertiary p-0.5">
|
||||
{CORRIDOR_WIDTHS_KM.map(km => (
|
||||
<button
|
||||
key={km}
|
||||
type="button"
|
||||
aria-pressed={widthKm === km}
|
||||
onClick={() => setWidthKm(km)}
|
||||
className={`rounded-md px-2 py-0.5 text-caption tabular-nums transition-colors ${
|
||||
widthKm === km ? 'bg-surface-card text-content' : 'text-content-muted hover:text-content'
|
||||
aria-pressed={corridor.widthKm === km}
|
||||
onClick={() => corridor.setWidthKm(km)}
|
||||
className={`rounded-md px-2.5 py-1 text-caption tabular-nums transition-colors ${
|
||||
corridor.widthKm === km ? 'bg-surface-card font-medium text-content' : 'text-content-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{formatDistance(km, distanceUnit)}
|
||||
@@ -155,15 +197,23 @@ export default function RoadtripCorridorPanel({ routes, onAddPoi }: RoadtripCorr
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={search.loading || active.length === 0 || line.length < 2}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-inverse text-inverse-text px-3 py-2 text-body font-medium disabled:opacity-50"
|
||||
disabled={!canSearch}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-xl bg-accent px-3 py-2 text-body font-medium text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading ? <RotateCw size={14} className="animate-spin" aria-hidden /> : <Search size={14} aria-hidden />}
|
||||
{search.loading
|
||||
? <RotateCw size={14} className="animate-spin" aria-hidden />
|
||||
: <Search size={14} aria-hidden />}
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
|
||||
: t('roadtrip.poi.search')}
|
||||
</button>
|
||||
|
||||
{search.loading ? (
|
||||
<div className="h-0.5 overflow-hidden rounded-full bg-surface-tertiary" role="progressbar" aria-valuenow={progressPct}>
|
||||
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{search.capped ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
@@ -178,17 +228,30 @@ export default function RoadtripCorridorPanel({ routes, onAddPoi }: RoadtripCorr
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-1.5 pb-3">
|
||||
{search.results.length === 0 && !search.loading ? (
|
||||
<p className="px-3 py-6 text-caption text-content-muted text-center">{t('roadtrip.poi.empty')}</p>
|
||||
{/* Results */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-secondary">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="px-6 py-10 text-center">
|
||||
<MapPin size={20} className="mx-auto mb-2.5 text-content-faint" aria-hidden />
|
||||
<p className="text-caption text-content-muted">
|
||||
{search.loading ? t('roadtrip.poi.searchingHint') : t('roadtrip.poi.empty')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
search.results.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
onAdd={onAddPoi ? () => onAddPoi(poi, day?.dayId ?? null) : undefined}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
<p className="px-4 pb-1 pt-2.5 text-caption text-content-faint">
|
||||
{t('roadtrip.poi.found', { count: search.results.length })}
|
||||
</p>
|
||||
{grouped.map(g => (
|
||||
<ResultGroup
|
||||
key={g.key}
|
||||
category={g.key}
|
||||
pois={g.pois}
|
||||
dayId={corridor.day?.dayId ?? null}
|
||||
onAddPoi={onAddPoi}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
formatClock,
|
||||
formatDurationShort,
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
} from './roadtripModel'
|
||||
|
||||
@@ -176,6 +177,41 @@ describe('computeSchedule', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitIntoRuns', () => {
|
||||
const drive = (): string => 'driving'
|
||||
|
||||
it('asks for a whole day travelled one way in a single run', () => {
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive)
|
||||
expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }])
|
||||
})
|
||||
|
||||
it('splits where the travel mode changes, and repeats the stop on both sides', () => {
|
||||
// Drive to b, walk to c, drive on to d: the walk is its own request, and b and c
|
||||
// each belong to two runs because they are the ends of neighbouring legs.
|
||||
const modes = ['driving', 'walking', 'driving']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b'], mode: 'driving' },
|
||||
{ stops: ['b', 'c'], mode: 'walking' },
|
||||
{ stops: ['c', 'd'], mode: 'driving' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps consecutive legs of the same mode together across a change and back', () => {
|
||||
const modes = ['driving', 'driving', 'walking', 'walking']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b', 'c'], mode: 'driving' },
|
||||
{ stops: ['c', 'd', 'e'], mode: 'walking' },
|
||||
])
|
||||
})
|
||||
|
||||
it('has nothing to ask for when there is no leg', () => {
|
||||
expect(splitIntoRuns([], drive)).toEqual([])
|
||||
expect(splitIntoRuns(['a'], drive)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sumLegSeconds', () => {
|
||||
it('adds only the legs that routed', () => {
|
||||
expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
|
||||
|
||||
@@ -126,6 +126,37 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
return { entries, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a day's stops into the stretches that can be asked for in one routing request:
|
||||
* consecutive stops whose leg shares a travel mode.
|
||||
*
|
||||
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
|
||||
* a day travelled one way is a single request rather than one per leg — the difference
|
||||
* between a road trip appearing at once and crawling in over ten seconds. A day that
|
||||
* mixes walking and driving splits at the change, because one request carries one mode.
|
||||
*/
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
const runs: { stops: T[]; mode: string }[] = []
|
||||
let current: T[] = []
|
||||
let currentMode: string | null = null
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const mode = modeOfLeg(stops[i], stops[i + 1])
|
||||
if (currentMode === null) {
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
} else if (mode === currentMode) {
|
||||
current.push(stops[i + 1])
|
||||
} else {
|
||||
runs.push({ stops: current, mode: currentMode })
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
}
|
||||
}
|
||||
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Total driving seconds of the legs that actually routed. */
|
||||
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCorridorPois, type CorridorSearch } from './useCorridorPois'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
/** The road-trip categories, in the order the panel shows them. */
|
||||
export const CORRIDOR_CATEGORY_KEYS = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
|
||||
|
||||
/** Corridor widths offered, in kilometres. */
|
||||
export const CORRIDOR_WIDTHS_KM = [2, 5, 10]
|
||||
|
||||
export interface RoadtripCorridor {
|
||||
/** Which day's drive is being searched. */
|
||||
dayId: string
|
||||
setDayId: (value: string) => void
|
||||
day: RoadtripDay | undefined
|
||||
categories: string[]
|
||||
toggleCategory: (key: string) => void
|
||||
widthKm: number
|
||||
setWidthKm: (km: number) => void
|
||||
search: CorridorSearch
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor search as trip state rather than panel state.
|
||||
*
|
||||
* The map has to draw what the search found — a list of petrol stations is only half an
|
||||
* answer if you cannot see which one is on your side of the road — so the results have to
|
||||
* live above both the panel and the map rather than inside the panel.
|
||||
*/
|
||||
export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
|
||||
const [dayId, setDayId] = useState<string>('')
|
||||
const [categories, setCategories] = useState<string[]>(['fuel'])
|
||||
const [widthKm, setWidthKm] = useState<number>(5)
|
||||
|
||||
// Falls back to the first day with a drive, so the panel is useful before the user
|
||||
// has picked anything — and follows along when that day disappears.
|
||||
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
|
||||
|
||||
const line = useMemo<LatLng[]>(
|
||||
// The stops are spine enough: the corridor is kilometres wide, and the routed
|
||||
// geometry per leg is not kept once its numbers have been read.
|
||||
() => (day ? day.stops.map(s => ({ lat: s.lat, lng: s.lng })) : []),
|
||||
[day],
|
||||
)
|
||||
|
||||
const search = useCorridorPois(line, categories, widthKm)
|
||||
|
||||
const toggleCategory = useCallback((key: string) => {
|
||||
setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dayId: day ? String(day.dayId) : '',
|
||||
setDayId,
|
||||
day,
|
||||
categories,
|
||||
toggleCategory,
|
||||
widthKm,
|
||||
setWidthKm,
|
||||
search,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { computeSchedule, type Schedule } from './roadtripModel'
|
||||
import { computeSchedule, splitIntoRuns, type Schedule } from './roadtripModel'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment } from '../../types'
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface RoadtripRoutes {
|
||||
* the first handful answer and the rest come back 429.
|
||||
*/
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a leg that failed (usually a rate limit) is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
@@ -95,10 +97,13 @@ const asStop = (a: Assignment): RoadtripStop | null => {
|
||||
* Distance and driving time for every leg of every day of the trip.
|
||||
*
|
||||
* The day plan sidebar routes one day at a time because that is all it shows; a road
|
||||
* trip is the whole chain, which is exactly what #435 asks for. Legs are fetched
|
||||
* pairwise so RouteCalculator's cache is shared with the sidebar — a day already drawn
|
||||
* on the map costs nothing here — and the worker pool stays small because the routing
|
||||
* host is usually someone else's OSRM.
|
||||
* trip is the whole chain, which is exactly what #435 asks for.
|
||||
*
|
||||
* A day is one request, not one per leg: the router already returns a leg for every
|
||||
* consecutive pair of the waypoints it is handed. Requests go out one after another
|
||||
* with a gap, because the public routing hosts TREK ships with allow about one per
|
||||
* second — but only the ones that actually reach the network are paced, so returning
|
||||
* to this view costs nothing.
|
||||
*/
|
||||
export function useRoadtripRoutes(
|
||||
tripId: number | string | null,
|
||||
@@ -166,27 +171,33 @@ export function useRoadtripRoutes(
|
||||
const dayLegs: Record<number, RoutedLeg> = {}
|
||||
collected[day.dayId] = dayLegs
|
||||
const dfMode = dayDefault(day.dayId)
|
||||
for (let i = 0; i < day.stops.length - 1; i++) {
|
||||
const from = day.stops[i]
|
||||
const to = day.stops[i + 1]
|
||||
const mode = resolveLegMode(
|
||||
|
||||
const runs = splitIntoRuns(day.stops, (from, to) =>
|
||||
resolveLegMode(
|
||||
{ isPlace: true, leg_transport_mode: from.legMode },
|
||||
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
|
||||
dfMode,
|
||||
)
|
||||
))
|
||||
|
||||
for (const { stops: run, mode } of runs) {
|
||||
tasks.push(async () => {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
[{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }],
|
||||
run.map(s => ({ lat: s.lat, lng: s.lng })),
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
)
|
||||
if (r.legs[0]) dayLegs[from.assignmentId] = { seg: { ...r.legs[0], mode }, line: r.coordinates }
|
||||
// legs[i] is the drive from run[i] to run[i+1]; the geometry comes back for
|
||||
// the run as a whole, which is what the map draws.
|
||||
r.legs.forEach((leg, i) => {
|
||||
const from = run[i]
|
||||
if (from) dayLegs[from.assignmentId] = { seg: { ...leg, mode }, line: i === 0 ? r.coordinates : [] }
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
// Almost always a rate limit on the shared routing host. Back off and try
|
||||
// again; a leg that still won't route (island hop, dead router) simply stays
|
||||
// again; a run that still won't route (island hop, dead router) simply stays
|
||||
// blank, and the totals say so by being partial rather than wrong.
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay === undefined) return
|
||||
@@ -202,12 +213,17 @@ export function useRoadtripRoutes(
|
||||
// public routing hosts answer that with 429 after the first handful. Results are
|
||||
// published as they land so the rail fills in instead of sitting empty.
|
||||
void (async () => {
|
||||
for (const task of tasks) {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (controller.signal.aborted) return
|
||||
await task()
|
||||
const startedAt = performance.now()
|
||||
await tasks[i]()
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
||||
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
})()
|
||||
|
||||
@@ -246,7 +246,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth,
|
||||
@@ -303,7 +303,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
// The corridor search draws into the same map channel and answers the same question for
|
||||
// a drive, so the explore pill stands down while road trip mode is on.
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
|
||||
|
||||
// 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.
|
||||
@@ -405,7 +407,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={poi.pois}
|
||||
pois={roadtripActive ? roadtripCorridor.search.results : poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
@@ -588,7 +590,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
{roadtripActive ? (
|
||||
<LazyPanel id="roadtrip-corridor">
|
||||
<RoadtripCorridorPanel routes={roadtripRoutes} onAddPoi={openAddPlaceFromPoi} />
|
||||
<RoadtripCorridorPanel corridor={roadtripCorridor} routes={roadtripRoutes} onAddPoi={openAddPlaceFromPoi} />
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
@@ -508,6 +509,8 @@ 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 roadtripRoutes = useRoadtripRoutes(tripId, roadtripActive ? days : EMPTY_DAYS, assignments, routeProfile)
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
|
||||
|
||||
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
||||
tripActions.setSelectedDay(dayId)
|
||||
@@ -1104,7 +1107,7 @@ export function useTripPlanner() {
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'محطات على الطريق',
|
||||
'roadtrip.stops.remove': 'إزالة المحطة',
|
||||
'roadtrip.poi.title': 'على طول المسار',
|
||||
'roadtrip.poi.hint': 'ابحث على طول مسار اليوم عمّا تحتاجه في الطريق.',
|
||||
'roadtrip.poi.scope': 'اليوم',
|
||||
'roadtrip.poi.looking': 'المطلوب',
|
||||
'roadtrip.poi.searchingHint': 'جارٍ البحث على طول المسار…',
|
||||
'roadtrip.poi.found': '{count} على الطريق',
|
||||
'roadtrip.poi.fuel': 'وقود',
|
||||
'roadtrip.poi.charging': 'شحن',
|
||||
'roadtrip.poi.rest': 'استراحة',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Paradas pelo caminho',
|
||||
'roadtrip.stops.remove': 'Remover parada',
|
||||
'roadtrip.poi.title': 'Ao longo da rota',
|
||||
'roadtrip.poi.hint': 'Pesquise ao longo do trajeto de um dia o que precisa no caminho.',
|
||||
'roadtrip.poi.scope': 'Dia',
|
||||
'roadtrip.poi.looking': 'Procurando por',
|
||||
'roadtrip.poi.searchingHint': 'Pesquisando a rota…',
|
||||
'roadtrip.poi.found': '{count} no caminho',
|
||||
'roadtrip.poi.fuel': 'Combustível',
|
||||
'roadtrip.poi.charging': 'Carregamento',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Parades pel camí',
|
||||
'roadtrip.stops.remove': 'Elimina la parada',
|
||||
'roadtrip.poi.title': 'Al llarg de la ruta',
|
||||
'roadtrip.poi.hint': 'Cerca al llarg del trajecte d’un dia el que necessitis.',
|
||||
'roadtrip.poi.scope': 'Dia',
|
||||
'roadtrip.poi.looking': 'Es busca',
|
||||
'roadtrip.poi.searchingHint': 'S’està cercant la ruta…',
|
||||
'roadtrip.poi.found': '{count} pel camí',
|
||||
'roadtrip.poi.fuel': 'Combustible',
|
||||
'roadtrip.poi.charging': 'Càrrega',
|
||||
'roadtrip.poi.rest': 'Àrea de descans',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Zastávky po cestě',
|
||||
'roadtrip.stops.remove': 'Odebrat zastávku',
|
||||
'roadtrip.poi.title': 'Podél trasy',
|
||||
'roadtrip.poi.hint': 'Prohledejte denní jízdu podle toho, co po cestě potřebujete.',
|
||||
'roadtrip.poi.scope': 'Den',
|
||||
'roadtrip.poi.looking': 'Hledá se',
|
||||
'roadtrip.poi.searchingHint': 'Prohledávám trasu…',
|
||||
'roadtrip.poi.found': '{count} po cestě',
|
||||
'roadtrip.poi.fuel': 'Palivo',
|
||||
'roadtrip.poi.charging': 'Nabíjení',
|
||||
'roadtrip.poi.rest': 'Odpočívadlo',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Stopps unterwegs',
|
||||
'roadtrip.stops.remove': 'Stopp entfernen',
|
||||
'roadtrip.poi.title': 'Entlang der Route',
|
||||
'roadtrip.poi.hint': 'Durchsuche die Fahrt eines Tages nach dem, was du unterwegs brauchst.',
|
||||
'roadtrip.poi.scope': 'Tag',
|
||||
'roadtrip.poi.looking': 'Gesucht wird',
|
||||
'roadtrip.poi.searchingHint': 'Die Route wird durchsucht…',
|
||||
'roadtrip.poi.found': '{count} unterwegs',
|
||||
'roadtrip.poi.fuel': 'Tanken',
|
||||
'roadtrip.poi.charging': 'Laden',
|
||||
'roadtrip.poi.rest': 'Rastplatz',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Stops along the way',
|
||||
'roadtrip.stops.remove': 'Remove stop',
|
||||
'roadtrip.poi.title': 'Along the route',
|
||||
'roadtrip.poi.hint': 'Search a day’s drive for what you need along it.',
|
||||
'roadtrip.poi.scope': 'Day',
|
||||
'roadtrip.poi.looking': 'Looking for',
|
||||
'roadtrip.poi.searchingHint': 'Searching the route…',
|
||||
'roadtrip.poi.found': '{count} on the way',
|
||||
'roadtrip.poi.fuel': 'Fuel',
|
||||
'roadtrip.poi.charging': 'Charging',
|
||||
'roadtrip.poi.rest': 'Rest area',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Paradas por el camino',
|
||||
'roadtrip.stops.remove': 'Eliminar parada',
|
||||
'roadtrip.poi.title': 'A lo largo de la ruta',
|
||||
'roadtrip.poi.hint': 'Busca a lo largo del trayecto de un día lo que necesites.',
|
||||
'roadtrip.poi.scope': 'Día',
|
||||
'roadtrip.poi.looking': 'Se busca',
|
||||
'roadtrip.poi.searchingHint': 'Buscando por la ruta…',
|
||||
'roadtrip.poi.found': '{count} por el camino',
|
||||
'roadtrip.poi.fuel': 'Gasolina',
|
||||
'roadtrip.poi.charging': 'Recarga',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Étapes en chemin',
|
||||
'roadtrip.stops.remove': 'Supprimer l’étape',
|
||||
'roadtrip.poi.title': 'Le long de l’itinéraire',
|
||||
'roadtrip.poi.hint': 'Cherchez le long du trajet d’une journée ce dont vous avez besoin.',
|
||||
'roadtrip.poi.scope': 'Jour',
|
||||
'roadtrip.poi.looking': 'Recherche de',
|
||||
'roadtrip.poi.searchingHint': 'Recherche le long de l’itinéraire…',
|
||||
'roadtrip.poi.found': '{count} sur le trajet',
|
||||
'roadtrip.poi.fuel': 'Carburant',
|
||||
'roadtrip.poi.charging': 'Recharge',
|
||||
'roadtrip.poi.rest': 'Aire de repos',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Στάσεις στη διαδρομή',
|
||||
'roadtrip.stops.remove': 'Αφαίρεση στάσης',
|
||||
'roadtrip.poi.title': 'Κατά μήκος της διαδρομής',
|
||||
'roadtrip.poi.hint': 'Αναζητήστε κατά μήκος της διαδρομής μιας ημέρας ό,τι χρειάζεστε.',
|
||||
'roadtrip.poi.scope': 'Ημέρα',
|
||||
'roadtrip.poi.looking': 'Αναζητείται',
|
||||
'roadtrip.poi.searchingHint': 'Αναζήτηση κατά μήκος της διαδρομής…',
|
||||
'roadtrip.poi.found': '{count} στη διαδρομή',
|
||||
'roadtrip.poi.fuel': 'Καύσιμα',
|
||||
'roadtrip.poi.charging': 'Φόρτιση',
|
||||
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Megállók útközben',
|
||||
'roadtrip.stops.remove': 'Megálló eltávolítása',
|
||||
'roadtrip.poi.title': 'Az útvonal mentén',
|
||||
'roadtrip.poi.hint': 'Keresd meg egy nap útvonalán, amire útközben szükséged van.',
|
||||
'roadtrip.poi.scope': 'Nap',
|
||||
'roadtrip.poi.looking': 'Amit keresel',
|
||||
'roadtrip.poi.searchingHint': 'Az útvonal keresése…',
|
||||
'roadtrip.poi.found': '{count} az úton',
|
||||
'roadtrip.poi.fuel': 'Üzemanyag',
|
||||
'roadtrip.poi.charging': 'Töltés',
|
||||
'roadtrip.poi.rest': 'Pihenőhely',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Perhentian di sepanjang jalan',
|
||||
'roadtrip.stops.remove': 'Hapus perhentian',
|
||||
'roadtrip.poi.title': 'Sepanjang rute',
|
||||
'roadtrip.poi.hint': 'Cari di sepanjang perjalanan satu hari apa yang Anda butuhkan.',
|
||||
'roadtrip.poi.scope': 'Hari',
|
||||
'roadtrip.poi.looking': 'Mencari',
|
||||
'roadtrip.poi.searchingHint': 'Mencari di sepanjang rute…',
|
||||
'roadtrip.poi.found': '{count} di jalan',
|
||||
'roadtrip.poi.fuel': 'Bahan bakar',
|
||||
'roadtrip.poi.charging': 'Pengisian daya',
|
||||
'roadtrip.poi.rest': 'Tempat istirahat',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Soste lungo il tragitto',
|
||||
'roadtrip.stops.remove': 'Rimuovi sosta',
|
||||
'roadtrip.poi.title': 'Lungo il percorso',
|
||||
'roadtrip.poi.hint': 'Cerca lungo il tragitto di una giornata ciò che ti serve.',
|
||||
'roadtrip.poi.scope': 'Giorno',
|
||||
'roadtrip.poi.looking': 'Sto cercando',
|
||||
'roadtrip.poi.searchingHint': 'Ricerca lungo il percorso…',
|
||||
'roadtrip.poi.found': '{count} lungo il tragitto',
|
||||
'roadtrip.poi.fuel': 'Carburante',
|
||||
'roadtrip.poi.charging': 'Ricarica',
|
||||
'roadtrip.poi.rest': 'Area di sosta',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': '途中の立ち寄り先',
|
||||
'roadtrip.stops.remove': '立ち寄り先を削除',
|
||||
'roadtrip.poi.title': 'ルート沿い',
|
||||
'roadtrip.poi.hint': 'その日のドライブ沿いに必要なものを探します。',
|
||||
'roadtrip.poi.scope': '日',
|
||||
'roadtrip.poi.looking': '探すもの',
|
||||
'roadtrip.poi.searchingHint': 'ルートを検索しています…',
|
||||
'roadtrip.poi.found': '道中に {count} 件',
|
||||
'roadtrip.poi.fuel': '給油',
|
||||
'roadtrip.poi.charging': '充電',
|
||||
'roadtrip.poi.rest': 'サービスエリア',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': '가는 길의 경유지',
|
||||
'roadtrip.stops.remove': '경유지 삭제',
|
||||
'roadtrip.poi.title': '경로 주변',
|
||||
'roadtrip.poi.hint': '하루 주행 경로를 따라 필요한 것을 찾습니다.',
|
||||
'roadtrip.poi.scope': '날짜',
|
||||
'roadtrip.poi.looking': '찾는 항목',
|
||||
'roadtrip.poi.searchingHint': '경로를 검색하는 중…',
|
||||
'roadtrip.poi.found': '경로에 {count}곳',
|
||||
'roadtrip.poi.fuel': '주유',
|
||||
'roadtrip.poi.charging': '충전',
|
||||
'roadtrip.poi.rest': '휴게소',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Stops onderweg',
|
||||
'roadtrip.stops.remove': 'Stop verwijderen',
|
||||
'roadtrip.poi.title': 'Langs de route',
|
||||
'roadtrip.poi.hint': 'Doorzoek de rit van een dag op wat je onderweg nodig hebt.',
|
||||
'roadtrip.poi.scope': 'Dag',
|
||||
'roadtrip.poi.looking': 'Gezocht wordt',
|
||||
'roadtrip.poi.searchingHint': 'De route wordt doorzocht…',
|
||||
'roadtrip.poi.found': '{count} onderweg',
|
||||
'roadtrip.poi.fuel': 'Tanken',
|
||||
'roadtrip.poi.charging': 'Laden',
|
||||
'roadtrip.poi.rest': 'Rustplaats',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Przystanki po drodze',
|
||||
'roadtrip.stops.remove': 'Usuń przystanek',
|
||||
'roadtrip.poi.title': 'Wzdłuż trasy',
|
||||
'roadtrip.poi.hint': 'Przeszukaj przejazd danego dnia pod kątem tego, czego potrzebujesz.',
|
||||
'roadtrip.poi.scope': 'Dzień',
|
||||
'roadtrip.poi.looking': 'Szukam',
|
||||
'roadtrip.poi.searchingHint': 'Przeszukiwanie trasy…',
|
||||
'roadtrip.poi.found': '{count} po drodze',
|
||||
'roadtrip.poi.fuel': 'Paliwo',
|
||||
'roadtrip.poi.charging': 'Ładowanie',
|
||||
'roadtrip.poi.rest': 'Miejsce odpoczynku',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Остановки в пути',
|
||||
'roadtrip.stops.remove': 'Удалить остановку',
|
||||
'roadtrip.poi.title': 'Вдоль маршрута',
|
||||
'roadtrip.poi.hint': 'Ищите вдоль дневного перегона то, что нужно в пути.',
|
||||
'roadtrip.poi.scope': 'День',
|
||||
'roadtrip.poi.looking': 'Ищем',
|
||||
'roadtrip.poi.searchingHint': 'Идёт поиск вдоль маршрута…',
|
||||
'roadtrip.poi.found': '{count} по пути',
|
||||
'roadtrip.poi.fuel': 'Заправка',
|
||||
'roadtrip.poi.charging': 'Зарядка',
|
||||
'roadtrip.poi.rest': 'Зона отдыха',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Stopp längs vägen',
|
||||
'roadtrip.stops.remove': 'Ta bort stopp',
|
||||
'roadtrip.poi.title': 'Längs rutten',
|
||||
'roadtrip.poi.hint': 'Sök längs en dags körning efter det du behöver på vägen.',
|
||||
'roadtrip.poi.scope': 'Dag',
|
||||
'roadtrip.poi.looking': 'Söker efter',
|
||||
'roadtrip.poi.searchingHint': 'Söker längs rutten…',
|
||||
'roadtrip.poi.found': '{count} längs vägen',
|
||||
'roadtrip.poi.fuel': 'Bensin',
|
||||
'roadtrip.poi.charging': 'Laddning',
|
||||
'roadtrip.poi.rest': 'Rastplats',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Yol üzerindeki duraklar',
|
||||
'roadtrip.stops.remove': 'Durağı kaldır',
|
||||
'roadtrip.poi.title': 'Rota boyunca',
|
||||
'roadtrip.poi.hint': 'Bir günün sürüşü boyunca yolda ihtiyacın olanı ara.',
|
||||
'roadtrip.poi.scope': 'Gün',
|
||||
'roadtrip.poi.looking': 'Aranan',
|
||||
'roadtrip.poi.searchingHint': 'Rota aranıyor…',
|
||||
'roadtrip.poi.found': 'yol üzerinde {count}',
|
||||
'roadtrip.poi.fuel': 'Yakıt',
|
||||
'roadtrip.poi.charging': 'Şarj',
|
||||
'roadtrip.poi.rest': 'Dinlenme alanı',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Зупинки в дорозі',
|
||||
'roadtrip.stops.remove': 'Видалити зупинку',
|
||||
'roadtrip.poi.title': 'Уздовж маршруту',
|
||||
'roadtrip.poi.hint': 'Шукайте вздовж денного перегону те, що потрібно в дорозі.',
|
||||
'roadtrip.poi.scope': 'День',
|
||||
'roadtrip.poi.looking': 'Шукаємо',
|
||||
'roadtrip.poi.searchingHint': 'Триває пошук уздовж маршруту…',
|
||||
'roadtrip.poi.found': '{count} у дорозі',
|
||||
'roadtrip.poi.fuel': 'Заправка',
|
||||
'roadtrip.poi.charging': 'Зарядка',
|
||||
'roadtrip.poi.rest': 'Зона відпочинку',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': 'Điểm dừng dọc đường',
|
||||
'roadtrip.stops.remove': 'Xóa điểm dừng',
|
||||
'roadtrip.poi.title': 'Dọc theo lộ trình',
|
||||
'roadtrip.poi.hint': 'Tìm dọc chặng đường trong ngày những thứ bạn cần.',
|
||||
'roadtrip.poi.scope': 'Ngày',
|
||||
'roadtrip.poi.looking': 'Đang tìm',
|
||||
'roadtrip.poi.searchingHint': 'Đang tìm dọc lộ trình…',
|
||||
'roadtrip.poi.found': '{count} trên đường',
|
||||
'roadtrip.poi.fuel': 'Xăng dầu',
|
||||
'roadtrip.poi.charging': 'Sạc điện',
|
||||
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': '沿途停靠點',
|
||||
'roadtrip.stops.remove': '移除停靠點',
|
||||
'roadtrip.poi.title': '沿線',
|
||||
'roadtrip.poi.hint': '沿某一天的行程搜尋路上需要的東西。',
|
||||
'roadtrip.poi.scope': '日期',
|
||||
'roadtrip.poi.looking': '尋找',
|
||||
'roadtrip.poi.searchingHint': '正在沿路線搜尋…',
|
||||
'roadtrip.poi.found': '沿途 {count} 處',
|
||||
'roadtrip.poi.fuel': '加油',
|
||||
'roadtrip.poi.charging': '充電',
|
||||
'roadtrip.poi.rest': '休息站',
|
||||
|
||||
@@ -11,6 +11,11 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stops.label': '沿途停靠点',
|
||||
'roadtrip.stops.remove': '移除停靠点',
|
||||
'roadtrip.poi.title': '沿线',
|
||||
'roadtrip.poi.hint': '沿某一天的行程搜索路上需要的东西。',
|
||||
'roadtrip.poi.scope': '日期',
|
||||
'roadtrip.poi.looking': '查找',
|
||||
'roadtrip.poi.searchingHint': '正在沿路线搜索…',
|
||||
'roadtrip.poi.found': '沿途 {count} 处',
|
||||
'roadtrip.poi.fuel': '加油',
|
||||
'roadtrip.poi.charging': '充电',
|
||||
'roadtrip.poi.rest': '服务区',
|
||||
|
||||
Reference in New Issue
Block a user