mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
feat(roadtrip): let a day drive along an imported track
An imported GPX track was a line drawn beside the day: the map showed the scenic route while the day kept driving the motorway, so the distance, the drive time and every warning belonged to a road nobody was taking. Norway publishes its scenic routes that way, and so does every touring club worth importing from. A track becomes a route the day takes by refinement, not by conversion. Route through what we have, find the point of the track the road misses by the most, make that point a via, go again. Each round roughly halves the worst miss, because the new via lands in the middle of what the last one left unfollowed, so a scenic route usually fits inside ten vias — and the loop stops when the road is close enough rather than after a fixed count. Every point of the track would route exactly and take a hundred requests; two would route in seconds and follow nothing. The day is routed whole per round, not leg by leg: it is a single request already, and per-leg would multiply every round by the number of legs against a host that answers one request a second. The track is oriented to the day first, because half of Norway's routes are published southbound, and the legs it covers are cleared before the new chain lands, so applying a track twice does not stack. Offered from the day's own badges, and only once something has been imported. Tinted while the day carries vias: the rail draws none of them, so that badge is the only place a drive shaped by hand differs from one the router picked.
This commit is contained in:
@@ -509,6 +509,34 @@ describe('RoadtripSidebar', () => {
|
||||
|
||||
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: the track badge is a viewer-free control, and it says when a day is shaped', () => {
|
||||
const onFollowTrack = vi.fn()
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const { rerender } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} viaCounts={{ 1: 4 }} />,
|
||||
)
|
||||
|
||||
const badge = screen.getByRole('button', { name: 'Track' })
|
||||
fireEvent.click(badge)
|
||||
expect(onFollowTrack).toHaveBeenCalledWith(1)
|
||||
// The rail draws no vias, so the tint is the only place a drive shaped by hand
|
||||
// differs from one the router picked on its own.
|
||||
expect(badge.className).toContain('bg-accent-subtle')
|
||||
|
||||
rerender(
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
|
||||
</TranslationProvider>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
|
||||
ParkingSquare, Shuffle, Fuel, Clock,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -46,6 +46,20 @@ interface RoadtripSidebarProps {
|
||||
* Absent leaves every disc read-only, which is also what a viewer sees.
|
||||
*/
|
||||
onSetStopKind?: (placeId: number, kind: RoadtripStopType | null) => Promise<void> | void
|
||||
/**
|
||||
* Opens the dialog that makes a day follow an imported track (#1797).
|
||||
*
|
||||
* Absent leaves the rail read-only on that count, which is also what a viewer sees.
|
||||
*/
|
||||
onFollowTrack?: (dayId: number) => void
|
||||
/**
|
||||
* How many vias each day carries, so a day whose shape was chosen by hand says so.
|
||||
*
|
||||
* A count rather than the points: the rail draws none of them — they are the router's
|
||||
* business — but "this drive is not the one the router would have picked" is exactly
|
||||
* what somebody reading the day needs to know.
|
||||
*/
|
||||
viaCounts?: Record<number, number>
|
||||
}
|
||||
|
||||
const MODE_ICON: Record<string, LucideIcon> = {
|
||||
@@ -745,7 +759,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
* (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across
|
||||
* the trip would put "17" beside a pin the map calls "3".
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount }: {
|
||||
day: RoadtripDay
|
||||
selectedAssignmentId?: number | null
|
||||
onSelectStop?: (placeId: number, assignmentId: number) => void
|
||||
@@ -757,6 +771,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
|
||||
onFollowTrack?: RoadtripSidebarProps['onFollowTrack']
|
||||
viaCount?: number
|
||||
}): React.ReactElement {
|
||||
const { from, setFrom, dropAt, setDropAt } = drag
|
||||
const dragging = from?.dayId === day.dayId ? from.index : null
|
||||
@@ -816,6 +832,25 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{/* Among the day's facts rather than beside its title, because "which road this
|
||||
day takes" is one of them. Tinted once the day carries vias: the rail draws
|
||||
none of them, so this badge is the only place a drive shaped by hand differs
|
||||
from one the router picked on its own. */}
|
||||
{onFollowTrack ? (
|
||||
<Tooltip label={t('roadtrip.track.hint')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFollowTrack(day.dayId)}
|
||||
className={`${DAY_BADGE} gap-1 transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
viaCount ? 'bg-accent-subtle text-content' : 'bg-surface-tertiary hover:bg-surface-hover'
|
||||
}`}
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
<Spline size={10} className="shrink-0" aria-hidden />
|
||||
{t('roadtrip.track.badge')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
{/* One list item per stop, with the drive that follows it inside — the chain is a
|
||||
@@ -994,7 +1029,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
*/
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind,
|
||||
onSetStopKind, onFollowTrack, viaCounts,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// One drag state for the whole rail rather than one per day: a stop that cannot leave
|
||||
@@ -1037,6 +1072,8 @@ export default function RoadtripSidebar({
|
||||
openAlternatives={openAlternatives}
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onFollowTrack={onFollowTrack}
|
||||
viaCount={viaCounts?.[day.dayId] ?? 0}
|
||||
/>
|
||||
))}
|
||||
{routes.quietDays.map(day => (
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripTrackModal from './RoadtripTrackModal'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const track = (over: Partial<TrackChoice> & { id: number; name: string }): TrackChoice => ({
|
||||
color: '#0ea5e9',
|
||||
points: [{ lat: 52, lng: 13 }, { lat: 52, lng: 14 }],
|
||||
lengthKm: 68,
|
||||
gapKm: 0.4,
|
||||
...over,
|
||||
})
|
||||
|
||||
function follow(over: Partial<FollowTrack> = {}): FollowTrack {
|
||||
return {
|
||||
dayId: 7,
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
tracks: [track({ id: 3, name: 'Atlantic Road' })],
|
||||
busy: false,
|
||||
round: 0,
|
||||
error: null,
|
||||
outcome: null,
|
||||
apply: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
viaCount: 0,
|
||||
available: true,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripTrackModal', () => {
|
||||
it('FE-TRACKMODAL-001: closed is nothing at all, not a hidden dialog', () => {
|
||||
const { container } = wrap(<RoadtripTrackModal follow={follow({ dayId: null })} dayNumber={1} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [] })} dayNumber={2} />)
|
||||
expect(screen.getByText('No tracks in this trip')).toBeTruthy()
|
||||
expect(screen.getByText(/GPX or KML/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-003: a track close to the day says so rather than quoting a distance', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
expect(screen.getByText(/along this day/)).toBeTruthy()
|
||||
expect(screen.getByText(/68 km long/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-004: a track that runs elsewhere is offered with the distance to here', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [track({ id: 9, name: 'Elsewhere', gapKm: 240 })] })} dayNumber={1} />)
|
||||
expect(screen.getByText(/240 km off this day/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-005: nothing is applied until a track is picked', () => {
|
||||
const state = follow()
|
||||
wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Follow this track' })
|
||||
expect((action as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
// What it will do, before it does it: a track becomes a dozen waypoints on the day.
|
||||
expect(screen.getByText(/via points/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow this track' }))
|
||||
expect(state.apply).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-006: a picked track can be unpicked', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-007: a run says which round it is on rather than going blank', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, round: 4 })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/round 4/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 6, strayKm: 0.8, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/6 via points placed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-009: a run that ran out of vias admits the gap it left', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 12, strayKm: 2.4, capped: true } })} dayNumber={1} />)
|
||||
// The honest form: it did what it could, and the number says how much that was worth.
|
||||
expect(screen.getByRole('status').textContent).toMatch(/no closer than/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-010: a drive that already followed the track is not dressed up as work', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 0, strayKm: 0, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/already followed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ error: 'route' })} dayNumber={1} />)
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/routing service/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-012: dropping the day\'s vias is offered only once it has some', () => {
|
||||
const state = follow({ viaCount: 5 })
|
||||
const { rerender } = wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
const drop = screen.getByRole('button', { name: /Drop 5 via points/ })
|
||||
fireEvent.click(drop)
|
||||
expect(state.clear).toHaveBeenCalled()
|
||||
|
||||
rerender(<TranslationProvider><RoadtripTrackModal follow={follow({ viaCount: 0 })} dayNumber={1} /></TranslationProvider>)
|
||||
expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, viaCount: 2 })} dayNumber={1} />)
|
||||
expect((screen.getByRole('button', { name: /Atlantic Road/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: /Drop 2 via points/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { FS } from './typeScale'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* Picking the track a day drives along.
|
||||
*
|
||||
* The list is the whole dialog. Everything a traveller needs to tell two imported tracks
|
||||
* apart is on the row — the colour the map draws it in, how long it runs, and how far it
|
||||
* is from this particular day — and the last of those is why the list is ordered the way
|
||||
* it is: a trip carrying every track of a three-week tour still opens on the one beside
|
||||
* today's drive.
|
||||
*
|
||||
* Applying is slow and says so. It is a routing round trip per refinement, half a minute
|
||||
* on a long scenic route, and a dialog that went blank for that long would read as broken.
|
||||
*/
|
||||
|
||||
/** A track close enough that it is plainly about this day rather than another one. */
|
||||
const ON_THIS_DAY_KM = 2
|
||||
|
||||
function TrackRow({ track, disabled, onPick }: {
|
||||
track: TrackChoice
|
||||
disabled: boolean
|
||||
onPick: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const onDay = track.gapKm <= ON_THIS_DAY_KM
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onPick}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* The line's own colour, as a short stroke rather than a dot: it is a line on the
|
||||
map, and a dot beside a name reads as a place. */}
|
||||
<span
|
||||
className="h-1 w-6 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the colour the track carries, the same one the map draws.
|
||||
style={{ background: track.color || 'var(--text-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })}
|
||||
{' · '}
|
||||
{onDay
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripTrackModal({ follow, dayNumber }: {
|
||||
follow: FollowTrack
|
||||
/** The day's number, so the dialog says which drive it is about to reshape. */
|
||||
dayNumber: number
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [picked, setPicked] = useState<number | null>(null)
|
||||
|
||||
const { dayId, busy, round, error, outcome, tracks, viaCount } = follow
|
||||
|
||||
// A track picked on one day means nothing on the next.
|
||||
useEffect(() => { setPicked(null) }, [dayId])
|
||||
|
||||
if (dayId === null) return null
|
||||
|
||||
const chosen = tracks.find(track => track.id === picked) ?? null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={follow.close}
|
||||
size="sm"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Spline size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.track.title', { number: dayNumber })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{tracks.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
|
||||
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
|
||||
{tracks.map(track => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
disabled={busy}
|
||||
onPick={() => setPicked(track.id === picked ? null : track.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done. The count is the honest part: a track
|
||||
becomes a dozen waypoints on the day, and a traveller who later wonders where
|
||||
they came from should have been told here. */}
|
||||
{chosen ? (
|
||||
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
|
||||
{t('roadtrip.track.explain')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
|
||||
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
|
||||
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{outcome && !busy && !error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
|
||||
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
|
||||
{outcome.vias === 0
|
||||
? t('roadtrip.track.already')
|
||||
: outcome.capped
|
||||
? t('roadtrip.track.capped', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})
|
||||
: t('roadtrip.track.done', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Offered whenever the day carries vias, whether this dialog put them there or
|
||||
a dragged handle did: "put it back the way the router had it" is the same
|
||||
wish either way. */}
|
||||
{viaCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void follow.clear() }}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} aria-hidden />
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
|
||||
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { haversineKm, type LatLng } from './corridor'
|
||||
import {
|
||||
parseTrack, lineLengthKm, thin, nearestIndex, orient, stopIndices, trackGapKm, planTrack,
|
||||
} from './followTrack'
|
||||
|
||||
/**
|
||||
* The refinement is the interesting part, and it is testable without a router: what it
|
||||
* needs is something that answers "the road I would drive through these waypoints", and
|
||||
* the straight line between them is exactly the answer that makes the loop work for its
|
||||
* living. A track that bulges away from that chord is the shape every scenic route has.
|
||||
*/
|
||||
|
||||
/** A track arching north away from the straight line between its ends. */
|
||||
function arc(points = 120, bulgeDeg = 0.1): LatLng[] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => ({
|
||||
lat: 52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
lng: 13 + i / points,
|
||||
}))
|
||||
}
|
||||
|
||||
const START: LatLng = { lat: 52, lng: 13 }
|
||||
const END: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
/** A router with no idea about roads: it drives straight from waypoint to waypoint. */
|
||||
function straightRouter(): { route: (w: LatLng[]) => Promise<LatLng[]>; calls: LatLng[][] } {
|
||||
const calls: LatLng[][] = []
|
||||
return {
|
||||
calls,
|
||||
route: async (waypoints: LatLng[]) => {
|
||||
calls.push(waypoints)
|
||||
return waypoints
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseTrack', () => {
|
||||
it('FE-FOLLOWTRACK-001: reads pairs and triples, and steps over what is neither', () => {
|
||||
const geometry = JSON.stringify([[52, 13], [52.1, 13.1, 340], ['x', 1], [1], null, [53, 14]])
|
||||
expect(parseTrack(geometry)).toEqual([
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.1, lng: 13.1 },
|
||||
{ lat: 53, lng: 14 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-002: a track that is not a track is nothing, never a throw', () => {
|
||||
// Somebody else's column, filled by an importer that has seen every malformed GPX
|
||||
// there is. A day that cannot follow a track is a day; an exception here is a page.
|
||||
expect(parseTrack('not json')).toEqual([])
|
||||
expect(parseTrack('{"type":"LineString"}')).toEqual([])
|
||||
expect(parseTrack(null)).toEqual([])
|
||||
expect(parseTrack(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinning and measuring', () => {
|
||||
it('FE-FOLLOWTRACK-003: thinning keeps both ends, whatever the stride', () => {
|
||||
const line = arc(1000)
|
||||
const thinned = thin(line, 400)
|
||||
expect(thinned.length).toBeLessThanOrEqual(401)
|
||||
expect(thinned[0]).toEqual(line[0])
|
||||
expect(thinned[thinned.length - 1]).toEqual(line[line.length - 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-004: a line shorter than the cap comes back untouched', () => {
|
||||
const line = arc(10)
|
||||
expect(thin(line, 400)).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-005: an arc is longer than the chord it spans', () => {
|
||||
expect(lineLengthKm(arc())).toBeGreaterThan(haversineKm(START, END))
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-006: the nearest point of a track is found by position, not by order', () => {
|
||||
const line = arc(20)
|
||||
expect(nearestIndex(line, line[7])).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orientation', () => {
|
||||
it('FE-FOLLOWTRACK-007: a track recorded the other way round is turned', () => {
|
||||
// Half of Norway's scenic routes are published southbound. Without this the vias come
|
||||
// out in reverse order and the day drives the loop backwards.
|
||||
const backwards = [...arc(20)].reverse()
|
||||
const turned = orient(backwards, [START, END])
|
||||
expect(turned[0].lng).toBeLessThan(turned[turned.length - 1].lng)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-008: a track already pointing the right way is left alone', () => {
|
||||
const line = arc(20)
|
||||
expect(orient(line, [START, END])).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-009: stops never walk backwards along the track', () => {
|
||||
// A track that doubles back has two nearest points for the same stop, and the wrong
|
||||
// one would put a via on the leg before it.
|
||||
const line = arc(40)
|
||||
const stops = [START, { lat: 52.05, lng: 13.5 }, END]
|
||||
const idx = stopIndices(line, stops)
|
||||
expect(idx[0]).toBeLessThanOrEqual(idx[1])
|
||||
expect(idx[1]).toBeLessThanOrEqual(idx[2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTrack', () => {
|
||||
it('FE-FOLLOWTRACK-010: a road that already follows the track needs no vias', async () => {
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route: async () => track })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.rounds).toBe(1)
|
||||
expect(plan.capped).toBe(false)
|
||||
// Still names the leg it runs along, so applying it clears what an earlier pass left.
|
||||
expect(plan.legs).toEqual([0])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-011: it refines until the road is inside the tolerance', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
expect(plan.capped).toBe(false)
|
||||
expect(plan.strayKm).toBeLessThanOrEqual(1)
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
// One route per round, and the waypoint list grows by exactly the via just added.
|
||||
expect(calls.length).toBe(plan.rounds)
|
||||
expect(calls[calls.length - 1].length).toBe(2 + plan.vias.length)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-012: every via lands on the track, in driving order', async () => {
|
||||
const { route } = straightRouter()
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
for (const via of plan.vias) {
|
||||
expect(track.some(p => haversineKm(p, via) < 0.001)).toBe(true)
|
||||
}
|
||||
const longitudes = plan.vias.map(v => v.lng)
|
||||
expect([...longitudes].sort((a, b) => a - b)).toEqual(longitudes)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-013: no via is laid down on top of a stop', async () => {
|
||||
// A via a hundred metres from a stop costs a waypoint to tell the router what the
|
||||
// stop already told it.
|
||||
const { route } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 0.2, maxVias: 8 } })
|
||||
for (const via of plan.vias) {
|
||||
expect(haversineKm(via, START)).toBeGreaterThanOrEqual(0.6)
|
||||
expect(haversineKm(via, END)).toBeGreaterThanOrEqual(0.6)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-014: a via hangs behind the stop whose leg it shapes', async () => {
|
||||
const { route } = straightRouter()
|
||||
// Three stops, and the bulge is entirely on the second leg.
|
||||
const straight: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 52, lng: 13 + i / 78 }))
|
||||
const bulge: LatLng[] = Array.from({ length: 81 }, (_, i) => ({
|
||||
lat: 52 + 0.1 * Math.sin((Math.PI * i) / 80),
|
||||
lng: 13.5 + i / 160,
|
||||
}))
|
||||
const middle: LatLng = { lat: 52, lng: 13.5 }
|
||||
const plan = await planTrack({
|
||||
track: [...straight, ...bulge],
|
||||
stops: [START, middle, END],
|
||||
route,
|
||||
options: { toleranceKm: 1 },
|
||||
})
|
||||
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
expect(plan.vias.every(v => v.after_order_index === 1)).toBe(true)
|
||||
expect(plan.legs).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-015: it gives up rather than spending the day on a road that will not fit', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route,
|
||||
options: { toleranceKm: 0.0001, maxVias: 3 },
|
||||
})
|
||||
|
||||
expect(plan.capped).toBe(true)
|
||||
expect(plan.vias).toHaveLength(3)
|
||||
expect(calls.length).toBe(4)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-016: a track the day only brushes past is not a route for it', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: [{ lat: 40, lng: 2 }, { lat: 40.001, lng: 2.001 }], stops: [START, END], route })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.legs).toEqual([])
|
||||
// Never asks the router about a track that has nothing to do with the day.
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-017: nothing to route through is not an error', async () => {
|
||||
const { route } = straightRouter()
|
||||
expect((await planTrack({ track: arc(), stops: [START], route })).vias).toEqual([])
|
||||
expect((await planTrack({ track: [], stops: [START, END], route })).vias).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-018: a router that answers with no road says so', async () => {
|
||||
await expect(planTrack({ track: arc(), stops: [START, END], route: async () => [] }))
|
||||
.rejects.toThrow(/road/)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-019: an aborted plan stops where it is', async () => {
|
||||
const controller = new AbortController()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: controller.signal,
|
||||
options: { toleranceKm: 0.001 },
|
||||
})
|
||||
controller.abort()
|
||||
// Ran to its own conclusion here; the point is that the signal is honoured at the top
|
||||
// of each round, which the aborted-before-start case proves.
|
||||
expect(plan.rounds).toBeGreaterThan(0)
|
||||
|
||||
const stopped = new AbortController()
|
||||
stopped.abort()
|
||||
const none = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: stopped.signal,
|
||||
})
|
||||
expect(none.rounds).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-020: how far a track runs from a day is what orders a list of them', async () => {
|
||||
const near = trackGapKm(arc(), [START, END])
|
||||
const far = trackGapKm(arc().map(p => ({ lat: p.lat + 3, lng: p.lng })), [START, END])
|
||||
expect(near).toBeLessThan(far)
|
||||
expect(trackGapKm([], [START])).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
|
||||
|
||||
/**
|
||||
* Making a day's drive follow an imported track.
|
||||
*
|
||||
* A GPX track is a shape, not a route: a list of positions with no idea which roads they
|
||||
* were recorded on. Drawing it beside the day is easy and useless — the distance, the
|
||||
* drive time and every warning still belong to the motorway the router picked, so the
|
||||
* plan says four hours while the road nobody is taking says seven. Norway publishes its
|
||||
* scenic routes this way, and so does every touring club worth importing from.
|
||||
*
|
||||
* What turns a shape into a route the day actually takes is a handful of vias placed on
|
||||
* it, because a via is the one thing a router treats as "go through here". Placing them
|
||||
* is the whole problem. Every point of the track would route exactly and take a hundred
|
||||
* requests; two would route in seconds and follow nothing.
|
||||
*
|
||||
* So this refines: route through what we have, find the point of the track the road
|
||||
* misses by the most, make that point a via, and go again. Each round roughly halves the
|
||||
* worst miss, because the new via lands in the middle of whatever the last one left
|
||||
* unfollowed — which is why a scenic route usually fits inside ten of them, and why the
|
||||
* loop stops when the road is close enough rather than after a fixed count.
|
||||
*
|
||||
* Everything here is pure apart from the injected router: no React, no store, no api.
|
||||
*/
|
||||
|
||||
/** Track points kept for measuring — enough to catch a hairpin, few enough to be fast. */
|
||||
const MEASURE_POINTS = 400
|
||||
|
||||
/** Routed points kept for measuring. Coarser: it is the thing being measured against. */
|
||||
const ROUTE_POINTS = 2000
|
||||
|
||||
/**
|
||||
* How close a via may sit to a stop, or to another via, before it is pointless.
|
||||
*
|
||||
* A via a hundred metres from a stop tells the router nothing it did not already know
|
||||
* from the stop, and two vias in the same lay-by cost a waypoint each to say one thing.
|
||||
*/
|
||||
const MIN_GAP_KM = 0.6
|
||||
|
||||
export interface FollowTrackOptions {
|
||||
/** How close the road must come to the track before the plan is done, in kilometres. */
|
||||
toleranceKm?: number
|
||||
/** The most vias the plan may lay down. */
|
||||
maxVias?: number
|
||||
/** The most routing round trips it may spend. */
|
||||
maxRounds?: number
|
||||
}
|
||||
|
||||
export interface TrackVia {
|
||||
/** The stop the via hangs behind, as an index into the day's stops. */
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface FollowTrackPlan {
|
||||
/** The vias to write, in driving order, each already on the leg it shapes. */
|
||||
vias: TrackVia[]
|
||||
/** The legs the track runs along — cleared first, so applying twice does not stack. */
|
||||
legs: number[]
|
||||
/** How far the road still strays from the track at its worst point, in kilometres. */
|
||||
strayKm: number
|
||||
/** Routing round trips spent. */
|
||||
rounds: number
|
||||
/** True when it ran out of vias or rounds before the road fitted the track. */
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: FollowTrackPlan = { vias: [], legs: [], strayKm: 0, rounds: 0, capped: false }
|
||||
|
||||
/**
|
||||
* The points of a stored track.
|
||||
*
|
||||
* `route_geometry` is the column the GPX and KML importers write, holding `[lat, lng]`
|
||||
* pairs or `[lat, lng, ele]` triples depending on whether every point carried an
|
||||
* elevation. Anything else in there is somebody else's data — a malformed track is worth
|
||||
* skipping, never worth throwing over.
|
||||
*/
|
||||
export function parseTrack(routeGeometry: string | null | undefined): LatLng[] {
|
||||
if (!routeGeometry) return []
|
||||
try {
|
||||
const raw: unknown = JSON.parse(routeGeometry)
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: LatLng[] = []
|
||||
for (const point of raw) {
|
||||
if (!Array.isArray(point) || point.length < 2) continue
|
||||
const lat = Number(point[0])
|
||||
const lng = Number(point[1])
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
out.push({ lat, lng })
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a polyline is, in kilometres. */
|
||||
export function lineLengthKm(line: LatLng[]): number {
|
||||
let km = 0
|
||||
for (let i = 1; i < line.length; i++) km += haversineKm(line[i - 1], line[i])
|
||||
return km
|
||||
}
|
||||
|
||||
/**
|
||||
* Every nth point, keeping both ends.
|
||||
*
|
||||
* Deliberately not the Douglas-Peucker in `corridor.ts`: that one is recursive and slices
|
||||
* the array at every step, which is fine for a routed leg and quadratic on a recorded
|
||||
* track of thirty thousand points. Taking a stride loses a little accuracy on a hairpin
|
||||
* and none of the shape, and the tolerance this feeds is measured in kilometres.
|
||||
*/
|
||||
export function thin(line: LatLng[], max: number): LatLng[] {
|
||||
if (line.length <= max) return line
|
||||
const stride = Math.ceil(line.length / max)
|
||||
const out: LatLng[] = []
|
||||
for (let i = 0; i < line.length; i += stride) out.push(line[i])
|
||||
const last = line[line.length - 1]
|
||||
if (out[out.length - 1] !== last) out.push(last)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Index of the point of `line` closest to `p`. */
|
||||
export function nearestIndex(line: LatLng[], p: LatLng): number {
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let at = 0
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const d = haversineKm(line[i], p)
|
||||
if (d < best) {
|
||||
best = d
|
||||
at = i
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
/** How far a point lies from a line, in kilometres. Infinite for a line of one point. */
|
||||
export function offLineKm(line: LatLng[], p: LatLng): number {
|
||||
return projectOntoRoute(p, line)?.offRouteKm ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* The track pointing the way the day is driven.
|
||||
*
|
||||
* A recorded track has a direction, and it is whichever way the person who recorded it
|
||||
* happened to drive. Half of Norway's scenic routes are published southbound. Reversed
|
||||
* where the day's last stop comes before its first, so everything downstream can treat a
|
||||
* higher index as "further along the drive".
|
||||
*/
|
||||
export function orient(track: LatLng[], stops: LatLng[]): LatLng[] {
|
||||
if (track.length < 2 || stops.length < 2) return track
|
||||
const start = nearestIndex(track, stops[0])
|
||||
const end = nearestIndex(track, stops[stops.length - 1])
|
||||
return end < start ? [...track].reverse() : track
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each stop sits on the track, as an index.
|
||||
*
|
||||
* Forced not to go backwards. A stop nowhere near the track still gets its nearest point,
|
||||
* and on a track that doubles back that point can land before the previous stop's —
|
||||
* which would put a via on the wrong leg and send the day round the loop twice.
|
||||
*/
|
||||
export function stopIndices(track: LatLng[], stops: LatLng[]): number[] {
|
||||
let floor = 0
|
||||
return stops.map(stop => {
|
||||
const at = Math.max(floor, nearestIndex(track, stop))
|
||||
floor = at
|
||||
return at
|
||||
})
|
||||
}
|
||||
|
||||
/** The leg an anchor belongs to: the last stop it comes after. */
|
||||
function legFor(anchor: number, stopIdx: number[]): number {
|
||||
let leg = 0
|
||||
for (let k = 0; k + 1 < stopIdx.length; k++) if (stopIdx[k] <= anchor) leg = k
|
||||
return leg
|
||||
}
|
||||
|
||||
/** The stops with the anchors threaded in between, in the order they are driven. */
|
||||
function waypointsFor(stops: LatLng[], anchors: number[], track: LatLng[], stopIdx: number[]): LatLng[] {
|
||||
const out: LatLng[] = []
|
||||
for (let k = 0; k < stops.length; k++) {
|
||||
out.push(stops[k])
|
||||
if (k + 1 >= stops.length) continue
|
||||
for (const anchor of anchors) if (legFor(anchor, stopIdx) === k) out.push(track[anchor])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the track the road misses by the most — among the ones worth fixing.
|
||||
*
|
||||
* Points already anchored are skipped because the router is going through them, and
|
||||
* points hard against a stop or an existing via are skipped because a via there would
|
||||
* buy nothing. What is left is the miss the next round can actually close.
|
||||
*/
|
||||
function worstMiss(
|
||||
track: LatLng[],
|
||||
road: LatLng[],
|
||||
span: { from: number; to: number },
|
||||
anchors: number[],
|
||||
stops: LatLng[],
|
||||
): { index: number; km: number } | null {
|
||||
const taken = new Set(anchors)
|
||||
let best: { index: number; km: number } | null = null
|
||||
for (let i = span.from; i <= span.to; i++) {
|
||||
if (taken.has(i)) continue
|
||||
const point = track[i]
|
||||
if (stops.some(s => haversineKm(s, point) < MIN_GAP_KM)) continue
|
||||
if (anchors.some(a => haversineKm(track[a], point) < MIN_GAP_KM)) continue
|
||||
const km = offLineKm(road, point)
|
||||
if (!Number.isFinite(km)) continue
|
||||
if (!best || km > best.km) best = { index: i, km }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface TrackPlanInput {
|
||||
/** The track's points, as stored. */
|
||||
track: LatLng[]
|
||||
/** The day's stops, in driving order. */
|
||||
stops: LatLng[]
|
||||
/** Routes through the given waypoints and answers with the road it drew. */
|
||||
route: (waypoints: LatLng[]) => Promise<LatLng[]>
|
||||
options?: FollowTrackOptions
|
||||
/** Called after each round, so a dialog can say how far it has got. */
|
||||
onRound?: (round: number, strayKm: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a day a track has anything to say about.
|
||||
*
|
||||
* A trip full of imported tracks needs an order to offer them in, and "how far this one
|
||||
* runs from that drive" is the answer — measured from the stops rather than from the
|
||||
* routed line, so it holds for a day that has not routed yet.
|
||||
*/
|
||||
export function trackGapKm(track: LatLng[], stops: LatLng[]): number {
|
||||
if (!track.length || !stops.length) return Number.POSITIVE_INFINITY
|
||||
const line = thin(track, MEASURE_POINTS)
|
||||
if (line.length < 2) return haversineKm(line[0], stops[0])
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
for (const stop of stops) best = Math.min(best, offLineKm(line, stop))
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Vias that pull a day's drive onto a track.
|
||||
*
|
||||
* Routes the whole day per round rather than one leg at a time: the day is a single
|
||||
* request to begin with, and asking per leg would multiply every round by the number of
|
||||
* legs against a host that answers one request a second.
|
||||
*/
|
||||
export async function planTrack({
|
||||
track,
|
||||
stops,
|
||||
route,
|
||||
options = {},
|
||||
onRound,
|
||||
signal,
|
||||
}: TrackPlanInput): Promise<FollowTrackPlan> {
|
||||
const toleranceKm = options.toleranceKm ?? 1
|
||||
const maxVias = options.maxVias ?? 12
|
||||
const maxRounds = options.maxRounds ?? 10
|
||||
if (track.length < 2 || stops.length < 2) return EMPTY_PLAN
|
||||
|
||||
const measured = orient(thin(track, MEASURE_POINTS), stops)
|
||||
const stopIdx = stopIndices(measured, stops)
|
||||
const span = { from: stopIdx[0], to: stopIdx[stopIdx.length - 1] }
|
||||
// A track the day only touches at one point is a track for some other day.
|
||||
if (span.to - span.from < 2) return EMPTY_PLAN
|
||||
|
||||
// The legs the track runs along. Read off the stops, not off the anchors, so applying
|
||||
// a track twice clears what the first pass laid down instead of stacking on it — and
|
||||
// so a track that already fits still says which legs it owns.
|
||||
const legs: number[] = []
|
||||
for (let k = 0; k + 1 < stops.length; k++) {
|
||||
if (stopIdx[k + 1] > span.from && stopIdx[k] < span.to) legs.push(k)
|
||||
}
|
||||
|
||||
const anchors: number[] = []
|
||||
let strayKm = 0
|
||||
let rounds = 0
|
||||
let capped = false
|
||||
|
||||
for (;;) {
|
||||
if (signal?.aborted) break
|
||||
const drawn = await route(waypointsFor(stops, anchors, measured, stopIdx))
|
||||
if (drawn.length < 2) throw new Error('The router answered without a road')
|
||||
rounds += 1
|
||||
|
||||
const miss = worstMiss(measured, thin(drawn, ROUTE_POINTS), span, anchors, stops)
|
||||
// No candidate left to fix is as finished as being inside the tolerance: everything
|
||||
// that remains sits against a stop or against a via that is already there.
|
||||
strayKm = miss?.km ?? 0
|
||||
onRound?.(rounds, strayKm)
|
||||
if (!miss || miss.km <= toleranceKm) break
|
||||
if (anchors.length >= maxVias || rounds >= maxRounds) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
anchors.push(miss.index)
|
||||
anchors.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return {
|
||||
vias: anchors.map(i => ({
|
||||
after_order_index: legFor(i, stopIdx),
|
||||
lat: measured[i].lat,
|
||||
lng: measured[i].lng,
|
||||
})),
|
||||
legs,
|
||||
strayKm,
|
||||
rounds,
|
||||
capped,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
import type { TrackPlace } from './useFollowTrack'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useFollowTrack } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* The hook is the part that talks to two services and has to survive both of them going
|
||||
* wrong halfway. The refinement itself is `followTrack`'s business and tested there; what
|
||||
* matters here is what reaches the day and what reaches the traveller.
|
||||
*/
|
||||
|
||||
const START = { lat: 52, lng: 13 }
|
||||
const END = { lat: 52, lng: 14 }
|
||||
|
||||
/** A track arching north away from the straight line between the day's two stops. */
|
||||
function arc(points = 60, bulgeDeg = 0.1): [number, number][] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => [
|
||||
52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
13 + i / points,
|
||||
])
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
return {
|
||||
byDay: {},
|
||||
add: vi.fn(),
|
||||
addMany: vi.fn().mockResolvedValue(undefined),
|
||||
move: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
reanchor: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TRACK: TrackPlace = { id: 3, name: 'Atlantic Road', route_geometry: JSON.stringify(arc()), route_color: '#0ea5e9' }
|
||||
const PLAIN: TrackPlace = { id: 4, name: 'A hotel', route_geometry: null }
|
||||
const FAR: TrackPlace = { id: 5, name: 'Somewhere else', route_geometry: JSON.stringify(arc().map(([la, ln]) => [la + 4, ln])) }
|
||||
|
||||
/**
|
||||
* A router with no idea about roads: it drives straight from waypoint to waypoint.
|
||||
*
|
||||
* Which is exactly what makes the refinement work for its living — each via added bends
|
||||
* the answer a little closer to the arc, so the loop converges instead of running to its
|
||||
* cap the way a fixed answer would.
|
||||
*/
|
||||
function answersStraight(): void {
|
||||
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
|
||||
coordinates: waypoints.map(w => [w.lat, w.lng]),
|
||||
distance: 0, duration: 0, legs: [],
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
})
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.dayId).toBeNull()
|
||||
// Tens of thousands of points per track, for a dialog nobody opened.
|
||||
expect(result.current.tracks).toEqual([])
|
||||
// But it still knows there is something to open, without parsing a single point.
|
||||
expect(result.current.available).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.available).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-002: opening lists only the places that carry a line, nearest first', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [FAR, PLAIN, TRACK], routes([day()]), viasStub()))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
expect(result.current.tracks.map(t => t.id)).toEqual([3, 5])
|
||||
expect(result.current.tracks[0].name).toBe('Atlantic Road')
|
||||
expect(result.current.tracks[0].color).toBe('#0ea5e9')
|
||||
expect(result.current.tracks[0].lengthKm).toBeGreaterThan(60)
|
||||
expect(result.current.tracks[0].gapKm).toBeLessThan(result.current.tracks[1].gapKm)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-003: applying writes the plan onto the day, legs and all', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(vias.addMany).toHaveBeenCalledTimes(1)
|
||||
const [dayId, written, legs] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(dayId).toBe(7)
|
||||
expect(legs).toEqual([0])
|
||||
expect(written.length).toBeGreaterThan(0)
|
||||
// Every via hangs behind the first stop, because the day has exactly one leg.
|
||||
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
|
||||
expect(result.current.outcome?.vias).toBe(written.length)
|
||||
expect(result.current.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-004: a track the drive already follows writes nothing but says so', async () => {
|
||||
// The router answers with the track itself, so there is nothing left to pull onto.
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: arc(), distance: 0, duration: 0, legs: [] })
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
// Still a write: the legs are cleared, so applying a second track does not stack.
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('route')
|
||||
expect(result.current.busy).toBe(false)
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
// One retry, because a refinement is several calls against a rate-limited host.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-006: a save that fails says so instead of claiming the day changed', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub({ addMany: vi.fn().mockRejectedValue(new Error('boom')) })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('save')
|
||||
expect(result.current.outcome).toBeNull()
|
||||
expect(result.current.busy).toBe(false)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-007: clearing drops the vias of every leg of the day', async () => {
|
||||
const vias = viasStub({ byDay: { 7: [{ id: 1 }, { id: 2 }] as never } })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.viaCount).toBe(2)
|
||||
|
||||
await act(async () => { await result.current.clear() })
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0])
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-008: closing abandons a run rather than letting it land afterwards', async () => {
|
||||
let release: ((value: unknown) => void) | null = null
|
||||
calculateRouteWithLegs.mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
let running: Promise<void> | null = null
|
||||
act(() => { running = result.current.apply(3) })
|
||||
await waitFor(() => { expect(result.current.busy).toBe(true) })
|
||||
|
||||
act(() => { result.current.close() })
|
||||
await act(async () => {
|
||||
release?.({ coordinates: [[START.lat, START.lng], [END.lat, END.lng]], distance: 0, duration: 0, legs: [] })
|
||||
await running
|
||||
})
|
||||
|
||||
// The day is left exactly as it was: an abandoned plan is not half a chain of vias.
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
expect(result.current.dayId).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const lonely = day({ stops: [day().stops[0]] })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([lonely]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
|
||||
/**
|
||||
* The dialog state behind "this day follows that track".
|
||||
*
|
||||
* Kept out of the dialog because applying one is a long job — a round trip to the routing
|
||||
* host per refinement — and a component that unmounts halfway would leave the day with
|
||||
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
|
||||
* got while it runs.
|
||||
*/
|
||||
|
||||
/** A track of this trip, ready to be offered. */
|
||||
export interface TrackChoice {
|
||||
id: number
|
||||
name: string
|
||||
/** The colour the map already draws it in, so the list and the line agree. */
|
||||
color: string | null
|
||||
points: LatLng[]
|
||||
lengthKm: number
|
||||
/** How far the track runs from the open day at its closest — what orders the list. */
|
||||
gapKm: number
|
||||
}
|
||||
|
||||
/** What became of an applied track. */
|
||||
export interface FollowTrackOutcome {
|
||||
vias: number
|
||||
strayKm: number
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
export interface FollowTrack {
|
||||
/** The day the dialog is open for, or null when it is closed. */
|
||||
dayId: number | null
|
||||
open: (dayId: number) => void
|
||||
close: () => void
|
||||
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
|
||||
tracks: TrackChoice[]
|
||||
busy: boolean
|
||||
/** Which refinement round is running, for a dialog that has to say something. */
|
||||
round: number
|
||||
error: 'route' | 'save' | null
|
||||
outcome: FollowTrackOutcome | null
|
||||
apply: (trackId: number) => Promise<void>
|
||||
/** Drops every via of the open day, so it drives the way the router would. */
|
||||
clear: () => Promise<void>
|
||||
/** How many vias the open day carries — what makes clearing worth offering. */
|
||||
viaCount: number
|
||||
/**
|
||||
* Whether the trip holds a track at all.
|
||||
*
|
||||
* Read off the column rather than off `tracks`, which is empty until the dialog opens
|
||||
* and would therefore never let it be opened. It is what keeps the rail from offering
|
||||
* a control whose only possible answer is "no tracks in this trip".
|
||||
*/
|
||||
available: boolean
|
||||
}
|
||||
|
||||
/** A place is a track when it carries a line. */
|
||||
export interface TrackPlace {
|
||||
id: number
|
||||
name?: string | null
|
||||
route_geometry?: string | null
|
||||
route_color?: string | null
|
||||
}
|
||||
|
||||
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
|
||||
export const ROUND_PAUSE_MS = 400
|
||||
|
||||
/** How long to wait before the one retry a round gets. */
|
||||
export const RETRY_PAUSE_MS = 1500
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
|
||||
|
||||
export function useFollowTrack(
|
||||
tripId: number | string | null,
|
||||
places: TrackPlace[],
|
||||
routes: RoadtripRoutes,
|
||||
vias: RoadtripVias,
|
||||
): FollowTrack {
|
||||
const [dayId, setDayId] = useState<number | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [round, setRound] = useState(0)
|
||||
const [error, setError] = useState<'route' | 'save' | null>(null)
|
||||
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
*
|
||||
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
|
||||
* parsing them to render a rail nobody opened would cost more than the rail does. The
|
||||
* memo hangs off `dayId` as well as `places` for exactly that reason.
|
||||
*/
|
||||
const tracks = useMemo<TrackChoice[]>(() => {
|
||||
if (dayId === null) return []
|
||||
const found: TrackChoice[] = []
|
||||
for (const place of places) {
|
||||
const points = parseTrack(place.route_geometry)
|
||||
if (points.length < 2) continue
|
||||
found.push({
|
||||
id: place.id,
|
||||
name: place.name || '',
|
||||
color: place.route_color ?? null,
|
||||
points,
|
||||
lengthKm: lineLengthKm(points),
|
||||
gapKm: trackGapKm(points, stops),
|
||||
})
|
||||
}
|
||||
// Nearest first: a trip carrying every track of a three-week tour should still open
|
||||
// on the one that runs along today's drive.
|
||||
return found.sort((a, b) => a.gapKm - b.gapKm)
|
||||
}, [dayId, places, stops])
|
||||
|
||||
const open = useCallback((next: number) => {
|
||||
setDayId(next)
|
||||
setOutcome(null)
|
||||
setError(null)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
abort.current?.abort()
|
||||
abort.current = null
|
||||
setDayId(null)
|
||||
setBusy(false)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const apply = useCallback(async (trackId: number) => {
|
||||
const track = tracks.find(t => t.id === trackId)
|
||||
if (!track || dayId === null || stops.length < 2 || busy) return
|
||||
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setOutcome(null)
|
||||
setRound(0)
|
||||
|
||||
let plan: FollowTrackPlan
|
||||
let spent = 0
|
||||
try {
|
||||
plan = await planTrack({
|
||||
track: track.points,
|
||||
stops,
|
||||
signal: controller.signal,
|
||||
onRound: n => { setRound(n) },
|
||||
route: async waypoints => {
|
||||
// Paced before the call rather than after it, so a plan that fits on its first
|
||||
// round costs nothing in waiting — and given one second chance, because a
|
||||
// refinement is several calls in a row against a host that allows about one a
|
||||
// second, and losing the whole loop to a single 429 would restart it at nothing.
|
||||
if (spent > 0) await sleep(ROUND_PAUSE_MS)
|
||||
spent += 1
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(waypoints, {
|
||||
signal: controller.signal,
|
||||
profile: 'driving',
|
||||
tripId,
|
||||
dayId,
|
||||
})
|
||||
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
|
||||
} catch (err) {
|
||||
if (attempt >= 1 || controller.signal.aborted) throw err
|
||||
await sleep(RETRY_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError('route')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) { setBusy(false); return }
|
||||
|
||||
try {
|
||||
await vias.addMany(dayId, plan.vias, plan.legs)
|
||||
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [tracks, dayId, stops, busy, tripId, vias])
|
||||
|
||||
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
|
||||
// A cheap look at the column, not a parse: this runs on every planner render, and the
|
||||
// question is only whether anything at all was ever imported.
|
||||
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
if (dayId === null || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Every leg of the day, so nothing is left behind on a leg the track never reached.
|
||||
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
|
||||
await vias.addMany(dayId, [], legs)
|
||||
setOutcome({ vias: 0, strayKm: 0, capped: false })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [dayId, busy, stops.length, vias])
|
||||
|
||||
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available }
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip
|
||||
const RoadtripLimitsCard = lazyWithRetry(() => import('../components/Roadtrip/RoadtripLimitsCard'))
|
||||
const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup'))
|
||||
const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
|
||||
const RoadtripTrackModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripTrackModal'))
|
||||
const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
|
||||
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
|
||||
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
|
||||
@@ -251,6 +252,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth,
|
||||
@@ -538,6 +540,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
|
||||
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
|
||||
viaCounts={roadtripViaCounts}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -943,6 +947,16 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
draft exists, so the chunk stays unloaded for anyone not using road trip mode. */}
|
||||
{/* How long a stop takes. Its own gate, not the corridor draft's: a stay is set on
|
||||
any stop of the trip, not only on something just found along the route. */}
|
||||
{/* Which track a day drives along. Mounted only while it is open, so the chunk and
|
||||
the parsing of every imported line stay out of an ordinary planner session. */}
|
||||
{followTrack.dayId !== null && (
|
||||
<LazyPanel id="roadtrip-track">
|
||||
<RoadtripTrackModal
|
||||
follow={followTrack}
|
||||
dayNumber={roadtripRoutes.days.find(d => d.dayId === followTrack.dayId)?.dayNumber ?? 0}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stayDraft && (
|
||||
<LazyPanel id="roadtrip-stay">
|
||||
<RoadtripStayModal stop={stayDraft} onClose={() => setStayDraft(null)} onSave={setRoadtripStay} />
|
||||
|
||||
@@ -150,6 +150,8 @@ 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() },
|
||||
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,
|
||||
setStopDraft: vi.fn(),
|
||||
saveStopDraft: vi.fn(async () => undefined),
|
||||
|
||||
@@ -1023,6 +1023,10 @@ describe('useTripPlanner — add place entry points', () => {
|
||||
|
||||
expect(result.current.prefillCoords).toEqual({
|
||||
lat: 1, lng: 2, name: 'Cafe', address: '', website: undefined, phone: undefined, osm_id: 'node/1',
|
||||
// A marker with no category behind it is an ordinary place, and the form is told so
|
||||
// rather than left to guess: `stop_type` is write-once on the server, so an absent
|
||||
// field and an explicit "not a service stop" are different answers.
|
||||
stop_type: null, duration_minutes: undefined,
|
||||
})
|
||||
expect(result.current.showPlaceForm).toBe(true)
|
||||
expect(mapsApi.reverse).not.toHaveBeenCalled()
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
||||
@@ -546,6 +547,16 @@ export function useTripPlanner() {
|
||||
)
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
|
||||
// Applying a track is a long job — a routing round trip per refinement — so it lives
|
||||
// above the dialog: a component that unmounted halfway would leave the day holding
|
||||
// half a chain of vias.
|
||||
const followTrack = useFollowTrack(tripId, places, roadtripRoutes, roadtripVias)
|
||||
/** How many vias each day carries, for the rail's badge. */
|
||||
const roadtripViaCounts = useMemo(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const [dayId, list] of Object.entries(roadtripVias.byDay)) counts[Number(dayId)] = list.length
|
||||
return counts
|
||||
}, [roadtripVias.byDay])
|
||||
|
||||
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
||||
tripActions.setSelectedDay(dayId)
|
||||
@@ -1713,6 +1724,7 @@ export function useTripPlanner() {
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'مجاني',
|
||||
'roadtrip.poi.wholeDay': 'اليوم كله',
|
||||
'roadtrip.poi.midLeg': 'في المنتصف، {from} إلى {to}',
|
||||
'roadtrip.track.title': 'اليوم {number} يتبع مسارًا',
|
||||
'roadtrip.track.hint': 'اجعل هذا اليوم يتبع مسارًا مستوردًا',
|
||||
'roadtrip.track.badge': 'مسار',
|
||||
'roadtrip.track.untitled': 'مسار بلا اسم',
|
||||
'roadtrip.track.empty': 'لا توجد مسارات في هذه الرحلة',
|
||||
'roadtrip.track.emptyHint': 'استورد ملف GPX أو KML وستظهر مساراته هنا.',
|
||||
'roadtrip.track.length': 'بطول {distance}',
|
||||
'roadtrip.track.gap': 'على بعد {distance} من هذا اليوم',
|
||||
'roadtrip.track.onDay': 'على امتداد هذا اليوم',
|
||||
'roadtrip.track.explain': 'يُسحب المسار إلى التتبع بواسطة بضع نقاط عبور. تُستبدل نقاط العبور الموجودة على تلك المراحل.',
|
||||
'roadtrip.track.action': 'اتبع هذا المسار',
|
||||
'roadtrip.track.working': 'يجري ملاءمة المسار مع التتبع، الجولة {round}',
|
||||
'roadtrip.track.done': 'تم وضع {count} نقطة عبور. يبقى المسار ضمن {distance} من التتبع.',
|
||||
'roadtrip.track.capped': 'تم وضع {count} نقطة عبور. في أوسع نقطة يبتعد المسار {distance} عن التتبع.',
|
||||
'roadtrip.track.already': 'كان المسار يتبع هذا التتبع بالفعل.',
|
||||
'roadtrip.track.errorRoute': 'لم تستجب خدمة تحديد المسارات.',
|
||||
'roadtrip.track.errorSave': 'تعذّر حفظ نقاط العبور.',
|
||||
'roadtrip.track.clear': 'إزالة {count} نقطة عبور',
|
||||
'roadtrip.poi.rest': 'استراحة',
|
||||
'roadtrip.poi.campsite': 'مخيم',
|
||||
'roadtrip.poi.food': 'طعام',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratuito',
|
||||
'roadtrip.poi.wholeDay': 'O dia todo',
|
||||
'roadtrip.poi.midLeg': 'Na metade, {from} ate {to}',
|
||||
'roadtrip.track.title': 'O dia {number} segue uma trilha',
|
||||
'roadtrip.track.hint': 'Fazer este dia seguir uma trilha importada',
|
||||
'roadtrip.track.badge': 'Trilha',
|
||||
'roadtrip.track.untitled': 'Trilha sem nome',
|
||||
'roadtrip.track.empty': 'Sem trilhas nesta viagem',
|
||||
'roadtrip.track.emptyHint': 'Importe um arquivo GPX ou KML e suas trilhas aparecem aqui.',
|
||||
'roadtrip.track.length': '{distance} de comprimento',
|
||||
'roadtrip.track.gap': 'a {distance} deste dia',
|
||||
'roadtrip.track.onDay': 'ao longo deste dia',
|
||||
'roadtrip.track.explain': 'O percurso é puxado para a trilha com alguns pontos de passagem. Os pontos existentes nessas etapas são substituídos.',
|
||||
'roadtrip.track.action': 'Seguir esta trilha',
|
||||
'roadtrip.track.working': 'Ajustando o percurso à trilha, rodada {round}',
|
||||
'roadtrip.track.done': '{count} pontos de passagem colocados. O percurso fica a menos de {distance} da trilha.',
|
||||
'roadtrip.track.capped': '{count} pontos de passagem colocados. No ponto mais largo, o percurso fica a {distance} da trilha.',
|
||||
'roadtrip.track.already': 'O percurso já seguia esta trilha.',
|
||||
'roadtrip.track.errorRoute': 'O serviço de rotas não respondeu.',
|
||||
'roadtrip.track.errorSave': 'Não foi possível salvar os pontos de passagem.',
|
||||
'roadtrip.track.clear': 'Remover {count} pontos de passagem',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratuït',
|
||||
'roadtrip.poi.wholeDay': 'Tot el dia',
|
||||
'roadtrip.poi.midLeg': 'A mig cami, {from} a {to}',
|
||||
'roadtrip.track.title': 'El dia {number} segueix una traça',
|
||||
'roadtrip.track.hint': 'Fes que aquest dia segueixi una traça importada',
|
||||
'roadtrip.track.badge': 'Traça',
|
||||
'roadtrip.track.untitled': 'Traça sense nom',
|
||||
'roadtrip.track.empty': 'No hi ha traces en aquest viatge',
|
||||
'roadtrip.track.emptyHint': 'Importa un fitxer GPX o KML i les seves traces apareixeran aquí.',
|
||||
'roadtrip.track.length': '{distance} de llarg',
|
||||
'roadtrip.track.gap': 'a {distance} d’aquest dia',
|
||||
'roadtrip.track.onDay': 'al llarg d’aquest dia',
|
||||
'roadtrip.track.explain': 'El trajecte s’ajusta a la traça amb uns quants punts de pas. Els punts existents en aquests trams se substitueixen.',
|
||||
'roadtrip.track.action': 'Segueix aquesta traça',
|
||||
'roadtrip.track.working': 'Ajustant el trajecte a la traça, ronda {round}',
|
||||
'roadtrip.track.done': 'S’han col·locat {count} punts de pas. El trajecte es manté a menys de {distance} de la traça.',
|
||||
'roadtrip.track.capped': 'S’han col·locat {count} punts de pas. En el punt més ample, el trajecte queda a {distance} de la traça.',
|
||||
'roadtrip.track.already': 'El trajecte ja seguia aquesta traça.',
|
||||
'roadtrip.track.errorRoute': 'El servei de rutes no ha respost.',
|
||||
'roadtrip.track.errorSave': 'No s’han pogut desar els punts de pas.',
|
||||
'roadtrip.track.clear': 'Elimina {count} punts de pas',
|
||||
'roadtrip.poi.rest': 'Àrea de descans',
|
||||
'roadtrip.poi.campsite': 'Càmping',
|
||||
'roadtrip.poi.food': 'Menjar',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Zdarma',
|
||||
'roadtrip.poi.wholeDay': 'Cely den',
|
||||
'roadtrip.poi.midLeg': 'V polovine, {from} do {to}',
|
||||
'roadtrip.track.title': 'Den {number} sleduje stopu',
|
||||
'roadtrip.track.hint': 'Nechat tento den sledovat importovanou stopu',
|
||||
'roadtrip.track.badge': 'Stopa',
|
||||
'roadtrip.track.untitled': 'Stopa bez názvu',
|
||||
'roadtrip.track.empty': 'V této cestě nejsou žádné stopy',
|
||||
'roadtrip.track.emptyHint': 'Importujte soubor GPX nebo KML a jeho stopy se objeví zde.',
|
||||
'roadtrip.track.length': 'délka {distance}',
|
||||
'roadtrip.track.gap': '{distance} od tohoto dne',
|
||||
'roadtrip.track.onDay': 'podél tohoto dne',
|
||||
'roadtrip.track.explain': 'Jízda se pomocí několika průjezdních bodů přitáhne ke stopě. Stávající body na těchto úsecích budou nahrazeny.',
|
||||
'roadtrip.track.action': 'Sledovat tuto stopu',
|
||||
'roadtrip.track.working': 'Přizpůsobuji jízdu stopě, kolo {round}',
|
||||
'roadtrip.track.done': 'Umístěno {count} průjezdních bodů. Jízda se drží do {distance} od stopy.',
|
||||
'roadtrip.track.capped': 'Umístěno {count} průjezdních bodů. V nejširším místě je jízda {distance} od stopy.',
|
||||
'roadtrip.track.already': 'Jízda už tuto stopu sledovala.',
|
||||
'roadtrip.track.errorRoute': 'Služba plánování trasy neodpověděla.',
|
||||
'roadtrip.track.errorSave': 'Průjezdní body se nepodařilo uložit.',
|
||||
'roadtrip.track.clear': 'Odebrat {count} průjezdních bodů',
|
||||
'roadtrip.poi.rest': 'Odpočívadlo',
|
||||
'roadtrip.poi.campsite': 'Kemp',
|
||||
'roadtrip.poi.food': 'Jídlo',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Kostenlos',
|
||||
'roadtrip.poi.wholeDay': 'Ganzer Tag',
|
||||
'roadtrip.poi.midLeg': 'Auf halber Strecke, {from} bis {to}',
|
||||
'roadtrip.track.title': 'Tag {number} folgt einer Spur',
|
||||
'roadtrip.track.hint': 'Diesen Tag einer importierten Spur folgen lassen',
|
||||
'roadtrip.track.badge': 'Spur',
|
||||
'roadtrip.track.untitled': 'Namenlose Spur',
|
||||
'roadtrip.track.empty': 'Keine Spuren in dieser Reise',
|
||||
'roadtrip.track.emptyHint': 'Importiere eine GPX- oder KML-Datei, dann stehen ihre Spuren hier.',
|
||||
'roadtrip.track.length': '{distance} lang',
|
||||
'roadtrip.track.gap': '{distance} von diesem Tag entfernt',
|
||||
'roadtrip.track.onDay': 'entlang dieses Tages',
|
||||
'roadtrip.track.explain': 'Die Fahrt wird mit einigen Zwischenpunkten auf die Spur gezogen. Vorhandene Zwischenpunkte dieser Etappen werden ersetzt.',
|
||||
'roadtrip.track.action': 'Dieser Spur folgen',
|
||||
'roadtrip.track.working': 'Fahrt wird an die Spur angepasst, Runde {round}',
|
||||
'roadtrip.track.done': '{count} Zwischenpunkte gesetzt. Die Fahrt bleibt bis auf {distance} an der Spur.',
|
||||
'roadtrip.track.capped': '{count} Zwischenpunkte gesetzt. An der weitesten Stelle bleibt die Fahrt {distance} von der Spur entfernt.',
|
||||
'roadtrip.track.already': 'Die Fahrt folgte dieser Spur bereits.',
|
||||
'roadtrip.track.errorRoute': 'Der Routendienst hat nicht geantwortet.',
|
||||
'roadtrip.track.errorSave': 'Die Zwischenpunkte konnten nicht gespeichert werden.',
|
||||
'roadtrip.track.clear': '{count} Zwischenpunkte entfernen',
|
||||
'roadtrip.poi.rest': 'Rastplatz',
|
||||
'roadtrip.poi.campsite': 'Campingplatz',
|
||||
'roadtrip.poi.food': 'Essen',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Free',
|
||||
'roadtrip.poi.wholeDay': 'The whole day',
|
||||
'roadtrip.poi.midLeg': 'Halfway, {from} to {to}',
|
||||
'roadtrip.track.title': 'Day {number} follows a track',
|
||||
'roadtrip.track.hint': 'Make this day follow an imported track',
|
||||
'roadtrip.track.badge': 'Track',
|
||||
'roadtrip.track.untitled': 'Unnamed track',
|
||||
'roadtrip.track.empty': 'No tracks in this trip',
|
||||
'roadtrip.track.emptyHint': 'Import a GPX or KML file and its tracks land here.',
|
||||
'roadtrip.track.length': '{distance} long',
|
||||
'roadtrip.track.gap': '{distance} off this day',
|
||||
'roadtrip.track.onDay': 'along this day',
|
||||
'roadtrip.track.explain': 'The drive is pulled onto the track with a handful of via points. Existing via points on those legs are replaced.',
|
||||
'roadtrip.track.action': 'Follow this track',
|
||||
'roadtrip.track.working': 'Fitting the drive to the track, round {round}',
|
||||
'roadtrip.track.done': '{count} via points placed. The drive stays within {distance} of the track.',
|
||||
'roadtrip.track.capped': '{count} via points placed. The drive gets no closer than {distance} at its widest.',
|
||||
'roadtrip.track.already': 'The drive already followed this track.',
|
||||
'roadtrip.track.errorRoute': 'The routing service did not answer.',
|
||||
'roadtrip.track.errorSave': 'The via points could not be saved.',
|
||||
'roadtrip.track.clear': 'Drop {count} via points',
|
||||
'roadtrip.poi.rest': 'Rest area',
|
||||
'roadtrip.poi.campsite': 'Campsite',
|
||||
'roadtrip.poi.food': 'Food',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratis',
|
||||
'roadtrip.poi.wholeDay': 'Todo el dia',
|
||||
'roadtrip.poi.midLeg': 'A mitad, {from} a {to}',
|
||||
'roadtrip.track.title': 'El día {number} sigue una traza',
|
||||
'roadtrip.track.hint': 'Hacer que este día siga una traza importada',
|
||||
'roadtrip.track.badge': 'Traza',
|
||||
'roadtrip.track.untitled': 'Traza sin nombre',
|
||||
'roadtrip.track.empty': 'No hay trazas en este viaje',
|
||||
'roadtrip.track.emptyHint': 'Importa un archivo GPX o KML y sus trazas aparecerán aquí.',
|
||||
'roadtrip.track.length': '{distance} de largo',
|
||||
'roadtrip.track.gap': 'a {distance} de este día',
|
||||
'roadtrip.track.onDay': 'a lo largo de este día',
|
||||
'roadtrip.track.explain': 'El trayecto se ajusta a la traza con unos pocos puntos de paso. Los puntos existentes en esos tramos se reemplazan.',
|
||||
'roadtrip.track.action': 'Seguir esta traza',
|
||||
'roadtrip.track.working': 'Ajustando el trayecto a la traza, ronda {round}',
|
||||
'roadtrip.track.done': '{count} puntos de paso colocados. El trayecto se mantiene a menos de {distance} de la traza.',
|
||||
'roadtrip.track.capped': '{count} puntos de paso colocados. En su punto más ancho, el trayecto queda a {distance} de la traza.',
|
||||
'roadtrip.track.already': 'El trayecto ya seguía esta traza.',
|
||||
'roadtrip.track.errorRoute': 'El servicio de rutas no respondió.',
|
||||
'roadtrip.track.errorSave': 'No se pudieron guardar los puntos de paso.',
|
||||
'roadtrip.track.clear': 'Quitar {count} puntos de paso',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratuit',
|
||||
'roadtrip.poi.wholeDay': 'Toute la journee',
|
||||
'roadtrip.poi.midLeg': 'A mi-chemin, {from} vers {to}',
|
||||
'roadtrip.track.title': 'Le jour {number} suit une trace',
|
||||
'roadtrip.track.hint': 'Faire suivre à ce jour une trace importée',
|
||||
'roadtrip.track.badge': 'Trace',
|
||||
'roadtrip.track.untitled': 'Trace sans nom',
|
||||
'roadtrip.track.empty': 'Aucune trace dans ce voyage',
|
||||
'roadtrip.track.emptyHint': 'Importez un fichier GPX ou KML et ses traces apparaîtront ici.',
|
||||
'roadtrip.track.length': '{distance} de long',
|
||||
'roadtrip.track.gap': 'à {distance} de ce jour',
|
||||
'roadtrip.track.onDay': 'le long de ce jour',
|
||||
'roadtrip.track.explain': 'Le trajet est ramené sur la trace à l’aide de quelques points de passage. Les points existants sur ces étapes sont remplacés.',
|
||||
'roadtrip.track.action': 'Suivre cette trace',
|
||||
'roadtrip.track.working': 'Ajustement du trajet à la trace, tour {round}',
|
||||
'roadtrip.track.done': '{count} points de passage placés. Le trajet reste à moins de {distance} de la trace.',
|
||||
'roadtrip.track.capped': '{count} points de passage placés. Au plus large, le trajet reste à {distance} de la trace.',
|
||||
'roadtrip.track.already': 'Le trajet suivait déjà cette trace.',
|
||||
'roadtrip.track.errorRoute': 'Le service de calcul d’itinéraire n’a pas répondu.',
|
||||
'roadtrip.track.errorSave': 'Les points de passage n’ont pas pu être enregistrés.',
|
||||
'roadtrip.track.clear': 'Supprimer {count} points de passage',
|
||||
'roadtrip.poi.rest': 'Aire de repos',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Restauration',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Δωρεάν',
|
||||
'roadtrip.poi.wholeDay': 'Ολη η ημερα',
|
||||
'roadtrip.poi.midLeg': 'Στα μισα, {from} προς {to}',
|
||||
'roadtrip.track.title': 'Η ημέρα {number} ακολουθεί ένα ίχνος',
|
||||
'roadtrip.track.hint': 'Κάντε αυτή την ημέρα να ακολουθεί ένα εισαγόμενο ίχνος',
|
||||
'roadtrip.track.badge': 'Ίχνος',
|
||||
'roadtrip.track.untitled': 'Ίχνος χωρίς όνομα',
|
||||
'roadtrip.track.empty': 'Δεν υπάρχουν ίχνη σε αυτό το ταξίδι',
|
||||
'roadtrip.track.emptyHint': 'Εισαγάγετε ένα αρχείο GPX ή KML και τα ίχνη του θα εμφανιστούν εδώ.',
|
||||
'roadtrip.track.length': 'μήκος {distance}',
|
||||
'roadtrip.track.gap': '{distance} από αυτή την ημέρα',
|
||||
'roadtrip.track.onDay': 'κατά μήκος αυτής της ημέρας',
|
||||
'roadtrip.track.explain': 'Η διαδρομή τραβιέται πάνω στο ίχνος με μερικά ενδιάμεσα σημεία. Τα υπάρχοντα σημεία σε αυτά τα σκέλη αντικαθίστανται.',
|
||||
'roadtrip.track.action': 'Ακολούθησε αυτό το ίχνος',
|
||||
'roadtrip.track.working': 'Προσαρμογή διαδρομής στο ίχνος, γύρος {round}',
|
||||
'roadtrip.track.done': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Η διαδρομή μένει εντός {distance} από το ίχνος.',
|
||||
'roadtrip.track.capped': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Στο πιο πλατύ σημείο η διαδρομή απέχει {distance} από το ίχνος.',
|
||||
'roadtrip.track.already': 'Η διαδρομή ακολουθούσε ήδη αυτό το ίχνος.',
|
||||
'roadtrip.track.errorRoute': 'Η υπηρεσία δρομολόγησης δεν απάντησε.',
|
||||
'roadtrip.track.errorSave': 'Δεν ήταν δυνατή η αποθήκευση των ενδιάμεσων σημείων.',
|
||||
'roadtrip.track.clear': 'Αφαίρεση {count} ενδιάμεσων σημείων',
|
||||
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
|
||||
'roadtrip.poi.campsite': 'Κάμπινγκ',
|
||||
'roadtrip.poi.food': 'Φαγητό',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Ingyenes',
|
||||
'roadtrip.poi.wholeDay': 'A teljes nap',
|
||||
'roadtrip.poi.midLeg': 'Felúton, {from} és {to} kozott',
|
||||
'roadtrip.track.title': 'A(z) {number}. nap egy nyomvonalat követ',
|
||||
'roadtrip.track.hint': 'Kövesse ez a nap egy importált nyomvonalat',
|
||||
'roadtrip.track.badge': 'Nyomvonal',
|
||||
'roadtrip.track.untitled': 'Névtelen nyomvonal',
|
||||
'roadtrip.track.empty': 'Nincs nyomvonal ebben az utazásban',
|
||||
'roadtrip.track.emptyHint': 'Importálj egy GPX- vagy KML-fájlt, és a nyomvonalai itt jelennek meg.',
|
||||
'roadtrip.track.length': '{distance} hosszú',
|
||||
'roadtrip.track.gap': '{distance} távolságra ettől a naptól',
|
||||
'roadtrip.track.onDay': 'e nap mentén',
|
||||
'roadtrip.track.explain': 'Az útvonalat néhány köztes pont húzza rá a nyomvonalra. Az érintett szakaszok meglévő köztes pontjai lecserélődnek.',
|
||||
'roadtrip.track.action': 'Kövesse ezt a nyomvonalat',
|
||||
'roadtrip.track.working': 'Az útvonal igazítása a nyomvonalhoz, {round}. kör',
|
||||
'roadtrip.track.done': '{count} köztes pont elhelyezve. Az útvonal {distance} távolságon belül marad a nyomvonaltól.',
|
||||
'roadtrip.track.capped': '{count} köztes pont elhelyezve. A legtávolabbi ponton az útvonal {distance} távolságra van a nyomvonaltól.',
|
||||
'roadtrip.track.already': 'Az útvonal már ezt a nyomvonalat követte.',
|
||||
'roadtrip.track.errorRoute': 'Az útvonaltervező szolgáltatás nem válaszolt.',
|
||||
'roadtrip.track.errorSave': 'A köztes pontokat nem sikerült menteni.',
|
||||
'roadtrip.track.clear': '{count} köztes pont eltávolítása',
|
||||
'roadtrip.poi.rest': 'Pihenőhely',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Étkezés',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratis',
|
||||
'roadtrip.poi.wholeDay': 'Sepanjang hari',
|
||||
'roadtrip.poi.midLeg': 'Di tengah, {from} ke {to}',
|
||||
'roadtrip.track.title': 'Hari {number} mengikuti jalur',
|
||||
'roadtrip.track.hint': 'Buat hari ini mengikuti jalur yang diimpor',
|
||||
'roadtrip.track.badge': 'Jalur',
|
||||
'roadtrip.track.untitled': 'Jalur tanpa nama',
|
||||
'roadtrip.track.empty': 'Tidak ada jalur di perjalanan ini',
|
||||
'roadtrip.track.emptyHint': 'Impor berkas GPX atau KML dan jalurnya akan muncul di sini.',
|
||||
'roadtrip.track.length': 'panjang {distance}',
|
||||
'roadtrip.track.gap': '{distance} dari hari ini',
|
||||
'roadtrip.track.onDay': 'sepanjang hari ini',
|
||||
'roadtrip.track.explain': 'Perjalanan ditarik ke jalur dengan beberapa titik lintas. Titik lintas yang ada di etape tersebut akan diganti.',
|
||||
'roadtrip.track.action': 'Ikuti jalur ini',
|
||||
'roadtrip.track.working': 'Menyesuaikan perjalanan dengan jalur, putaran {round}',
|
||||
'roadtrip.track.done': '{count} titik lintas ditempatkan. Perjalanan tetap dalam {distance} dari jalur.',
|
||||
'roadtrip.track.capped': '{count} titik lintas ditempatkan. Di titik terlebar, perjalanan berjarak {distance} dari jalur.',
|
||||
'roadtrip.track.already': 'Perjalanan sudah mengikuti jalur ini.',
|
||||
'roadtrip.track.errorRoute': 'Layanan rute tidak menjawab.',
|
||||
'roadtrip.track.errorSave': 'Titik lintas tidak dapat disimpan.',
|
||||
'roadtrip.track.clear': 'Hapus {count} titik lintas',
|
||||
'roadtrip.poi.rest': 'Tempat istirahat',
|
||||
'roadtrip.poi.campsite': 'Perkemahan',
|
||||
'roadtrip.poi.food': 'Makanan',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratuito',
|
||||
'roadtrip.poi.wholeDay': 'Tutta la giornata',
|
||||
'roadtrip.poi.midLeg': 'A meta strada, {from} verso {to}',
|
||||
'roadtrip.track.title': 'Il giorno {number} segue una traccia',
|
||||
'roadtrip.track.hint': 'Fai seguire a questo giorno una traccia importata',
|
||||
'roadtrip.track.badge': 'Traccia',
|
||||
'roadtrip.track.untitled': 'Traccia senza nome',
|
||||
'roadtrip.track.empty': 'Nessuna traccia in questo viaggio',
|
||||
'roadtrip.track.emptyHint': 'Importa un file GPX o KML e le sue tracce compaiono qui.',
|
||||
'roadtrip.track.length': '{distance} di lunghezza',
|
||||
'roadtrip.track.gap': 'a {distance} da questo giorno',
|
||||
'roadtrip.track.onDay': 'lungo questo giorno',
|
||||
'roadtrip.track.explain': 'Il percorso viene portato sulla traccia con alcuni punti di passaggio. Quelli già presenti su quelle tratte vengono sostituiti.',
|
||||
'roadtrip.track.action': 'Segui questa traccia',
|
||||
'roadtrip.track.working': 'Adatto il percorso alla traccia, giro {round}',
|
||||
'roadtrip.track.done': '{count} punti di passaggio posati. Il percorso resta entro {distance} dalla traccia.',
|
||||
'roadtrip.track.capped': '{count} punti di passaggio posati. Nel punto peggiore il percorso resta a {distance} dalla traccia.',
|
||||
'roadtrip.track.already': 'Il percorso seguiva già questa traccia.',
|
||||
'roadtrip.track.errorRoute': 'Il servizio di calcolo percorso non ha risposto.',
|
||||
'roadtrip.track.errorSave': 'Non è stato possibile salvare i punti di passaggio.',
|
||||
'roadtrip.track.clear': 'Rimuovi {count} punti di passaggio',
|
||||
'roadtrip.poi.rest': 'Area di sosta',
|
||||
'roadtrip.poi.campsite': 'Campeggio',
|
||||
'roadtrip.poi.food': 'Ristoro',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': '無料',
|
||||
'roadtrip.poi.wholeDay': '一日全体',
|
||||
'roadtrip.poi.midLeg': '中間地点、{from}から{to}',
|
||||
'roadtrip.track.title': '{number}日目はトラックをたどる',
|
||||
'roadtrip.track.hint': 'この日を読み込んだトラックに沿わせる',
|
||||
'roadtrip.track.badge': 'トラック',
|
||||
'roadtrip.track.untitled': '名前のないトラック',
|
||||
'roadtrip.track.empty': 'この旅行にトラックはありません',
|
||||
'roadtrip.track.emptyHint': 'GPX または KML ファイルを読み込むと、そのトラックがここに並びます。',
|
||||
'roadtrip.track.length': '全長 {distance}',
|
||||
'roadtrip.track.gap': 'この日から {distance}',
|
||||
'roadtrip.track.onDay': 'この日に沿って',
|
||||
'roadtrip.track.explain': 'いくつかの経由地で走行をトラックに引き寄せます。その区間にある既存の経由地は置き換えられます。',
|
||||
'roadtrip.track.action': 'このトラックをたどる',
|
||||
'roadtrip.track.working': '走行をトラックに合わせています({round} 回目)',
|
||||
'roadtrip.track.done': '経由地を {count} か所置きました。走行はトラックから {distance} 以内に収まります。',
|
||||
'roadtrip.track.capped': '経由地を {count} か所置きました。最も離れる地点でトラックから {distance} です。',
|
||||
'roadtrip.track.already': '走行はすでにこのトラックに沿っていました。',
|
||||
'roadtrip.track.errorRoute': 'ルート検索サービスが応答しませんでした。',
|
||||
'roadtrip.track.errorSave': '経由地を保存できませんでした。',
|
||||
'roadtrip.track.clear': '経由地 {count} か所を削除',
|
||||
'roadtrip.poi.rest': 'サービスエリア',
|
||||
'roadtrip.poi.campsite': 'キャンプ場',
|
||||
'roadtrip.poi.food': '食事',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': '무료',
|
||||
'roadtrip.poi.wholeDay': '하루 전체',
|
||||
'roadtrip.poi.midLeg': '중간 지점, {from}에서 {to}',
|
||||
'roadtrip.track.title': '{number}일차가 트랙을 따라갑니다',
|
||||
'roadtrip.track.hint': '이 날의 이동을 가져온 트랙에 맞춥니다',
|
||||
'roadtrip.track.badge': '트랙',
|
||||
'roadtrip.track.untitled': '이름 없는 트랙',
|
||||
'roadtrip.track.empty': '이 여행에는 트랙이 없습니다',
|
||||
'roadtrip.track.emptyHint': 'GPX 또는 KML 파일을 가져오면 그 트랙이 여기에 표시됩니다.',
|
||||
'roadtrip.track.length': '길이 {distance}',
|
||||
'roadtrip.track.gap': '이 날에서 {distance}',
|
||||
'roadtrip.track.onDay': '이 날을 따라',
|
||||
'roadtrip.track.explain': '몇 개의 경유지로 주행을 트랙에 맞춥니다. 해당 구간의 기존 경유지는 대체됩니다.',
|
||||
'roadtrip.track.action': '이 트랙 따라가기',
|
||||
'roadtrip.track.working': '주행을 트랙에 맞추는 중, {round}회차',
|
||||
'roadtrip.track.done': '경유지 {count}개를 배치했습니다. 주행은 트랙에서 {distance} 이내를 유지합니다.',
|
||||
'roadtrip.track.capped': '경유지 {count}개를 배치했습니다. 가장 벌어지는 지점에서 트랙과 {distance} 떨어져 있습니다.',
|
||||
'roadtrip.track.already': '주행이 이미 이 트랙을 따르고 있었습니다.',
|
||||
'roadtrip.track.errorRoute': '경로 서비스가 응답하지 않았습니다.',
|
||||
'roadtrip.track.errorSave': '경유지를 저장하지 못했습니다.',
|
||||
'roadtrip.track.clear': '경유지 {count}개 삭제',
|
||||
'roadtrip.poi.rest': '휴게소',
|
||||
'roadtrip.poi.campsite': '캠핑장',
|
||||
'roadtrip.poi.food': '식사',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratis',
|
||||
'roadtrip.poi.wholeDay': 'De hele dag',
|
||||
'roadtrip.poi.midLeg': 'Halverwege, {from} naar {to}',
|
||||
'roadtrip.track.title': 'Dag {number} volgt een track',
|
||||
'roadtrip.track.hint': 'Deze dag een geïmporteerde track laten volgen',
|
||||
'roadtrip.track.badge': 'Track',
|
||||
'roadtrip.track.untitled': 'Naamloze track',
|
||||
'roadtrip.track.empty': 'Geen tracks in deze reis',
|
||||
'roadtrip.track.emptyHint': 'Importeer een GPX- of KML-bestand en de tracks verschijnen hier.',
|
||||
'roadtrip.track.length': '{distance} lang',
|
||||
'roadtrip.track.gap': '{distance} van deze dag',
|
||||
'roadtrip.track.onDay': 'langs deze dag',
|
||||
'roadtrip.track.explain': 'De rit wordt met een handvol tussenpunten op de track getrokken. Bestaande tussenpunten op die etappes worden vervangen.',
|
||||
'roadtrip.track.action': 'Deze track volgen',
|
||||
'roadtrip.track.working': 'Rit wordt op de track gepast, ronde {round}',
|
||||
'roadtrip.track.done': '{count} tussenpunten geplaatst. De rit blijft binnen {distance} van de track.',
|
||||
'roadtrip.track.capped': '{count} tussenpunten geplaatst. Op het verste punt blijft de rit {distance} van de track.',
|
||||
'roadtrip.track.already': 'De rit volgde deze track al.',
|
||||
'roadtrip.track.errorRoute': 'De routeservice antwoordde niet.',
|
||||
'roadtrip.track.errorSave': 'De tussenpunten konden niet worden opgeslagen.',
|
||||
'roadtrip.track.clear': '{count} tussenpunten verwijderen',
|
||||
'roadtrip.poi.rest': 'Rustplaats',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Eten',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Bezpłatnie',
|
||||
'roadtrip.poi.wholeDay': 'Caly dzien',
|
||||
'roadtrip.poi.midLeg': 'W polowie, {from} do {to}',
|
||||
'roadtrip.track.title': 'Dzień {number} podąża za śladem',
|
||||
'roadtrip.track.hint': 'Niech ten dzień podąża za zaimportowanym śladem',
|
||||
'roadtrip.track.badge': 'Ślad',
|
||||
'roadtrip.track.untitled': 'Ślad bez nazwy',
|
||||
'roadtrip.track.empty': 'Brak śladów w tej podróży',
|
||||
'roadtrip.track.emptyHint': 'Zaimportuj plik GPX lub KML, a jego ślady pojawią się tutaj.',
|
||||
'roadtrip.track.length': '{distance} długości',
|
||||
'roadtrip.track.gap': '{distance} od tego dnia',
|
||||
'roadtrip.track.onDay': 'wzdłuż tego dnia',
|
||||
'roadtrip.track.explain': 'Trasa zostaje naciągnięta na ślad kilkoma punktami pośrednimi. Istniejące punkty na tych odcinkach zostaną zastąpione.',
|
||||
'roadtrip.track.action': 'Podążaj za tym śladem',
|
||||
'roadtrip.track.working': 'Dopasowywanie trasy do śladu, runda {round}',
|
||||
'roadtrip.track.done': 'Ustawiono {count} punktów pośrednich. Trasa trzyma się śladu w promieniu {distance}.',
|
||||
'roadtrip.track.capped': 'Ustawiono {count} punktów pośrednich. W najszerszym miejscu trasa mija ślad o {distance}.',
|
||||
'roadtrip.track.already': 'Trasa już podążała za tym śladem.',
|
||||
'roadtrip.track.errorRoute': 'Usługa wyznaczania trasy nie odpowiedziała.',
|
||||
'roadtrip.track.errorSave': 'Nie udało się zapisać punktów pośrednich.',
|
||||
'roadtrip.track.clear': 'Usuń {count} punktów pośrednich',
|
||||
'roadtrip.poi.rest': 'Miejsce odpoczynku',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Jedzenie',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Бесплатно',
|
||||
'roadtrip.poi.wholeDay': 'Весь день',
|
||||
'roadtrip.poi.midLeg': 'На полпути, {from} до {to}',
|
||||
'roadtrip.track.title': 'День {number} идёт по треку',
|
||||
'roadtrip.track.hint': 'Провести этот день по импортированному треку',
|
||||
'roadtrip.track.badge': 'Трек',
|
||||
'roadtrip.track.untitled': 'Трек без названия',
|
||||
'roadtrip.track.empty': 'В этой поездке нет треков',
|
||||
'roadtrip.track.emptyHint': 'Импортируйте файл GPX или KML, и его треки появятся здесь.',
|
||||
'roadtrip.track.length': 'длина {distance}',
|
||||
'roadtrip.track.gap': 'в {distance} от этого дня',
|
||||
'roadtrip.track.onDay': 'вдоль этого дня',
|
||||
'roadtrip.track.explain': 'Маршрут притягивается к треку несколькими промежуточными точками. Существующие точки на этих участках заменяются.',
|
||||
'roadtrip.track.action': 'Идти по этому треку',
|
||||
'roadtrip.track.working': 'Подгоняем маршрут к треку, круг {round}',
|
||||
'roadtrip.track.done': 'Поставлено промежуточных точек: {count}. Маршрут держится в пределах {distance} от трека.',
|
||||
'roadtrip.track.capped': 'Поставлено промежуточных точек: {count}. В худшем месте маршрут отходит от трека на {distance}.',
|
||||
'roadtrip.track.already': 'Маршрут уже шёл по этому треку.',
|
||||
'roadtrip.track.errorRoute': 'Сервис маршрутов не ответил.',
|
||||
'roadtrip.track.errorSave': 'Не удалось сохранить промежуточные точки.',
|
||||
'roadtrip.track.clear': 'Убрать промежуточные точки: {count}',
|
||||
'roadtrip.poi.rest': 'Зона отдыха',
|
||||
'roadtrip.poi.campsite': 'Кемпинг',
|
||||
'roadtrip.poi.food': 'Еда',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Gratis',
|
||||
'roadtrip.poi.wholeDay': 'Hela dagen',
|
||||
'roadtrip.poi.midLeg': 'Halvvags, {from} till {to}',
|
||||
'roadtrip.track.title': 'Dag {number} följer ett spår',
|
||||
'roadtrip.track.hint': 'Låt den här dagen följa ett importerat spår',
|
||||
'roadtrip.track.badge': 'Spår',
|
||||
'roadtrip.track.untitled': 'Namnlöst spår',
|
||||
'roadtrip.track.empty': 'Inga spår i den här resan',
|
||||
'roadtrip.track.emptyHint': 'Importera en GPX- eller KML-fil så dyker spåren upp här.',
|
||||
'roadtrip.track.length': '{distance} lång',
|
||||
'roadtrip.track.gap': '{distance} från den här dagen',
|
||||
'roadtrip.track.onDay': 'längs den här dagen',
|
||||
'roadtrip.track.explain': 'Körningen dras in på spåret med ett fåtal mellanpunkter. Befintliga mellanpunkter på de sträckorna ersätts.',
|
||||
'roadtrip.track.action': 'Följ det här spåret',
|
||||
'roadtrip.track.working': 'Anpassar körningen till spåret, runda {round}',
|
||||
'roadtrip.track.done': '{count} mellanpunkter satta. Körningen håller sig inom {distance} från spåret.',
|
||||
'roadtrip.track.capped': '{count} mellanpunkter satta. Som mest ligger körningen {distance} från spåret.',
|
||||
'roadtrip.track.already': 'Körningen följde redan det här spåret.',
|
||||
'roadtrip.track.errorRoute': 'Ruttjänsten svarade inte.',
|
||||
'roadtrip.track.errorSave': 'Mellanpunkterna kunde inte sparas.',
|
||||
'roadtrip.track.clear': 'Ta bort {count} mellanpunkter',
|
||||
'roadtrip.poi.rest': 'Rastplats',
|
||||
'roadtrip.poi.campsite': 'Campingplats',
|
||||
'roadtrip.poi.food': 'Mat',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Ucretsiz',
|
||||
'roadtrip.poi.wholeDay': 'Tum gun',
|
||||
'roadtrip.poi.midLeg': 'Yolun yarisi, {from} - {to}',
|
||||
'roadtrip.track.title': '{number}. gün bir izi takip eder',
|
||||
'roadtrip.track.hint': 'Bu günü içe aktarılmış bir izi takip ettir',
|
||||
'roadtrip.track.badge': 'İz',
|
||||
'roadtrip.track.untitled': 'Adsız iz',
|
||||
'roadtrip.track.empty': 'Bu gezide iz yok',
|
||||
'roadtrip.track.emptyHint': 'Bir GPX veya KML dosyası içe aktarın, izleri burada görünür.',
|
||||
'roadtrip.track.length': '{distance} uzunluğunda',
|
||||
'roadtrip.track.gap': 'bu günden {distance} uzakta',
|
||||
'roadtrip.track.onDay': 'bu gün boyunca',
|
||||
'roadtrip.track.explain': 'Sürüş, birkaç ara noktayla izin üzerine çekilir. Bu etaplardaki mevcut ara noktalar değiştirilir.',
|
||||
'roadtrip.track.action': 'Bu izi takip et',
|
||||
'roadtrip.track.working': 'Sürüş ize uyarlanıyor, tur {round}',
|
||||
'roadtrip.track.done': '{count} ara nokta yerleştirildi. Sürüş, izin {distance} yakınında kalıyor.',
|
||||
'roadtrip.track.capped': '{count} ara nokta yerleştirildi. En geniş yerde sürüş, izden {distance} uzakta kalıyor.',
|
||||
'roadtrip.track.already': 'Sürüş bu izi zaten takip ediyordu.',
|
||||
'roadtrip.track.errorRoute': 'Rota servisi yanıt vermedi.',
|
||||
'roadtrip.track.errorSave': 'Ara noktalar kaydedilemedi.',
|
||||
'roadtrip.track.clear': '{count} ara noktayı kaldır',
|
||||
'roadtrip.poi.rest': 'Dinlenme alanı',
|
||||
'roadtrip.poi.campsite': 'Kamp alanı',
|
||||
'roadtrip.poi.food': 'Yemek',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Безкоштовно',
|
||||
'roadtrip.poi.wholeDay': 'Весь день',
|
||||
'roadtrip.poi.midLeg': 'На пiвдорозi, {from} до {to}',
|
||||
'roadtrip.track.title': 'День {number} іде за треком',
|
||||
'roadtrip.track.hint': 'Провести цей день за імпортованим треком',
|
||||
'roadtrip.track.badge': 'Трек',
|
||||
'roadtrip.track.untitled': 'Трек без назви',
|
||||
'roadtrip.track.empty': 'У цій подорожі немає треків',
|
||||
'roadtrip.track.emptyHint': 'Імпортуйте файл GPX або KML, і його треки з’являться тут.',
|
||||
'roadtrip.track.length': 'довжина {distance}',
|
||||
'roadtrip.track.gap': 'за {distance} від цього дня',
|
||||
'roadtrip.track.onDay': 'уздовж цього дня',
|
||||
'roadtrip.track.explain': 'Маршрут притягується до треку кількома проміжними точками. Наявні точки на цих ділянках буде замінено.',
|
||||
'roadtrip.track.action': 'Іти за цим треком',
|
||||
'roadtrip.track.working': 'Підганяємо маршрут до треку, коло {round}',
|
||||
'roadtrip.track.done': 'Поставлено проміжних точок: {count}. Маршрут тримається в межах {distance} від треку.',
|
||||
'roadtrip.track.capped': 'Поставлено проміжних точок: {count}. У найширшому місці маршрут відходить від треку на {distance}.',
|
||||
'roadtrip.track.already': 'Маршрут уже йшов за цим треком.',
|
||||
'roadtrip.track.errorRoute': 'Сервіс маршрутів не відповів.',
|
||||
'roadtrip.track.errorSave': 'Не вдалося зберегти проміжні точки.',
|
||||
'roadtrip.track.clear': 'Прибрати проміжні точки: {count}',
|
||||
'roadtrip.poi.rest': 'Зона відпочинку',
|
||||
'roadtrip.poi.campsite': 'Кемпінг',
|
||||
'roadtrip.poi.food': 'Їжа',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': 'Miễn phí',
|
||||
'roadtrip.poi.wholeDay': 'Cả ngày',
|
||||
'roadtrip.poi.midLeg': 'Giữa đường, {from} đến {to}',
|
||||
'roadtrip.track.title': 'Ngày {number} đi theo một tuyến',
|
||||
'roadtrip.track.hint': 'Cho ngày này đi theo tuyến đã nhập',
|
||||
'roadtrip.track.badge': 'Tuyến',
|
||||
'roadtrip.track.untitled': 'Tuyến chưa đặt tên',
|
||||
'roadtrip.track.empty': 'Chuyến đi này chưa có tuyến nào',
|
||||
'roadtrip.track.emptyHint': 'Nhập tệp GPX hoặc KML và các tuyến của nó sẽ xuất hiện ở đây.',
|
||||
'roadtrip.track.length': 'dài {distance}',
|
||||
'roadtrip.track.gap': 'cách ngày này {distance}',
|
||||
'roadtrip.track.onDay': 'dọc theo ngày này',
|
||||
'roadtrip.track.explain': 'Hành trình được kéo về tuyến bằng một vài điểm trung gian. Các điểm hiện có trên những chặng đó sẽ bị thay thế.',
|
||||
'roadtrip.track.action': 'Đi theo tuyến này',
|
||||
'roadtrip.track.working': 'Đang khớp hành trình với tuyến, vòng {round}',
|
||||
'roadtrip.track.done': 'Đã đặt {count} điểm trung gian. Hành trình bám tuyến trong phạm vi {distance}.',
|
||||
'roadtrip.track.capped': 'Đã đặt {count} điểm trung gian. Ở chỗ lệch nhất, hành trình cách tuyến {distance}.',
|
||||
'roadtrip.track.already': 'Hành trình vốn đã đi theo tuyến này.',
|
||||
'roadtrip.track.errorRoute': 'Dịch vụ chỉ đường không phản hồi.',
|
||||
'roadtrip.track.errorSave': 'Không thể lưu các điểm trung gian.',
|
||||
'roadtrip.track.clear': 'Bỏ {count} điểm trung gian',
|
||||
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
|
||||
'roadtrip.poi.campsite': 'Khu cắm trại',
|
||||
'roadtrip.poi.food': 'Đồ ăn',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': '免費',
|
||||
'roadtrip.poi.wholeDay': '全天',
|
||||
'roadtrip.poi.midLeg': '中途,{from} 至 {to}',
|
||||
'roadtrip.track.title': '第 {number} 天沿軌跡行駛',
|
||||
'roadtrip.track.hint': '讓這一天沿匯入的軌跡行駛',
|
||||
'roadtrip.track.badge': '軌跡',
|
||||
'roadtrip.track.untitled': '未命名軌跡',
|
||||
'roadtrip.track.empty': '此行程沒有軌跡',
|
||||
'roadtrip.track.emptyHint': '匯入 GPX 或 KML 檔案,其軌跡會出現在這裡。',
|
||||
'roadtrip.track.length': '全長 {distance}',
|
||||
'roadtrip.track.gap': '距這一天 {distance}',
|
||||
'roadtrip.track.onDay': '沿著這一天',
|
||||
'roadtrip.track.explain': '行程會透過幾個途經點被拉到軌跡上。這些路段上既有的途經點會被取代。',
|
||||
'roadtrip.track.action': '沿此軌跡行駛',
|
||||
'roadtrip.track.working': '正在將行程貼合軌跡,第 {round} 輪',
|
||||
'roadtrip.track.done': '已放置 {count} 個途經點。行程與軌跡的偏差不超過 {distance}。',
|
||||
'roadtrip.track.capped': '已放置 {count} 個途經點。最寬處行程仍偏離軌跡 {distance}。',
|
||||
'roadtrip.track.already': '行程本來就沿著這條軌跡。',
|
||||
'roadtrip.track.errorRoute': '路線服務沒有回應。',
|
||||
'roadtrip.track.errorSave': '無法儲存途經點。',
|
||||
'roadtrip.track.clear': '移除 {count} 個途經點',
|
||||
'roadtrip.poi.rest': '休息站',
|
||||
'roadtrip.poi.campsite': '露營地',
|
||||
'roadtrip.poi.food': '餐飲',
|
||||
|
||||
@@ -46,6 +46,24 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.free': '免费',
|
||||
'roadtrip.poi.wholeDay': '全天',
|
||||
'roadtrip.poi.midLeg': '中途,{from} 至 {to}',
|
||||
'roadtrip.track.title': '第 {number} 天沿轨迹行驶',
|
||||
'roadtrip.track.hint': '让这一天沿导入的轨迹行驶',
|
||||
'roadtrip.track.badge': '轨迹',
|
||||
'roadtrip.track.untitled': '未命名轨迹',
|
||||
'roadtrip.track.empty': '此行程没有轨迹',
|
||||
'roadtrip.track.emptyHint': '导入 GPX 或 KML 文件,其轨迹会出现在这里。',
|
||||
'roadtrip.track.length': '全长 {distance}',
|
||||
'roadtrip.track.gap': '距这一天 {distance}',
|
||||
'roadtrip.track.onDay': '沿着这一天',
|
||||
'roadtrip.track.explain': '行程会通过几个途经点被拉到轨迹上。这些路段上已有的途经点会被替换。',
|
||||
'roadtrip.track.action': '沿此轨迹行驶',
|
||||
'roadtrip.track.working': '正在将行程贴合轨迹,第 {round} 轮',
|
||||
'roadtrip.track.done': '已放置 {count} 个途经点。行程与轨迹的偏差不超过 {distance}。',
|
||||
'roadtrip.track.capped': '已放置 {count} 个途经点。最宽处行程仍偏离轨迹 {distance}。',
|
||||
'roadtrip.track.already': '行程本来就沿着这条轨迹。',
|
||||
'roadtrip.track.errorRoute': '路线服务没有响应。',
|
||||
'roadtrip.track.errorSave': '无法保存途经点。',
|
||||
'roadtrip.track.clear': '移除 {count} 个途经点',
|
||||
'roadtrip.poi.rest': '服务区',
|
||||
'roadtrip.poi.campsite': '露营地',
|
||||
'roadtrip.poi.food': '餐饮',
|
||||
|
||||
Reference in New Issue
Block a user