diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 6058cdd24..41b63e1b3 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -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(' · ') diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx new file mode 100644 index 000000000..ea7266aeb --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -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 = 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 ( +
+ +
+
{poi.name}
+
+ {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} + · + {/* 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) })} +
+
+ {onAdd ? ( + + ) : null} +
+ ) +} + +/** + * "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(routes.days[0]?.dayId ?? '')) + const [active, setActive] = useState(['fuel']) + const [widthKm, setWidthKm] = useState(5) + + const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0] + const line = useMemo(() => { + 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 ( +
+
+

{t('roadtrip.poi.title')}

+ + {dayOptions.length > 1 ? ( + setDayId(String(value))} options={dayOptions} size="sm" /> + ) : null} + +
+ {CORRIDOR_CATEGORIES.map(({ key, labelKey, Icon }) => { + const on = active.includes(key) + return ( + + ) + })} +
+ +
+ {t('roadtrip.poi.within')} +
+ {WIDTHS_KM.map(km => ( + + ))} +
+
+ + + + {search.capped ? ( +

+ + {t('roadtrip.poi.capped')} +

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

+ + {t('roadtrip.poi.failed')} +

+ ) : null} +
+ +
+ {search.results.length === 0 && !search.loading ? ( +

{t('roadtrip.poi.empty')}

+ ) : ( + search.results.map(poi => ( + onAddPoi(poi, day?.dayId ?? null) : undefined} + /> + )) + )} +
+
+ ) +} diff --git a/client/src/components/Roadtrip/corridor.test.ts b/client/src/components/Roadtrip/corridor.test.ts new file mode 100644 index 000000000..e02008d0a --- /dev/null +++ b/client/src/components/Roadtrip/corridor.test.ts @@ -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]) + }) +}) diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts new file mode 100644 index 000000000..301189094 --- /dev/null +++ b/client/src/components/Roadtrip/corridor.ts @@ -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] +} diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts new file mode 100644 index 000000000..16b60a7bd --- /dev/null +++ b/client/src/components/Roadtrip/useCorridorPois.ts @@ -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 => + 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([]) + 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(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() + 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 } +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 42b6eeed5..c55250439 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 { /> )}
+ {roadtripActive ? ( + + + + ) : ( + )}
diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index c4515cad4..2a42f75ff 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -291,6 +291,13 @@ export const CATEGORY_OSM_FILTERS: Record = { 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); diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 79f91cfc8..bd448e7a7 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -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 diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index f0380b91e..ace357abe 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 763feb53b..ca3bd237d 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 825b5e4b2..c4fe4f8e4 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index fec79cd1f..d03a52ce7 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 093de610b..3c6823e7f 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 309a89c84..3bed15197 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 4d057549d..09040ee31 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 5f7913c4a..e00f2241c 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index f2e25b6dd..6182bed4c 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 372f6a884..fe0a8a9c0 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 74b80bda9..932c31468 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index efb652dfc..b4b5d7d20 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 7f84c65c4..564467713 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -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} 日目', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index f85dcde2d..bbe47d037 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -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}일차', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 1cb81b3f5..1c2822ff7 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 81cfe8d84..8efaf8b75 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 5bd7b0b6c..8d11bb1af 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 7c9ae9b85..2ecf911f8 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index cc8cf3d32..5776bd282 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 87099c734..db9c685f8 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index de8ff072a..230788766 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -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}', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index bc073dc08..ebb977654 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -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} 天', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 22c7ca0f2..d68e04979 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -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} 天',