mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(roadtrip): find fuel, rest areas and sights along the drive
The road trip mode could show the route but not what sits beside it, which is half of what planning a drive is about (#1797). The right column becomes a corridor search: pick what you need, pick how far off the route you will go for it, and the day's drive is searched box by box. The boxes come from the route itself rather than the viewport, so the answer is "on the way", not "on screen"; each hit reports how far off the route it lies and how far into the drive it comes, and adding one opens the normal place form so it lands on the day like any other stop. Overpass is asked in plain bounding boxes rather than with a polyline filter, because that is what the existing /api/maps/pois route accepts and what the mirrors can answer; the geometry work is pure and tested. Long routes stop at ten boxes and say so instead of quietly searching half the drive. Fuel, charging, rest areas and campsites join the server's POI categories, so the explore pill and the MCP search_pois tool gain them too. A car booking's stops now also reach the PDF, which printed pick-up and return with everything in between missing.
This commit is contained in:
@@ -353,6 +353,13 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
// A rental with stops is a drive, and the printout is what people take
|
||||
// into the car (#1797). Without this the route reads as pick-up and return
|
||||
// with everything in between missing.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.name)
|
||||
subtitle = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Search, Plus, RotateCw, AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useCorridorPois, type CorridorPoi } from './useCorridorPois'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
interface RoadtripCorridorPanelProps {
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, optionally on a day. */
|
||||
onAddPoi?: (
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
|
||||
dayId?: number | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What a drive needs, rather than what a city visit does. Deliberately a short list:
|
||||
* every extra category is another round of requests against a shared Overpass mirror.
|
||||
*/
|
||||
const CORRIDOR_CATEGORIES: { key: string; labelKey: string; Icon: LucideIcon }[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent },
|
||||
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils },
|
||||
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera },
|
||||
]
|
||||
|
||||
const CATEGORY_ICON: Record<string, LucideIcon> = Object.fromEntries(
|
||||
CORRIDOR_CATEGORIES.map(c => [c.key, c.Icon]),
|
||||
)
|
||||
|
||||
const WIDTHS_KM = [2, 5, 10]
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const Icon = CATEGORY_ICON[poi.category] ?? Camera
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-surface-hover">
|
||||
<Icon size={15} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body text-content truncate">{poi.name}</div>
|
||||
<div className="text-caption text-content-faint tabular-nums">
|
||||
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
<span className="mx-1">·</span>
|
||||
{/* A candidate beside the first stop projects to the very start of the line;
|
||||
printing that as "after 0 m" reads like a bug rather than "right here". */}
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
</div>
|
||||
</div>
|
||||
{onAdd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="shrink-0 flex items-center gap-1 rounded-lg px-2 py-1 text-caption text-content-secondary hover:text-content hover:bg-surface-selected"
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t('roadtrip.poi.add')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "What is on the way" — the right column while road trip mode is on.
|
||||
*
|
||||
* Searching is a button, not a side effect of panning: one run is several Overpass
|
||||
* requests and the mirrors are shared infrastructure. The scope is one day's drive by
|
||||
* default, which keeps a run to a handful of boxes and makes "add this" unambiguous —
|
||||
* the stop lands on the day whose route it was found along.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [dayId, setDayId] = useState<string>(() => String(routes.days[0]?.dayId ?? ''))
|
||||
const [active, setActive] = useState<string[]>(['fuel'])
|
||||
const [widthKm, setWidthKm] = useState<number>(5)
|
||||
|
||||
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
|
||||
const line = useMemo<LatLng[]>(() => {
|
||||
if (!day) return []
|
||||
// The stops themselves are enough of a spine: the corridor is kilometres wide, and
|
||||
// the routed geometry per leg is not kept once its numbers are read.
|
||||
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day])
|
||||
|
||||
const search = useCorridorPois(line, active, widthKm)
|
||||
|
||||
const dayOptions = routes.days.map(d => ({
|
||||
value: String(d.dayId),
|
||||
label: t('roadtrip.day', { number: d.dayNumber }),
|
||||
}))
|
||||
|
||||
const toggle = (key: string): void =>
|
||||
setActive(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-3 pt-3 pb-2 flex flex-col gap-2.5">
|
||||
<h2 className="text-subtitle font-semibold text-content">{t('roadtrip.poi.title')}</h2>
|
||||
|
||||
{dayOptions.length > 1 ? (
|
||||
<CustomSelect value={dayId} onChange={value => setDayId(String(value))} options={dayOptions} size="sm" />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CORRIDOR_CATEGORIES.map(({ key, labelKey, Icon }) => {
|
||||
const on = active.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => toggle(key)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
|
||||
on
|
||||
? 'bg-accent text-accent-text border-transparent'
|
||||
: 'bg-surface-card text-content-secondary border-edge hover:border-edge-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption text-content-faint">{t('roadtrip.poi.within')}</span>
|
||||
<div className="flex gap-1 p-0.5 rounded-lg bg-surface-tertiary border border-edge-faint">
|
||||
{WIDTHS_KM.map(km => (
|
||||
<button
|
||||
key={km}
|
||||
type="button"
|
||||
aria-pressed={widthKm === km}
|
||||
onClick={() => setWidthKm(km)}
|
||||
className={`rounded-md px-2 py-0.5 text-caption tabular-nums transition-colors ${
|
||||
widthKm === km ? 'bg-surface-card text-content' : 'text-content-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{formatDistance(km, distanceUnit)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={search.loading || active.length === 0 || line.length < 2}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-inverse text-inverse-text px-3 py-2 text-body font-medium disabled:opacity-50"
|
||||
>
|
||||
{search.loading ? <RotateCw size={14} className="animate-spin" aria-hidden /> : <Search size={14} aria-hidden />}
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
|
||||
: t('roadtrip.poi.search')}
|
||||
</button>
|
||||
|
||||
{search.capped ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.poi.capped')}
|
||||
</p>
|
||||
) : null}
|
||||
{search.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-danger">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.poi.failed')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-1.5 pb-3">
|
||||
{search.results.length === 0 && !search.loading ? (
|
||||
<p className="px-3 py-6 text-caption text-content-muted text-center">{t('roadtrip.poi.empty')}</p>
|
||||
) : (
|
||||
search.results.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
onAdd={onAddPoi ? () => onAddPoi(poi, day?.dayId ?? null) : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
haversineKm,
|
||||
projectOntoRoute,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor'
|
||||
|
||||
const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }
|
||||
const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }
|
||||
const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('measures a known distance', () => {
|
||||
// Berlin → Dresden is about 165 km as the crow flies.
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160)
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170)
|
||||
})
|
||||
|
||||
it('is zero for the same point', () => {
|
||||
expect(haversineKm(BERLIN, BERLIN)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('distanceToSegmentKm', () => {
|
||||
const a: LatLng = { lat: 52, lng: 13 }
|
||||
const b: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
it('measures perpendicular distance to the line', () => {
|
||||
const p: LatLng = { lat: 52.09, lng: 13.5 }
|
||||
// 0.09° of latitude is about 10 km, whatever the longitude does.
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9)
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11)
|
||||
})
|
||||
|
||||
it('measures to the end, not to the infinite line, for a point beyond it', () => {
|
||||
const beyond: LatLng = { lat: 52, lng: 16 }
|
||||
const d = distanceToSegmentKm(beyond, a, b)
|
||||
// Two degrees of longitude past the end at 52° north — roughly 137 km.
|
||||
expect(d).toBeGreaterThan(120)
|
||||
expect(d).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('falls back to point distance for a zero-length segment', () => {
|
||||
expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectOntoRoute', () => {
|
||||
const line = [BERLIN, DRESDEN, PRAGUE]
|
||||
|
||||
it('reports how far off the route a point is', () => {
|
||||
const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line)
|
||||
expect(onRoute!.offRouteKm).toBeLessThan(15)
|
||||
const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line)
|
||||
expect(wayOff!.offRouteKm).toBeGreaterThan(150)
|
||||
})
|
||||
|
||||
it('orders points by how far into the drive they come', () => {
|
||||
const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!
|
||||
const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!
|
||||
expect(early.alongKm).toBeLessThan(late.alongKm)
|
||||
})
|
||||
|
||||
it('tells two points on the same long straight apart', () => {
|
||||
// The petrol stations along one motorway segment must not all report the same
|
||||
// position in the drive, which is what taking the segment midpoint produced.
|
||||
const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }]
|
||||
const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!
|
||||
const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!
|
||||
expect(late.alongKm - early.alongKm).toBeGreaterThan(80)
|
||||
})
|
||||
|
||||
it('returns nothing for a line that is not one', () => {
|
||||
expect(projectOntoRoute(BERLIN, [])).toBeNull()
|
||||
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corridorTiles', () => {
|
||||
it('covers a short route with a single box', () => {
|
||||
const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5)
|
||||
expect(tiles).toHaveLength(1)
|
||||
expect(tiles[0].south).toBeLessThan(52.4)
|
||||
expect(tiles[0].north).toBeGreaterThan(52.52)
|
||||
})
|
||||
|
||||
it('splits a long route into boxes the server will not clamp', () => {
|
||||
const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10)
|
||||
expect(tiles.length).toBeGreaterThan(1)
|
||||
for (const t of tiles) {
|
||||
expect(t.north - t.south).toBeLessThanOrEqual(0.45)
|
||||
expect(t.east - t.west).toBeLessThanOrEqual(0.45)
|
||||
}
|
||||
})
|
||||
|
||||
it('pads longitude more the further north it goes', () => {
|
||||
const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]
|
||||
const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]
|
||||
expect(north.east - north.west).toBeGreaterThan(south.east - south.west)
|
||||
})
|
||||
|
||||
it('has nothing to cover for an empty route', () => {
|
||||
expect(corridorTiles([], 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('simplifyLine', () => {
|
||||
it('keeps the ends and drops what sits on the line between them', () => {
|
||||
const straight = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]])
|
||||
})
|
||||
|
||||
it('keeps a point that actually bends the route', () => {
|
||||
const bent = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.5, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(bent, 1)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('leaves a two-point line alone', () => {
|
||||
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Corridor geometry — "what lies along this drive", without asking Overpass to think
|
||||
* about a polyline.
|
||||
*
|
||||
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
|
||||
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
|
||||
* for plain bounding boxes that cover the route, then measure each candidate's
|
||||
* perpendicular distance to the line and throw away what is too far. The boxes are what
|
||||
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
|
||||
*
|
||||
* Everything here is pure: no React, no network, no store.
|
||||
*/
|
||||
|
||||
export interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Bbox {
|
||||
south: number
|
||||
west: number
|
||||
north: number
|
||||
east: number
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_KM = 6371
|
||||
const KM_PER_DEG_LAT = 111.32
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(a: LatLng, b: LatLng): number {
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const lat1 = toRad(a.lat)
|
||||
const lat2 = toRad(b.lat)
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from a point to a line segment, in kilometres.
|
||||
*
|
||||
* Works in a local equirectangular projection around the segment: longitudes shrink by
|
||||
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
|
||||
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
|
||||
* below the width the user picked.
|
||||
*/
|
||||
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
|
||||
return projectOnSegment(p, a, b).distanceKm
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
|
||||
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
|
||||
* the drive — taking the segment's midpoint instead makes every petrol station on a long
|
||||
* straight report the same position.
|
||||
*/
|
||||
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
|
||||
const latScale = KM_PER_DEG_LAT
|
||||
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
|
||||
const ax = a.lng * lngScale
|
||||
const ay = a.lat * latScale
|
||||
const bx = b.lng * lngScale
|
||||
const by = b.lat * latScale
|
||||
const px = p.lng * lngScale
|
||||
const py = p.lat * latScale
|
||||
const dx = bx - ax
|
||||
const dy = by - ay
|
||||
const lenSq = dx * dx + dy * dy
|
||||
if (lenSq === 0) return { distanceKm: haversineKm(p, a), t: 0 }
|
||||
// Clamped so a point beside the segment's end measures to the end, not to the
|
||||
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
|
||||
const cx = ax + t * dx
|
||||
const cy = ay + t * dy
|
||||
return { distanceKm: Math.hypot(px - cx, py - cy), t }
|
||||
}
|
||||
|
||||
export interface CorridorHit {
|
||||
/** Kilometres from the route line at its closest point. */
|
||||
offRouteKm: number
|
||||
/** Kilometres travelled along the route before reaching that closest point. */
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a point sits relative to the route: how far off it is, and how far into the
|
||||
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
|
||||
* instead of sorting by name.
|
||||
*/
|
||||
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
|
||||
if (line.length < 2) return null
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let bestAlong = 0
|
||||
let travelled = 0
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const a = line[i]
|
||||
const b = line[i + 1]
|
||||
const segment = haversineKm(a, b)
|
||||
const { distanceKm: d, t } = projectOnSegment(p, a, b)
|
||||
if (d < best) {
|
||||
best = d
|
||||
bestAlong = travelled + t * segment
|
||||
}
|
||||
travelled += segment
|
||||
}
|
||||
return { offRouteKm: best, alongKm: bestAlong }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
|
||||
*
|
||||
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
|
||||
* asked to scan a continent, which means a long drive has to be cut into boxes here
|
||||
* rather than sent as one. Boxes are grown along the route until one would exceed the
|
||||
* span, so a straight motorway needs far fewer requests than a mountain road.
|
||||
*/
|
||||
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
|
||||
if (line.length === 0) return []
|
||||
const padLat = widthKm / KM_PER_DEG_LAT
|
||||
const tiles: Bbox[] = []
|
||||
let current: Bbox | null = null
|
||||
|
||||
const padded = (b: Bbox): Bbox => {
|
||||
// Longitude degrees shrink towards the poles, so the padding has to grow with
|
||||
// latitude or the corridor is narrower than asked for in the north.
|
||||
const midLat = (b.north + b.south) / 2
|
||||
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
|
||||
return {
|
||||
south: b.south - padLat,
|
||||
north: b.north + padLat,
|
||||
west: b.west - padLng,
|
||||
east: b.east + padLng,
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of line) {
|
||||
if (!current) {
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
continue
|
||||
}
|
||||
const next: Bbox = {
|
||||
south: Math.min(current.south, p.lat),
|
||||
north: Math.max(current.north, p.lat),
|
||||
west: Math.min(current.west, p.lng),
|
||||
east: Math.max(current.east, p.lng),
|
||||
}
|
||||
const grown = padded(next)
|
||||
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
||||
tiles.push(padded(current))
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
}
|
||||
if (current) tiles.push(padded(current))
|
||||
return tiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins a route down to the points that still describe it, so the corridor is built
|
||||
* from tens of coordinates rather than the thousands a routed geometry carries.
|
||||
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
|
||||
*/
|
||||
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
if (line.length < 3) return line.slice()
|
||||
let maxDist = 0
|
||||
let index = 0
|
||||
const first = line[0]
|
||||
const last = line[line.length - 1]
|
||||
for (let i = 1; i < line.length - 1; i++) {
|
||||
const d = distanceToSegmentKm(line[i], first, last)
|
||||
if (d > maxDist) {
|
||||
maxDist = d
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if (maxDist <= toleranceKm) return [first, last]
|
||||
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
/** A POI that is actually on the way, with the two numbers that make it one. */
|
||||
export interface CorridorPoi extends Poi {
|
||||
offRouteKm: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export interface CorridorSearch {
|
||||
results: CorridorPoi[]
|
||||
/** Areas answered so far, out of how many the route was cut into. */
|
||||
progress: { done: number; total: number }
|
||||
loading: boolean
|
||||
/** True when the route was longer than the request budget and the tail was left out. */
|
||||
capped: boolean
|
||||
error: boolean
|
||||
search: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
|
||||
* would otherwise be dozens of requests against a shared public mirror; past this the
|
||||
* search stops and says so rather than quietly covering half the route.
|
||||
*/
|
||||
const MAX_TILES = 10
|
||||
/** Gap between Overpass requests — the public mirrors are a shared resource. */
|
||||
const REQUEST_SPACING_MS = 700
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal.aborted) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* "What is on the way" for a drawn route.
|
||||
*
|
||||
* Deliberately manual: the search runs when the user asks for it, never on pan or on
|
||||
* every route change, because each run is several Overpass requests and the mirrors are
|
||||
* shared. Results stay until the categories or the route change.
|
||||
*/
|
||||
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
|
||||
const { locale } = useTranslation()
|
||||
const [results, setResults] = useState<CorridorPoi[]>([])
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [capped, setCapped] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const runIdRef = useRef(0)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setResults([])
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setLoading(false)
|
||||
setCapped(false)
|
||||
setError(false)
|
||||
}, [])
|
||||
|
||||
// A changed route or category set makes the old answers wrong, not stale — drop them.
|
||||
const routeKey = line.length ? `${line.length}:${line[0].lat.toFixed(3)},${line[0].lng.toFixed(3)}` : ''
|
||||
const catKey = categories.join(',')
|
||||
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
|
||||
|
||||
const search = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
if (line.length < 2 || categories.length === 0) return
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const runId = ++runIdRef.current
|
||||
|
||||
// Thin the routed geometry first: a corridor built from every vertex would make
|
||||
// hundreds of overlapping boxes out of what a handful describe just as well.
|
||||
const spine = simplifyLine(line, Math.max(1, widthKm / 3))
|
||||
const allTiles = corridorTiles(spine, widthKm)
|
||||
const tiles = allTiles.slice(0, MAX_TILES)
|
||||
|
||||
setCapped(allTiles.length > tiles.length)
|
||||
setError(false)
|
||||
setResults([])
|
||||
setLoading(true)
|
||||
setProgress({ done: 0, total: tiles.length * categories.length })
|
||||
|
||||
void (async () => {
|
||||
const seen = new Map<string, CorridorPoi>()
|
||||
let failures = 0
|
||||
let done = 0
|
||||
for (const tile of tiles) {
|
||||
for (const category of categories) {
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
try {
|
||||
const data = await mapsApi.pois(category, tile, locale, controller.signal)
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
|
||||
}
|
||||
} catch {
|
||||
// One dead mirror or one clamped box must not empty the whole search; the
|
||||
// run is only called a failure if every request failed.
|
||||
failures++
|
||||
}
|
||||
done++
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
setProgress({ done, total: tiles.length * categories.length })
|
||||
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
|
||||
await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
}
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
setError(failures === tiles.length * categories.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [line, categories, widthKm, locale])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
return { results, progress, loading, capped, error, search, clear }
|
||||
}
|
||||
@@ -68,6 +68,7 @@ const ExpenseModal = lazyWithRetry(() =>
|
||||
)
|
||||
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
|
||||
const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
|
||||
const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel'))
|
||||
// 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
|
||||
// — about 200 kB of packed zone geometry that every trip used to load.
|
||||
@@ -585,6 +586,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
{roadtripActive ? (
|
||||
<LazyPanel id="roadtrip-corridor">
|
||||
<RoadtripCorridorPanel routes={roadtripRoutes} onAddPoi={openAddPlaceFromPoi} />
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<PlacesSidebar
|
||||
tripId={tripId}
|
||||
places={places}
|
||||
@@ -604,6 +610,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
days={days}
|
||||
isMobile={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +291,13 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
// What a drive needs rather than what a city visit does (#1797). Separate from
|
||||
// `activity`/`nature` on purpose: nobody browsing museums wants petrol stations in
|
||||
// the same result set, and the road trip panel asks for these by name.
|
||||
fuel: ['amenity=fuel'],
|
||||
charging: ['amenity=charging_station'],
|
||||
rest_area: ['highway=rest_area', 'highway=services'],
|
||||
campsite: ['tourism=camp_site', 'tourism=caravan_site'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
@@ -1046,7 +1046,11 @@ export class MapsService {
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null;
|
||||
// `operator` comes last but matters for the road categories: petrol stations,
|
||||
// charging points and service areas are routinely mapped with an operator and no
|
||||
// name, and dropping those would empty the road trip corridor over long stretches.
|
||||
const name =
|
||||
tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'أضف أماكن إلى يوم أو حجز سيارة مع محطات توقف، وسيظهر المسار هنا.',
|
||||
'roadtrip.stops.label': 'محطات على الطريق',
|
||||
'roadtrip.stops.remove': 'إزالة المحطة',
|
||||
'roadtrip.poi.title': 'على طول المسار',
|
||||
'roadtrip.poi.fuel': 'وقود',
|
||||
'roadtrip.poi.charging': 'شحن',
|
||||
'roadtrip.poi.rest': 'استراحة',
|
||||
'roadtrip.poi.campsite': 'مخيم',
|
||||
'roadtrip.poi.food': 'طعام',
|
||||
'roadtrip.poi.sights': 'معالم',
|
||||
'roadtrip.poi.within': 'ضمن',
|
||||
'roadtrip.poi.search': 'ابحث على طول المسار',
|
||||
'roadtrip.poi.searching': 'جارٍ البحث {done} من {total}',
|
||||
'roadtrip.poi.capped': 'المسار طويل — تم البحث في الجزء الأول فقط.',
|
||||
'roadtrip.poi.failed': 'البحث عن الأماكن لا يستجيب حاليًا.',
|
||||
'roadtrip.poi.empty': 'اختر ما تحتاجه ثم ابحث.',
|
||||
'roadtrip.poi.add': 'إضافة',
|
||||
'roadtrip.poi.offRoute': '{distance} عن المسار',
|
||||
'roadtrip.poi.atStart': 'قرب نقطة البداية',
|
||||
'roadtrip.poi.alongRoute': 'بعد {distance}',
|
||||
'roadtrip.summary.distance': 'المسافة',
|
||||
'roadtrip.summary.driving': 'مدة القيادة',
|
||||
'roadtrip.day': 'اليوم {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Adicione locais a um dia ou uma reserva de carro com paradas e a rota aparecerá aqui.',
|
||||
'roadtrip.stops.label': 'Paradas pelo caminho',
|
||||
'roadtrip.stops.remove': 'Remover parada',
|
||||
'roadtrip.poi.title': 'Ao longo da rota',
|
||||
'roadtrip.poi.fuel': 'Combustível',
|
||||
'roadtrip.poi.charging': 'Carregamento',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
'roadtrip.poi.sights': 'Pontos turísticos',
|
||||
'roadtrip.poi.within': 'Num raio de',
|
||||
'roadtrip.poi.search': 'Buscar ao longo da rota',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'A rota é longa — apenas o primeiro trecho foi pesquisado.',
|
||||
'roadtrip.poi.failed': 'A busca de locais não está respondendo agora.',
|
||||
'roadtrip.poi.empty': 'Escolha o que precisa e pesquise.',
|
||||
'roadtrip.poi.add': 'Adicionar',
|
||||
'roadtrip.poi.offRoute': '{distance} fora da rota',
|
||||
'roadtrip.poi.atStart': 'perto do início',
|
||||
'roadtrip.poi.alongRoute': 'após {distance}',
|
||||
'roadtrip.summary.distance': 'Distância',
|
||||
'roadtrip.summary.driving': 'Tempo de direção',
|
||||
'roadtrip.day': 'Dia {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Afegeix llocs a un dia o una reserva de cotxe amb parades i la ruta apareixerà aquí.',
|
||||
'roadtrip.stops.label': 'Parades pel camí',
|
||||
'roadtrip.stops.remove': 'Elimina la parada',
|
||||
'roadtrip.poi.title': 'Al llarg de la ruta',
|
||||
'roadtrip.poi.fuel': 'Combustible',
|
||||
'roadtrip.poi.charging': 'Càrrega',
|
||||
'roadtrip.poi.rest': 'Àrea de descans',
|
||||
'roadtrip.poi.campsite': 'Càmping',
|
||||
'roadtrip.poi.food': 'Menjar',
|
||||
'roadtrip.poi.sights': 'Llocs d’interès',
|
||||
'roadtrip.poi.within': 'En un radi de',
|
||||
'roadtrip.poi.search': 'Cerca al llarg de la ruta',
|
||||
'roadtrip.poi.searching': 'Cercant {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta és llarga: només s’ha cercat el primer tram.',
|
||||
'roadtrip.poi.failed': 'La cerca de llocs no respon ara mateix.',
|
||||
'roadtrip.poi.empty': 'Tria què necessites i cerca.',
|
||||
'roadtrip.poi.add': 'Afegeix',
|
||||
'roadtrip.poi.offRoute': '{distance} fora de la ruta',
|
||||
'roadtrip.poi.atStart': 'a prop de l’inici',
|
||||
'roadtrip.poi.alongRoute': 'després de {distance}',
|
||||
'roadtrip.summary.distance': 'Distància',
|
||||
'roadtrip.summary.driving': 'Temps de conducció',
|
||||
'roadtrip.day': 'Dia {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Přidejte místa ke dni nebo rezervaci auta se zastávkami a trasa se zobrazí zde.',
|
||||
'roadtrip.stops.label': 'Zastávky po cestě',
|
||||
'roadtrip.stops.remove': 'Odebrat zastávku',
|
||||
'roadtrip.poi.title': 'Podél trasy',
|
||||
'roadtrip.poi.fuel': 'Palivo',
|
||||
'roadtrip.poi.charging': 'Nabíjení',
|
||||
'roadtrip.poi.rest': 'Odpočívadlo',
|
||||
'roadtrip.poi.campsite': 'Kemp',
|
||||
'roadtrip.poi.food': 'Jídlo',
|
||||
'roadtrip.poi.sights': 'Zajímavosti',
|
||||
'roadtrip.poi.within': 'Do',
|
||||
'roadtrip.poi.search': 'Hledat podél trasy',
|
||||
'roadtrip.poi.searching': 'Hledání {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa je dlouhá — prohledán byl jen první úsek.',
|
||||
'roadtrip.poi.failed': 'Vyhledávání míst právě neodpovídá.',
|
||||
'roadtrip.poi.empty': 'Vyberte, co potřebujete, a hledejte.',
|
||||
'roadtrip.poi.add': 'Přidat',
|
||||
'roadtrip.poi.offRoute': '{distance} od trasy',
|
||||
'roadtrip.poi.atStart': 'blízko startu',
|
||||
'roadtrip.poi.alongRoute': 'po {distance}',
|
||||
'roadtrip.summary.distance': 'Vzdálenost',
|
||||
'roadtrip.summary.driving': 'Doba jízdy',
|
||||
'roadtrip.day': 'Den {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Ordne einem Tag Orte zu oder lege eine Autofahrt mit Stopps an — die Route erscheint dann hier.',
|
||||
'roadtrip.stops.label': 'Stopps unterwegs',
|
||||
'roadtrip.stops.remove': 'Stopp entfernen',
|
||||
'roadtrip.poi.title': 'Entlang der Route',
|
||||
'roadtrip.poi.fuel': 'Tanken',
|
||||
'roadtrip.poi.charging': 'Laden',
|
||||
'roadtrip.poi.rest': 'Rastplatz',
|
||||
'roadtrip.poi.campsite': 'Campingplatz',
|
||||
'roadtrip.poi.food': 'Essen',
|
||||
'roadtrip.poi.sights': 'Sehenswertes',
|
||||
'roadtrip.poi.within': 'Im Umkreis',
|
||||
'roadtrip.poi.search': 'Entlang der Route suchen',
|
||||
'roadtrip.poi.searching': 'Suche {done} von {total}',
|
||||
'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.',
|
||||
'roadtrip.poi.failed': 'Die Ortssuche antwortet gerade nicht.',
|
||||
'roadtrip.poi.empty': 'Wähle aus, was du brauchst, und suche.',
|
||||
'roadtrip.poi.add': 'Übernehmen',
|
||||
'roadtrip.poi.offRoute': '{distance} abseits der Route',
|
||||
'roadtrip.poi.atStart': 'am Startpunkt',
|
||||
'roadtrip.poi.alongRoute': 'nach {distance}',
|
||||
'roadtrip.summary.distance': 'Strecke',
|
||||
'roadtrip.summary.driving': 'Fahrzeit',
|
||||
'roadtrip.day': 'Tag {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Add places to a day, or a car booking with stops, and the route appears here.',
|
||||
'roadtrip.stops.label': 'Stops along the way',
|
||||
'roadtrip.stops.remove': 'Remove stop',
|
||||
'roadtrip.poi.title': 'Along the route',
|
||||
'roadtrip.poi.fuel': 'Fuel',
|
||||
'roadtrip.poi.charging': 'Charging',
|
||||
'roadtrip.poi.rest': 'Rest area',
|
||||
'roadtrip.poi.campsite': 'Campsite',
|
||||
'roadtrip.poi.food': 'Food',
|
||||
'roadtrip.poi.sights': 'Sights',
|
||||
'roadtrip.poi.within': 'Within',
|
||||
'roadtrip.poi.search': 'Search along the route',
|
||||
'roadtrip.poi.searching': 'Searching {done} of {total}',
|
||||
'roadtrip.poi.capped': 'The route is long — only the first stretch was searched.',
|
||||
'roadtrip.poi.failed': 'The place search is not answering right now.',
|
||||
'roadtrip.poi.empty': 'Pick what you need and search.',
|
||||
'roadtrip.poi.add': 'Add',
|
||||
'roadtrip.poi.offRoute': '{distance} off the route',
|
||||
'roadtrip.poi.atStart': 'near the start',
|
||||
'roadtrip.poi.alongRoute': 'after {distance}',
|
||||
'roadtrip.summary.distance': 'Distance',
|
||||
'roadtrip.summary.driving': 'Driving time',
|
||||
'roadtrip.day': 'Day {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Añade lugares a un día o una reserva de coche con paradas y la ruta aparecerá aquí.',
|
||||
'roadtrip.stops.label': 'Paradas por el camino',
|
||||
'roadtrip.stops.remove': 'Eliminar parada',
|
||||
'roadtrip.poi.title': 'A lo largo de la ruta',
|
||||
'roadtrip.poi.fuel': 'Gasolina',
|
||||
'roadtrip.poi.charging': 'Recarga',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
'roadtrip.poi.sights': 'Lugares de interés',
|
||||
'roadtrip.poi.within': 'En un radio de',
|
||||
'roadtrip.poi.search': 'Buscar a lo largo de la ruta',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta es larga: solo se buscó en el primer tramo.',
|
||||
'roadtrip.poi.failed': 'La búsqueda de lugares no responde ahora mismo.',
|
||||
'roadtrip.poi.empty': 'Elige lo que necesitas y busca.',
|
||||
'roadtrip.poi.add': 'Añadir',
|
||||
'roadtrip.poi.offRoute': '{distance} fuera de la ruta',
|
||||
'roadtrip.poi.atStart': 'cerca del inicio',
|
||||
'roadtrip.poi.alongRoute': 'tras {distance}',
|
||||
'roadtrip.summary.distance': 'Distancia',
|
||||
'roadtrip.summary.driving': 'Tiempo de conducción',
|
||||
'roadtrip.day': 'Día {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Ajoutez des lieux à une journée ou une location de voiture avec des étapes : l’itinéraire apparaîtra ici.',
|
||||
'roadtrip.stops.label': 'Étapes en chemin',
|
||||
'roadtrip.stops.remove': 'Supprimer l’étape',
|
||||
'roadtrip.poi.title': 'Le long de l’itinéraire',
|
||||
'roadtrip.poi.fuel': 'Carburant',
|
||||
'roadtrip.poi.charging': 'Recharge',
|
||||
'roadtrip.poi.rest': 'Aire de repos',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Restauration',
|
||||
'roadtrip.poi.sights': 'À voir',
|
||||
'roadtrip.poi.within': 'Dans un rayon de',
|
||||
'roadtrip.poi.search': 'Chercher le long du trajet',
|
||||
'roadtrip.poi.searching': 'Recherche {done} sur {total}',
|
||||
'roadtrip.poi.capped': 'L’itinéraire est long : seule la première portion a été parcourue.',
|
||||
'roadtrip.poi.failed': 'La recherche de lieux ne répond pas pour le moment.',
|
||||
'roadtrip.poi.empty': 'Choisissez ce qu’il vous faut, puis lancez la recherche.',
|
||||
'roadtrip.poi.add': 'Ajouter',
|
||||
'roadtrip.poi.offRoute': '{distance} hors itinéraire',
|
||||
'roadtrip.poi.atStart': 'près du départ',
|
||||
'roadtrip.poi.alongRoute': 'après {distance}',
|
||||
'roadtrip.summary.distance': 'Distance',
|
||||
'roadtrip.summary.driving': 'Temps de conduite',
|
||||
'roadtrip.day': 'Jour {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Προσθέστε τοποθεσίες σε μια ημέρα ή μια κράτηση αυτοκινήτου με στάσεις και η διαδρομή θα εμφανιστεί εδώ.',
|
||||
'roadtrip.stops.label': 'Στάσεις στη διαδρομή',
|
||||
'roadtrip.stops.remove': 'Αφαίρεση στάσης',
|
||||
'roadtrip.poi.title': 'Κατά μήκος της διαδρομής',
|
||||
'roadtrip.poi.fuel': 'Καύσιμα',
|
||||
'roadtrip.poi.charging': 'Φόρτιση',
|
||||
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
|
||||
'roadtrip.poi.campsite': 'Κάμπινγκ',
|
||||
'roadtrip.poi.food': 'Φαγητό',
|
||||
'roadtrip.poi.sights': 'Αξιοθέατα',
|
||||
'roadtrip.poi.within': 'Εντός',
|
||||
'roadtrip.poi.search': 'Αναζήτηση κατά μήκος της διαδρομής',
|
||||
'roadtrip.poi.searching': 'Αναζήτηση {done} από {total}',
|
||||
'roadtrip.poi.capped': 'Η διαδρομή είναι μεγάλη — αναζητήθηκε μόνο το πρώτο τμήμα.',
|
||||
'roadtrip.poi.failed': 'Η αναζήτηση τοποθεσιών δεν απαντά αυτή τη στιγμή.',
|
||||
'roadtrip.poi.empty': 'Διαλέξτε τι χρειάζεστε και αναζητήστε.',
|
||||
'roadtrip.poi.add': 'Προσθήκη',
|
||||
'roadtrip.poi.offRoute': '{distance} από τη διαδρομή',
|
||||
'roadtrip.poi.atStart': 'κοντά στην αφετηρία',
|
||||
'roadtrip.poi.alongRoute': 'μετά από {distance}',
|
||||
'roadtrip.summary.distance': 'Απόσταση',
|
||||
'roadtrip.summary.driving': 'Χρόνος οδήγησης',
|
||||
'roadtrip.day': 'Ημέρα {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Adj helyszíneket egy naphoz, vagy hozz létre megállókkal rendelkező autós foglalást – az útvonal itt jelenik meg.',
|
||||
'roadtrip.stops.label': 'Megállók útközben',
|
||||
'roadtrip.stops.remove': 'Megálló eltávolítása',
|
||||
'roadtrip.poi.title': 'Az útvonal mentén',
|
||||
'roadtrip.poi.fuel': 'Üzemanyag',
|
||||
'roadtrip.poi.charging': 'Töltés',
|
||||
'roadtrip.poi.rest': 'Pihenőhely',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Étkezés',
|
||||
'roadtrip.poi.sights': 'Látnivalók',
|
||||
'roadtrip.poi.within': 'Ezen belül',
|
||||
'roadtrip.poi.search': 'Keresés az útvonal mentén',
|
||||
'roadtrip.poi.searching': 'Keresés {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Az útvonal hosszú – csak az első szakaszon kerestünk.',
|
||||
'roadtrip.poi.failed': 'A helykeresés most nem válaszol.',
|
||||
'roadtrip.poi.empty': 'Válaszd ki, mire van szükséged, és keress.',
|
||||
'roadtrip.poi.add': 'Hozzáadás',
|
||||
'roadtrip.poi.offRoute': '{distance} az útvonaltól',
|
||||
'roadtrip.poi.atStart': 'a kiindulás közelében',
|
||||
'roadtrip.poi.alongRoute': '{distance} után',
|
||||
'roadtrip.summary.distance': 'Távolság',
|
||||
'roadtrip.summary.driving': 'Vezetési idő',
|
||||
'roadtrip.day': '{number}. nap',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Tambahkan tempat ke sebuah hari, atau pemesanan mobil dengan perhentian, dan rute akan muncul di sini.',
|
||||
'roadtrip.stops.label': 'Perhentian di sepanjang jalan',
|
||||
'roadtrip.stops.remove': 'Hapus perhentian',
|
||||
'roadtrip.poi.title': 'Sepanjang rute',
|
||||
'roadtrip.poi.fuel': 'Bahan bakar',
|
||||
'roadtrip.poi.charging': 'Pengisian daya',
|
||||
'roadtrip.poi.rest': 'Tempat istirahat',
|
||||
'roadtrip.poi.campsite': 'Perkemahan',
|
||||
'roadtrip.poi.food': 'Makanan',
|
||||
'roadtrip.poi.sights': 'Objek wisata',
|
||||
'roadtrip.poi.within': 'Dalam',
|
||||
'roadtrip.poi.search': 'Cari di sepanjang rute',
|
||||
'roadtrip.poi.searching': 'Mencari {done} dari {total}',
|
||||
'roadtrip.poi.capped': 'Rute panjang — hanya bagian pertama yang dicari.',
|
||||
'roadtrip.poi.failed': 'Pencarian tempat sedang tidak merespons.',
|
||||
'roadtrip.poi.empty': 'Pilih yang Anda butuhkan lalu cari.',
|
||||
'roadtrip.poi.add': 'Tambah',
|
||||
'roadtrip.poi.offRoute': '{distance} dari rute',
|
||||
'roadtrip.poi.atStart': 'dekat titik awal',
|
||||
'roadtrip.poi.alongRoute': 'setelah {distance}',
|
||||
'roadtrip.summary.distance': 'Jarak',
|
||||
'roadtrip.summary.driving': 'Waktu berkendara',
|
||||
'roadtrip.day': 'Hari {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Aggiungi luoghi a un giorno o una prenotazione auto con soste: il percorso comparirà qui.',
|
||||
'roadtrip.stops.label': 'Soste lungo il tragitto',
|
||||
'roadtrip.stops.remove': 'Rimuovi sosta',
|
||||
'roadtrip.poi.title': 'Lungo il percorso',
|
||||
'roadtrip.poi.fuel': 'Carburante',
|
||||
'roadtrip.poi.charging': 'Ricarica',
|
||||
'roadtrip.poi.rest': 'Area di sosta',
|
||||
'roadtrip.poi.campsite': 'Campeggio',
|
||||
'roadtrip.poi.food': 'Ristoro',
|
||||
'roadtrip.poi.sights': 'Da vedere',
|
||||
'roadtrip.poi.within': 'Entro',
|
||||
'roadtrip.poi.search': 'Cerca lungo il percorso',
|
||||
'roadtrip.poi.searching': 'Ricerca {done} di {total}',
|
||||
'roadtrip.poi.capped': 'Il percorso è lungo: è stato cercato solo il primo tratto.',
|
||||
'roadtrip.poi.failed': 'La ricerca dei luoghi non risponde al momento.',
|
||||
'roadtrip.poi.empty': 'Scegli cosa ti serve e cerca.',
|
||||
'roadtrip.poi.add': 'Aggiungi',
|
||||
'roadtrip.poi.offRoute': '{distance} fuori percorso',
|
||||
'roadtrip.poi.atStart': 'vicino alla partenza',
|
||||
'roadtrip.poi.alongRoute': 'dopo {distance}',
|
||||
'roadtrip.summary.distance': 'Distanza',
|
||||
'roadtrip.summary.driving': 'Tempo di guida',
|
||||
'roadtrip.day': 'Giorno {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': '日に場所を追加するか、立ち寄り先付きの車の予約を作成すると、ここにルートが表示されます。',
|
||||
'roadtrip.stops.label': '途中の立ち寄り先',
|
||||
'roadtrip.stops.remove': '立ち寄り先を削除',
|
||||
'roadtrip.poi.title': 'ルート沿い',
|
||||
'roadtrip.poi.fuel': '給油',
|
||||
'roadtrip.poi.charging': '充電',
|
||||
'roadtrip.poi.rest': 'サービスエリア',
|
||||
'roadtrip.poi.campsite': 'キャンプ場',
|
||||
'roadtrip.poi.food': '食事',
|
||||
'roadtrip.poi.sights': '見どころ',
|
||||
'roadtrip.poi.within': '範囲',
|
||||
'roadtrip.poi.search': 'ルート沿いを検索',
|
||||
'roadtrip.poi.searching': '検索中 {done}/{total}',
|
||||
'roadtrip.poi.capped': 'ルートが長いため、最初の区間のみ検索しました。',
|
||||
'roadtrip.poi.failed': '場所の検索が現在応答していません。',
|
||||
'roadtrip.poi.empty': '必要なものを選んで検索してください。',
|
||||
'roadtrip.poi.add': '追加',
|
||||
'roadtrip.poi.offRoute': 'ルートから {distance}',
|
||||
'roadtrip.poi.atStart': '出発地の近く',
|
||||
'roadtrip.poi.alongRoute': '{distance} 地点',
|
||||
'roadtrip.summary.distance': '距離',
|
||||
'roadtrip.summary.driving': '運転時間',
|
||||
'roadtrip.day': '{number} 日目',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': '하루에 장소를 추가하거나 경유지가 있는 차량 예약을 만들면 여기에 경로가 표시됩니다.',
|
||||
'roadtrip.stops.label': '가는 길의 경유지',
|
||||
'roadtrip.stops.remove': '경유지 삭제',
|
||||
'roadtrip.poi.title': '경로 주변',
|
||||
'roadtrip.poi.fuel': '주유',
|
||||
'roadtrip.poi.charging': '충전',
|
||||
'roadtrip.poi.rest': '휴게소',
|
||||
'roadtrip.poi.campsite': '캠핑장',
|
||||
'roadtrip.poi.food': '식사',
|
||||
'roadtrip.poi.sights': '볼거리',
|
||||
'roadtrip.poi.within': '반경',
|
||||
'roadtrip.poi.search': '경로 주변 검색',
|
||||
'roadtrip.poi.searching': '검색 중 {done}/{total}',
|
||||
'roadtrip.poi.capped': '경로가 길어 앞부분만 검색했습니다.',
|
||||
'roadtrip.poi.failed': '장소 검색이 지금 응답하지 않습니다.',
|
||||
'roadtrip.poi.empty': '필요한 항목을 고르고 검색하세요.',
|
||||
'roadtrip.poi.add': '추가',
|
||||
'roadtrip.poi.offRoute': '경로에서 {distance}',
|
||||
'roadtrip.poi.atStart': '출발지 근처',
|
||||
'roadtrip.poi.alongRoute': '{distance} 지점',
|
||||
'roadtrip.summary.distance': '거리',
|
||||
'roadtrip.summary.driving': '운전 시간',
|
||||
'roadtrip.day': '{number}일차',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Voeg plaatsen toe aan een dag of een autoboeking met stops, dan verschijnt de route hier.',
|
||||
'roadtrip.stops.label': 'Stops onderweg',
|
||||
'roadtrip.stops.remove': 'Stop verwijderen',
|
||||
'roadtrip.poi.title': 'Langs de route',
|
||||
'roadtrip.poi.fuel': 'Tanken',
|
||||
'roadtrip.poi.charging': 'Laden',
|
||||
'roadtrip.poi.rest': 'Rustplaats',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Eten',
|
||||
'roadtrip.poi.sights': 'Bezienswaardig',
|
||||
'roadtrip.poi.within': 'Binnen',
|
||||
'roadtrip.poi.search': 'Langs de route zoeken',
|
||||
'roadtrip.poi.searching': 'Zoeken {done} van {total}',
|
||||
'roadtrip.poi.capped': 'De route is lang — alleen het eerste deel is doorzocht.',
|
||||
'roadtrip.poi.failed': 'De plaatszoekfunctie reageert nu niet.',
|
||||
'roadtrip.poi.empty': 'Kies wat je nodig hebt en zoek.',
|
||||
'roadtrip.poi.add': 'Toevoegen',
|
||||
'roadtrip.poi.offRoute': '{distance} van de route',
|
||||
'roadtrip.poi.atStart': 'bij het startpunt',
|
||||
'roadtrip.poi.alongRoute': 'na {distance}',
|
||||
'roadtrip.summary.distance': 'Afstand',
|
||||
'roadtrip.summary.driving': 'Rijtijd',
|
||||
'roadtrip.day': 'Dag {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Dodaj miejsca do dnia albo rezerwację samochodu z przystankami, a trasa pojawi się tutaj.',
|
||||
'roadtrip.stops.label': 'Przystanki po drodze',
|
||||
'roadtrip.stops.remove': 'Usuń przystanek',
|
||||
'roadtrip.poi.title': 'Wzdłuż trasy',
|
||||
'roadtrip.poi.fuel': 'Paliwo',
|
||||
'roadtrip.poi.charging': 'Ładowanie',
|
||||
'roadtrip.poi.rest': 'Miejsce odpoczynku',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Jedzenie',
|
||||
'roadtrip.poi.sights': 'Atrakcje',
|
||||
'roadtrip.poi.within': 'W promieniu',
|
||||
'roadtrip.poi.search': 'Szukaj wzdłuż trasy',
|
||||
'roadtrip.poi.searching': 'Szukanie {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa jest długa — przeszukano tylko pierwszy odcinek.',
|
||||
'roadtrip.poi.failed': 'Wyszukiwanie miejsc chwilowo nie odpowiada.',
|
||||
'roadtrip.poi.empty': 'Wybierz, czego potrzebujesz, i szukaj.',
|
||||
'roadtrip.poi.add': 'Dodaj',
|
||||
'roadtrip.poi.offRoute': '{distance} od trasy',
|
||||
'roadtrip.poi.atStart': 'blisko startu',
|
||||
'roadtrip.poi.alongRoute': 'po {distance}',
|
||||
'roadtrip.summary.distance': 'Dystans',
|
||||
'roadtrip.summary.driving': 'Czas jazdy',
|
||||
'roadtrip.day': 'Dzień {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Добавьте места в день или бронирование авто с остановками — маршрут появится здесь.',
|
||||
'roadtrip.stops.label': 'Остановки в пути',
|
||||
'roadtrip.stops.remove': 'Удалить остановку',
|
||||
'roadtrip.poi.title': 'Вдоль маршрута',
|
||||
'roadtrip.poi.fuel': 'Заправка',
|
||||
'roadtrip.poi.charging': 'Зарядка',
|
||||
'roadtrip.poi.rest': 'Зона отдыха',
|
||||
'roadtrip.poi.campsite': 'Кемпинг',
|
||||
'roadtrip.poi.food': 'Еда',
|
||||
'roadtrip.poi.sights': 'Достопримечательности',
|
||||
'roadtrip.poi.within': 'В радиусе',
|
||||
'roadtrip.poi.search': 'Искать вдоль маршрута',
|
||||
'roadtrip.poi.searching': 'Поиск {done} из {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут длинный — просмотрен только первый участок.',
|
||||
'roadtrip.poi.failed': 'Поиск мест сейчас не отвечает.',
|
||||
'roadtrip.poi.empty': 'Выберите нужное и запустите поиск.',
|
||||
'roadtrip.poi.add': 'Добавить',
|
||||
'roadtrip.poi.offRoute': '{distance} от маршрута',
|
||||
'roadtrip.poi.atStart': 'у начала маршрута',
|
||||
'roadtrip.poi.alongRoute': 'через {distance}',
|
||||
'roadtrip.summary.distance': 'Расстояние',
|
||||
'roadtrip.summary.driving': 'Время в пути',
|
||||
'roadtrip.day': 'День {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Lägg till platser på en dag eller en bilbokning med stopp, så visas rutten här.',
|
||||
'roadtrip.stops.label': 'Stopp längs vägen',
|
||||
'roadtrip.stops.remove': 'Ta bort stopp',
|
||||
'roadtrip.poi.title': 'Längs rutten',
|
||||
'roadtrip.poi.fuel': 'Bensin',
|
||||
'roadtrip.poi.charging': 'Laddning',
|
||||
'roadtrip.poi.rest': 'Rastplats',
|
||||
'roadtrip.poi.campsite': 'Campingplats',
|
||||
'roadtrip.poi.food': 'Mat',
|
||||
'roadtrip.poi.sights': 'Sevärt',
|
||||
'roadtrip.poi.within': 'Inom',
|
||||
'roadtrip.poi.search': 'Sök längs rutten',
|
||||
'roadtrip.poi.searching': 'Söker {done} av {total}',
|
||||
'roadtrip.poi.capped': 'Rutten är lång – bara första sträckan genomsöktes.',
|
||||
'roadtrip.poi.failed': 'Platssökningen svarar inte just nu.',
|
||||
'roadtrip.poi.empty': 'Välj vad du behöver och sök.',
|
||||
'roadtrip.poi.add': 'Lägg till',
|
||||
'roadtrip.poi.offRoute': '{distance} från rutten',
|
||||
'roadtrip.poi.atStart': 'nära starten',
|
||||
'roadtrip.poi.alongRoute': 'efter {distance}',
|
||||
'roadtrip.summary.distance': 'Sträcka',
|
||||
'roadtrip.summary.driving': 'Körtid',
|
||||
'roadtrip.day': 'Dag {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Bir güne yer ekleyin ya da duraklı bir araç rezervasyonu oluşturun; rota burada görünür.',
|
||||
'roadtrip.stops.label': 'Yol üzerindeki duraklar',
|
||||
'roadtrip.stops.remove': 'Durağı kaldır',
|
||||
'roadtrip.poi.title': 'Rota boyunca',
|
||||
'roadtrip.poi.fuel': 'Yakıt',
|
||||
'roadtrip.poi.charging': 'Şarj',
|
||||
'roadtrip.poi.rest': 'Dinlenme alanı',
|
||||
'roadtrip.poi.campsite': 'Kamp alanı',
|
||||
'roadtrip.poi.food': 'Yemek',
|
||||
'roadtrip.poi.sights': 'Görülecek yerler',
|
||||
'roadtrip.poi.within': 'Mesafe',
|
||||
'roadtrip.poi.search': 'Rota boyunca ara',
|
||||
'roadtrip.poi.searching': 'Aranıyor {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Rota uzun — yalnızca ilk bölüm arandı.',
|
||||
'roadtrip.poi.failed': 'Yer araması şu anda yanıt vermiyor.',
|
||||
'roadtrip.poi.empty': 'Neye ihtiyacın olduğunu seç ve ara.',
|
||||
'roadtrip.poi.add': 'Ekle',
|
||||
'roadtrip.poi.offRoute': 'rotadan {distance}',
|
||||
'roadtrip.poi.atStart': 'başlangıca yakın',
|
||||
'roadtrip.poi.alongRoute': '{distance} sonra',
|
||||
'roadtrip.summary.distance': 'Mesafe',
|
||||
'roadtrip.summary.driving': 'Sürüş süresi',
|
||||
'roadtrip.day': '{number}. gün',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Додайте місця до дня або бронювання авто із зупинками — маршрут з’явиться тут.',
|
||||
'roadtrip.stops.label': 'Зупинки в дорозі',
|
||||
'roadtrip.stops.remove': 'Видалити зупинку',
|
||||
'roadtrip.poi.title': 'Уздовж маршруту',
|
||||
'roadtrip.poi.fuel': 'Заправка',
|
||||
'roadtrip.poi.charging': 'Зарядка',
|
||||
'roadtrip.poi.rest': 'Зона відпочинку',
|
||||
'roadtrip.poi.campsite': 'Кемпінг',
|
||||
'roadtrip.poi.food': 'Їжа',
|
||||
'roadtrip.poi.sights': 'Пам’ятки',
|
||||
'roadtrip.poi.within': 'У радіусі',
|
||||
'roadtrip.poi.search': 'Шукати вздовж маршруту',
|
||||
'roadtrip.poi.searching': 'Пошук {done} з {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут довгий — переглянуто лише перший відрізок.',
|
||||
'roadtrip.poi.failed': 'Пошук місць зараз не відповідає.',
|
||||
'roadtrip.poi.empty': 'Оберіть потрібне й почніть пошук.',
|
||||
'roadtrip.poi.add': 'Додати',
|
||||
'roadtrip.poi.offRoute': '{distance} від маршруту',
|
||||
'roadtrip.poi.atStart': 'біля початку',
|
||||
'roadtrip.poi.alongRoute': 'через {distance}',
|
||||
'roadtrip.summary.distance': 'Відстань',
|
||||
'roadtrip.summary.driving': 'Час у дорозі',
|
||||
'roadtrip.day': 'День {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': 'Thêm địa điểm vào một ngày hoặc đặt xe có điểm dừng, lộ trình sẽ hiện ở đây.',
|
||||
'roadtrip.stops.label': 'Điểm dừng dọc đường',
|
||||
'roadtrip.stops.remove': 'Xóa điểm dừng',
|
||||
'roadtrip.poi.title': 'Dọc theo lộ trình',
|
||||
'roadtrip.poi.fuel': 'Xăng dầu',
|
||||
'roadtrip.poi.charging': 'Sạc điện',
|
||||
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
|
||||
'roadtrip.poi.campsite': 'Khu cắm trại',
|
||||
'roadtrip.poi.food': 'Đồ ăn',
|
||||
'roadtrip.poi.sights': 'Điểm tham quan',
|
||||
'roadtrip.poi.within': 'Trong vòng',
|
||||
'roadtrip.poi.search': 'Tìm dọc lộ trình',
|
||||
'roadtrip.poi.searching': 'Đang tìm {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Lộ trình dài — chỉ đoạn đầu được tìm kiếm.',
|
||||
'roadtrip.poi.failed': 'Tìm kiếm địa điểm hiện không phản hồi.',
|
||||
'roadtrip.poi.empty': 'Chọn thứ bạn cần rồi tìm.',
|
||||
'roadtrip.poi.add': 'Thêm',
|
||||
'roadtrip.poi.offRoute': 'cách lộ trình {distance}',
|
||||
'roadtrip.poi.atStart': 'gần điểm xuất phát',
|
||||
'roadtrip.poi.alongRoute': 'sau {distance}',
|
||||
'roadtrip.summary.distance': 'Quãng đường',
|
||||
'roadtrip.summary.driving': 'Thời gian lái xe',
|
||||
'roadtrip.day': 'Ngày {number}',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': '為某一天新增地點,或建立含停靠點的租車預訂,路線就會顯示在這裡。',
|
||||
'roadtrip.stops.label': '沿途停靠點',
|
||||
'roadtrip.stops.remove': '移除停靠點',
|
||||
'roadtrip.poi.title': '沿線',
|
||||
'roadtrip.poi.fuel': '加油',
|
||||
'roadtrip.poi.charging': '充電',
|
||||
'roadtrip.poi.rest': '休息站',
|
||||
'roadtrip.poi.campsite': '露營地',
|
||||
'roadtrip.poi.food': '餐飲',
|
||||
'roadtrip.poi.sights': '景點',
|
||||
'roadtrip.poi.within': '範圍內',
|
||||
'roadtrip.poi.search': '沿路線搜尋',
|
||||
'roadtrip.poi.searching': '搜尋中 {done}/{total}',
|
||||
'roadtrip.poi.capped': '路線較長,僅搜尋了前段。',
|
||||
'roadtrip.poi.failed': '地點搜尋暫時沒有回應。',
|
||||
'roadtrip.poi.empty': '選擇你需要的,然後搜尋。',
|
||||
'roadtrip.poi.add': '新增',
|
||||
'roadtrip.poi.offRoute': '偏離路線 {distance}',
|
||||
'roadtrip.poi.atStart': '靠近起點',
|
||||
'roadtrip.poi.alongRoute': '{distance} 處',
|
||||
'roadtrip.summary.distance': '距離',
|
||||
'roadtrip.summary.driving': '駕駛時間',
|
||||
'roadtrip.day': '第 {number} 天',
|
||||
|
||||
@@ -10,6 +10,23 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.empty.body': '为某一天添加地点,或创建带停靠点的租车预订,路线就会显示在这里。',
|
||||
'roadtrip.stops.label': '沿途停靠点',
|
||||
'roadtrip.stops.remove': '移除停靠点',
|
||||
'roadtrip.poi.title': '沿线',
|
||||
'roadtrip.poi.fuel': '加油',
|
||||
'roadtrip.poi.charging': '充电',
|
||||
'roadtrip.poi.rest': '服务区',
|
||||
'roadtrip.poi.campsite': '露营地',
|
||||
'roadtrip.poi.food': '餐饮',
|
||||
'roadtrip.poi.sights': '景点',
|
||||
'roadtrip.poi.within': '范围内',
|
||||
'roadtrip.poi.search': '沿路线搜索',
|
||||
'roadtrip.poi.searching': '搜索中 {done}/{total}',
|
||||
'roadtrip.poi.capped': '路线较长,仅搜索了前段。',
|
||||
'roadtrip.poi.failed': '地点搜索暂时没有响应。',
|
||||
'roadtrip.poi.empty': '选择你需要的,然后搜索。',
|
||||
'roadtrip.poi.add': '添加',
|
||||
'roadtrip.poi.offRoute': '偏离路线 {distance}',
|
||||
'roadtrip.poi.atStart': '靠近起点',
|
||||
'roadtrip.poi.alongRoute': '{distance} 处',
|
||||
'roadtrip.summary.distance': '距离',
|
||||
'roadtrip.summary.driving': '驾驶时间',
|
||||
'roadtrip.day': '第 {number} 天',
|
||||
|
||||
Reference in New Issue
Block a user