From 07a4447bbd283700681eddee720157d5421fbd24 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 22:04:15 +0200 Subject: [PATCH] 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. --- client/src/components/PDF/TripPDF.tsx | 7 + .../Roadtrip/RoadtripCorridorPanel.tsx | 196 ++++++++++++++++++ .../src/components/Roadtrip/corridor.test.ts | 132 ++++++++++++ client/src/components/Roadtrip/corridor.ts | 184 ++++++++++++++++ .../components/Roadtrip/useCorridorPois.ts | 132 ++++++++++++ client/src/pages/TripPlannerPage.tsx | 7 + server/src/nest/maps/maps.helpers.ts | 7 + server/src/nest/maps/maps.service.ts | 6 +- shared/src/i18n/ar/roadtrip.ts | 17 ++ shared/src/i18n/br/roadtrip.ts | 17 ++ shared/src/i18n/ca/roadtrip.ts | 17 ++ shared/src/i18n/cs/roadtrip.ts | 17 ++ shared/src/i18n/de/roadtrip.ts | 17 ++ shared/src/i18n/en/roadtrip.ts | 17 ++ shared/src/i18n/es/roadtrip.ts | 17 ++ shared/src/i18n/fr/roadtrip.ts | 17 ++ shared/src/i18n/gr/roadtrip.ts | 17 ++ shared/src/i18n/hu/roadtrip.ts | 17 ++ shared/src/i18n/id/roadtrip.ts | 17 ++ shared/src/i18n/it/roadtrip.ts | 17 ++ shared/src/i18n/ja/roadtrip.ts | 17 ++ shared/src/i18n/ko/roadtrip.ts | 17 ++ shared/src/i18n/nl/roadtrip.ts | 17 ++ shared/src/i18n/pl/roadtrip.ts | 17 ++ shared/src/i18n/ru/roadtrip.ts | 17 ++ shared/src/i18n/sv/roadtrip.ts | 17 ++ shared/src/i18n/tr/roadtrip.ts | 17 ++ shared/src/i18n/uk/roadtrip.ts | 17 ++ shared/src/i18n/vi/roadtrip.ts | 17 ++ shared/src/i18n/zh-TW/roadtrip.ts | 17 ++ shared/src/i18n/zh/roadtrip.ts | 17 ++ 31 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Roadtrip/RoadtripCorridorPanel.tsx create mode 100644 client/src/components/Roadtrip/corridor.test.ts create mode 100644 client/src/components/Roadtrip/corridor.ts create mode 100644 client/src/components/Roadtrip/useCorridorPois.ts 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} 天',