From 761ec4e66fcf17e23f31b6e84ca404bace1f19a1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 5 Sep 2026 00:22:44 +0200 Subject: [PATCH] feat(roadtrip): let a day drive along an imported track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Roadtrip/RoadtripSidebar.test.tsx | 28 ++ .../components/Roadtrip/RoadtripSidebar.tsx | 43 ++- .../Roadtrip/RoadtripTrackModal.test.tsx | 124 +++++++ .../Roadtrip/RoadtripTrackModal.tsx | 185 ++++++++++ .../components/Roadtrip/followTrack.test.ts | 238 +++++++++++++ client/src/components/Roadtrip/followTrack.ts | 318 ++++++++++++++++++ .../Roadtrip/useFollowTrack.test.ts | 209 ++++++++++++ .../src/components/Roadtrip/useFollowTrack.ts | 219 ++++++++++++ client/src/pages/TripPlannerPage.tsx | 14 + .../src/pages/TripPlannerPage.wiring.test.tsx | 2 + .../pages/tripPlanner/useTripPlanner.test.tsx | 4 + .../src/pages/tripPlanner/useTripPlanner.ts | 12 + shared/src/i18n/ar/roadtrip.ts | 18 + shared/src/i18n/br/roadtrip.ts | 18 + shared/src/i18n/ca/roadtrip.ts | 18 + shared/src/i18n/cs/roadtrip.ts | 18 + shared/src/i18n/de/roadtrip.ts | 18 + shared/src/i18n/en/roadtrip.ts | 18 + shared/src/i18n/es/roadtrip.ts | 18 + shared/src/i18n/fr/roadtrip.ts | 18 + shared/src/i18n/gr/roadtrip.ts | 18 + shared/src/i18n/hu/roadtrip.ts | 18 + shared/src/i18n/id/roadtrip.ts | 18 + shared/src/i18n/it/roadtrip.ts | 18 + shared/src/i18n/ja/roadtrip.ts | 18 + shared/src/i18n/ko/roadtrip.ts | 18 + shared/src/i18n/nl/roadtrip.ts | 18 + shared/src/i18n/pl/roadtrip.ts | 18 + shared/src/i18n/ru/roadtrip.ts | 18 + shared/src/i18n/sv/roadtrip.ts | 18 + shared/src/i18n/tr/roadtrip.ts | 18 + shared/src/i18n/uk/roadtrip.ts | 18 + shared/src/i18n/vi/roadtrip.ts | 18 + shared/src/i18n/zh-TW/roadtrip.ts | 18 + shared/src/i18n/zh/roadtrip.ts | 18 + 35 files changed, 1807 insertions(+), 3 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripTrackModal.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripTrackModal.tsx create mode 100644 client/src/components/Roadtrip/followTrack.test.ts create mode 100644 client/src/components/Roadtrip/followTrack.ts create mode 100644 client/src/components/Roadtrip/useFollowTrack.test.ts create mode 100644 client/src/components/Roadtrip/useFollowTrack.ts diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 060b735cc..f67d6c654 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -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( + , + ) + + 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( + + + , + ) + 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() + expect(screen.queryByRole('button', { name: 'Track' })).toBeNull() + }) }) describe('RoadtripModeSwitch', () => { diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index e3c2cc51e..ed2932bc5 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -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 + /** + * 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 } const MODE_ICON: Record = { @@ -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 {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })} + {/* 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 ? ( + + + + ) : null} {/* 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 => ( diff --git a/client/src/components/Roadtrip/RoadtripTrackModal.test.tsx b/client/src/components/Roadtrip/RoadtripTrackModal.test.tsx new file mode 100644 index 000000000..6000fef3f --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripTrackModal.test.tsx @@ -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({ui}) + +const track = (over: Partial & { 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 { + 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() + expect(container.firstChild).toBeNull() + }) + + it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => { + wrap() + 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() + 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() + 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() + + 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() + 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() + expect(screen.getByRole('status').textContent).toMatch(/round 4/) + }) + + it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => { + wrap() + 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() + // 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() + expect(screen.getByRole('status').textContent).toMatch(/already followed/) + }) + + it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => { + wrap() + 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() + const drop = screen.getByRole('button', { name: /Drop 5 via points/ }) + fireEvent.click(drop) + expect(state.clear).toHaveBeenCalled() + + rerender() + expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull() + }) + + it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => { + wrap() + 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) + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripTrackModal.tsx b/client/src/components/Roadtrip/RoadtripTrackModal.tsx new file mode 100644 index 000000000..cdc7ad736 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripTrackModal.tsx @@ -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 ( +
  • + +
  • + ) +} + +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(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 ( + + + {t('roadtrip.track.title', { number: dayNumber })} + + } + > +
    + {tracks.length === 0 ? ( +
    + +

    {t('roadtrip.track.emptyHint')}

    +
    + ) : ( +
      + {tracks.map(track => ( + setPicked(track.id === picked ? null : track.id)} + /> + ))} +
    + )} + + {/* 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 ? ( +

    + {t('roadtrip.track.explain')} +

    + ) : null} + + {busy ? ( +

    + + {t('roadtrip.track.working', { round })} +

    + ) : null} + + {error ? ( +

    + + {t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')} +

    + ) : null} + + {outcome && !busy && !error ? ( +

    + + {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), + })} +

    + ) : null} + +
    + {/* 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 ? ( + + ) : null} + +
    +
    +
    + ) +} diff --git a/client/src/components/Roadtrip/followTrack.test.ts b/client/src/components/Roadtrip/followTrack.test.ts new file mode 100644 index 000000000..26146f2bc --- /dev/null +++ b/client/src/components/Roadtrip/followTrack.test.ts @@ -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; 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) + }) +}) diff --git a/client/src/components/Roadtrip/followTrack.ts b/client/src/components/Roadtrip/followTrack.ts new file mode 100644 index 000000000..a35619c3d --- /dev/null +++ b/client/src/components/Roadtrip/followTrack.ts @@ -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 + 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 { + 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, + } +} diff --git a/client/src/components/Roadtrip/useFollowTrack.test.ts b/client/src/components/Roadtrip/useFollowTrack.test.ts new file mode 100644 index 000000000..e5032110e --- /dev/null +++ b/client/src/components/Roadtrip/useFollowTrack.test.ts @@ -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 { + 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 { + 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).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 | 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() + }) +}) diff --git a/client/src/components/Roadtrip/useFollowTrack.ts b/client/src/components/Roadtrip/useFollowTrack.ts new file mode 100644 index 000000000..820ebed6e --- /dev/null +++ b/client/src/components/Roadtrip/useFollowTrack.ts @@ -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 + /** Drops every via of the open day, so it drives the way the router would. */ + clear: () => Promise + /** 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 => new Promise(resolve => { setTimeout(resolve, ms) }) + +export function useFollowTrack( + tripId: number | string | null, + places: TrackPlace[], + routes: RoadtripRoutes, + vias: RoadtripVias, +): FollowTrack { + const [dayId, setDayId] = useState(null) + const [busy, setBusy] = useState(false) + const [round, setRound] = useState(0) + const [error, setError] = useState<'route' | 'save' | null>(null) + const [outcome, setOutcome] = useState(null) + const abort = useRef(null) + + const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId]) + const stops = useMemo(() => (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(() => { + 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 } +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index a254ff1f5..1fa89f78e 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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} /> ) : ( @@ -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 && ( + + d.dayId === followTrack.dayId)?.dayNumber ?? 0} + /> + + )} {stayDraft && ( setStayDraft(null)} onSave={setRoadtripStay} /> diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index d081f8ccc..bc758ffff 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -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), diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index dc859bb70..843ae9e38 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -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() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 0091f53ed..94b3b0287 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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 = {} + 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, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 139cb02ef..2d94da042 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -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': 'طعام', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 52369017d..ebba7d5c9 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index f0312b826..dbaea2b7b 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 119cd4cdb..95f403961 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index fd45bd5a3..d2f8cff55 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 7f75f3f50..132825330 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 68b928356..e7f3fdbca 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 01f8ed8f7..48fe8cc98 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index afc4d5a6f..957514b18 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -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': 'Φαγητό', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index b6ceff83b..511a6cec0 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 34c63c856..c6f540086 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index b11deae75..c7de8384b 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 3fba77cd0..d48945003 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -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': '食事', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 147f494e6..f5adcba79 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -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': '식사', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 9f315b5df..db0dfa87d 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index a8ef76395..cf73fc2b7 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index f31ad401f..6890feae5 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -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': 'Еда', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 1c55dfb4a..1a4330519 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 9a53bbd1b..9875c3e57 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index a09278079..50350dc67 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -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': 'Їжа', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index d4589115c..1873d24c0 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 42c76541b..cff32ab16 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -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': '餐飲', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f57d3aa28..d94f922b5 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -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': '餐饮',