fix(pdf): route every leg of the trip map and mark what is left unrouted

The PDF export routed the whole trip under an 8 s deadline while the router
is asked one leg at a time, a second apart. A cold month-long trip got about
seven legs routed per export; the rest printed as straight lines at "0 m"
under a total that looked final, and every further export routed a few more
days from the device cache.

The export now waits for every leg, bounded by the request pacing, the
rate-limit retries and a per-leg timeout. The export dialog and the phone
sheet count the legs while they route and offer to print without the rest.
Any leg left without a road is marked in the document: the day reads "not
routed" or "at least ...", the total says it is a floor, and a note names
how many legs are missing. The planner overview shares the same wording.

The PDF also routes what the planner map routes now: the road trip's vias,
the multi-day tour stages and the reader's distance unit.

Closes #2876
This commit is contained in:
Maurice
2026-10-29 14:48:13 +01:00
parent c03c40d287
commit 3238fbc517
69 changed files with 506 additions and 99 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/PDF/TripPDF.tsx": 1054,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
@@ -100,6 +100,9 @@ describe('TripRouteOverviewPanel', () => {
expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted')
expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1)
// The short figures read as floors, the trip's and the day's, not as measurements (#2876).
expect(screen.getAllByText('map.overview.distanceAtLeast')).toHaveLength(2)
expect(screen.getByText('140.5 km')).toBeInTheDocument()
})
it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => {
@@ -2,7 +2,7 @@ import { AlertTriangle, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MapTogglePill } from './MapTogglePill'
import { routeModeIcon } from '../Planner/routeModes'
import { formatDistance } from '../../utils/units'
import { routeDistanceLabel } from './tripRouteLabels'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
@@ -65,7 +65,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
{/* Same tier as the label beside it: the two read as one line rather than as a
heading with a number stuck under it. */}
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
{formatDistance(overview.totalDistance / 1000, unit)}
{routeDistanceLabel(overview.totalDistance, unrouted, unit, t)}
{/* Still routing: the number is a partial sum, and saying so beats a total
that silently grows while you read it. */}
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
@@ -112,7 +112,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
/>
)}
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
{routeDistanceLabel(day.distance, dayUnrouted, unit, t)}
</span>
</>
)
@@ -78,31 +78,61 @@ describe('tripRouteGeometry', () => {
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-MAP-TRG-005: the deadline gives back what answered and leaves the rest as straight lines', async () => {
neverAnswers()
// The PDF used to route under an 8 s deadline: with a second between two requests that
// is about seven legs, so a month-long trip printed most of its days as straight lines
// and a total that looked final, and every export routed a few more days (#2876).
it('FE-MAP-TRG-005: a long trip is routed to its last leg, and says how far it has got', async () => {
const days = Array.from({ length: 12 }, (_, i) => buildDay({ id: i + 1, day_number: i + 1 }))
const assignments: AssignmentsMap = Object.fromEntries(days.map(day => [
String(day.id),
[at(48 + day.id / 10, 2.35, 0, day.id), at(48 + day.id / 10, 2.45, 1, day.id)],
]))
vi.useFakeTimers()
try {
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 8000 })
await vi.advanceTimersByTimeAsync(8100)
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
}))
const onProgress = vi.fn<(done: number, total: number) => void>()
const pending = routeTrip({ ...input, days, assignments }, { profile: 'driving', tripId: 7, onProgress })
await vi.advanceTimersByTimeAsync(12 * 1200)
const summary = await pending
// The shape of the trip survives...
expect(summary.lines).toHaveLength(2)
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
// ...and nothing that never answered is counted.
expect(summary.totalDistance).toBe(0)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12)
expect(summary.unroutedLegs).toBe(0)
expect(summary.totalDistance).toBe(120000)
expect(onProgress.mock.calls[0]).toEqual([0, 12])
expect(onProgress.mock.calls.map(([done]) => done)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(onProgress.mock.calls.every(([, total]) => total === 12)).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-006: a caller\'s own abort cuts the routing short too', async () => {
it('FE-MAP-TRG-006: a caller's own abort cuts the routing short, and what never answered is counted', async () => {
neverAnswers()
const controller = new AbortController()
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 60_000, signal: controller.signal })
const pending = routeTrip(input, { profile: 'driving', tripId: 7, signal: controller.signal })
controller.abort()
await expect(pending).resolves.toMatchObject({ totalDistance: 0 })
await expect(pending).resolves.toMatchObject({ totalDistance: 0, unroutedLegs: 2 })
})
it('FE-MAP-TRG-014: a host that never answers costs one leg its road, not the whole round', async () => {
neverAnswers()
vi.useFakeTimers()
try {
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
await vi.advanceTimersByTimeAsync(30_000)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1100 + 30_000)
const summary = await pending
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(summary.lines).toHaveLength(2)
expect(summary.unroutedLegs).toBe(2)
} finally {
vi.useRealTimers()
}
})
// The public OSRM hosts allow about one request a second. Every leg of every day at
+35 -20
View File
@@ -29,7 +29,7 @@ export interface TripOverviewDay {
modes: string[]
/**
* Legs drawn as a straight line because the router gave no road for them: not asked
* yet while the round is running, refused or cut off by the deadline once it is over.
* yet while the round is running, refused, timed out or skipped by the caller once it is over.
* Those legs add nothing to `distance`, so a day with any of them reads too short.
*/
unroutedLegs?: number
@@ -56,7 +56,7 @@ export interface TripRouteSummary {
interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] }
/** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */
interface TripRouteInputs extends DayRouteInputs {
export interface TripRouteInputs extends DayRouteInputs {
/** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */
storedAssignments?: AssignmentsMap
viasByDay?: Record<number, readonly RoadtripVia[]>
@@ -187,6 +187,15 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60
/**
* The longest one leg may take, its rate-limit retries included. The OSRM request has no
* deadline of its own, and a round that waits for every leg must not hang on a host that
* accepted the connection and never answered.
*/
const LEG_TIMEOUT_MS = 30_000
/** How far a routing round has got: legs answered or given up on, out of all it asks. */
export type TripRouteProgress = (done: number, total: number) => void
/**
* Ask the router for every leg of every day, one at a time.
@@ -206,13 +215,15 @@ const CACHE_HIT_MS = 60
*/
export async function routeTripLegs(
plan: TripRoutePlanDay[],
{ tripId, signal, onAnswer, unit }: {
{ tripId, signal, onAnswer, onProgress, unit }: {
tripId: number | null
signal?: AbortSignal
/** The unit a leg bent through vias has its text written again in. */
unit?: DistanceUnit
/** Called after each answer, for a caller that wants to draw as they arrive. */
onAnswer?: (routed: Answer[][][]) => void
/** Called once before the first leg and again after every leg, answered or not. */
onProgress?: TripRouteProgress
},
): Promise<Answer[][][]> {
const routed = emptyAnswers(plan)
@@ -222,25 +233,34 @@ export async function routeTripLegs(
chunks.forEach((chunk, c) => {
tasks.push(async () => {
if (signal?.aborted) return
const leg = new AbortController()
const abortLeg = () => leg.abort()
const timer = setTimeout(abortLeg, LEG_TIMEOUT_MS)
signal?.addEventListener('abort', abortLeg, { once: true })
try {
const answer = await calculateRouteWithRetries(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
{ signal: leg.signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
} catch {
// Refused for good, or aborted: the leg keeps its straight line.
// Refused for good, timed out or aborted: the leg keeps its straight line.
} finally {
clearTimeout(timer)
signal?.removeEventListener('abort', abortLeg)
}
})
})
})
})
onProgress?.(0, tasks.length)
for (let i = 0; i < tasks.length; i++) {
if (signal?.aborted) break
const startedAt = performance.now()
await tasks[i]()
onProgress?.(i + 1, tasks.length)
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal)
}
@@ -250,30 +270,25 @@ export async function routeTripLegs(
/**
* The whole thing end to end, for a caller with no need to draw the intermediate state.
*
* `timeoutMs` stops waiting rather than stopping the work: legs that answered are kept
* and the rest stay straight lines, which is what makes this usable from an export
* somebody is standing in front of. A month-long trip with a cold cache is a few hundred
* requests against a shared router, and no document is worth that wait.
* Waits for every leg: a cold month-long trip is a few minutes against a shared router,
* which is why `onProgress` exists, and the pacing, the retry limit and the per-leg
* timeout keep that wait finite. A document printed after a deadline looked finished
* while most of its days were straight lines (#2876). The caller's `signal` is the only
* way to stop early; the legs that answered by then are kept, the rest stay straight
* lines and are counted in `unroutedLegs`.
*/
export async function routeTrip(
input: TripRouteInputs,
{ profile, tripId, signal, timeoutMs, unit }: {
{ profile, tripId, signal, unit, onProgress }: {
profile: RouteProfileKey
tripId: number | null
signal?: AbortSignal
timeoutMs?: number
unit?: DistanceUnit
onProgress?: TripRouteProgress
},
): Promise<TripRouteSummary> {
const plan = planTripRoute(input, profile)
if (!plan.length) return summariseTripRoute([])
const deadline = timeoutMs ? new AbortController() : null
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
try {
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit })
return summariseTripRoute(assembleTripRoute(plan, routed))
} finally {
if (timer) clearTimeout(timer)
}
const routed = await routeTripLegs(plan, { tripId, signal, unit, onProgress })
return summariseTripRoute(assembleTripRoute(plan, routed))
}
@@ -0,0 +1,18 @@
import type { DistanceUnit } from '../../types';
import { formatDistance } from '../../utils/units';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* A day's or the trip's distance as the overview panel and the PDF print it.
*
* A leg the router gave no road for is drawn straight and adds nothing to the sum, so a
* figure with such legs in it is a floor, and a figure made of nothing but such legs is
* no figure at all. "0 m" for a day of driving read as a measurement (#2876). One wording
* for the screen and the document, so the two cannot drift apart again.
*/
export function routeDistanceLabel(meters: number, unroutedLegs: number, unit: DistanceUnit, t: Translate): string {
const distance = formatDistance(meters / 1000, unit);
if (unroutedLegs <= 0) return distance;
return meters > 0 ? t('map.overview.distanceAtLeast', { distance }) : t('map.overview.notRouted');
}
+70 -1
View File
@@ -20,7 +20,7 @@ vi.mock('../Map/tripRouteGeometry', async (importActual) => {
// basemap branch and the outline fallback can be exercised.
vi.mock('./tripMapImage', () => ({ renderTripMapImage: vi.fn(async () => null) }))
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
const { calculateRouteWithLegs, RoutingRefusedError } = await import('../Map/RouteCalculator')
const { renderTripMapImage } = await import('./tripMapImage')
const { routeTrip } = await import('../Map/tripRouteGeometry')
const { downloadTripPDF } = await import('./TripPDF')
@@ -324,4 +324,73 @@ describe('trip route map in the PDF', () => {
expect(html).not.toContain('pdf.distanceLabel')
})
// The export used to give the router 8 s: about seven legs at a second each, so a
// month-long trip printed most of its days as straight lines at "0 m" under a total
// that looked final, and every export routed a few more days (#2876).
it('FE-COMP-TRIPPDF-MAP-013: a long trip prints every day on the road, however long the router takes', async () => {
const days = Array.from({ length: 12 }, (_, i) =>
({ id: i + 1, day_number: i + 1, title: null, date: `2025-06-${String(i + 1).padStart(2, '0')}` }))
const assignments = Object.fromEntries(days.map(day => [String(day.id), [
assign(day.id * 10, day.id, 0, 45 + day.id / 10, 4.8),
assign(day.id * 10 + 1, day.id, 1, 45 + day.id / 10, 4.9),
]]))
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
setTimeout(() => resolve({ coordinates: [[45, 4.8], [45, 4.9]], distance: 12000, duration: 900, legs: [leg(12000)] }), 100)
}))
vi.useFakeTimers()
try {
let settled = false
const pending = downloadTripPDF({ ...args, days: days as never, assignments: assignments as never })
.finally(() => { settled = true })
for (let second = 0; second < 60 && !settled; second++) await vi.advanceTimersByTimeAsync(1000)
await pending
} finally {
vi.useRealTimers()
}
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12)
const html = srcdoc()
expect(html).toContain('144 km')
expect(html).not.toContain('map.overview.unrouted')
expect(html).not.toContain('map.overview.notRouted')
})
it('FE-COMP-TRIPPDF-MAP-014: a leg the router refused is marked, and the total says it is short', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new RoutingRefusedError(400, null))
await downloadTripPDF(args)
const html = srcdoc()
const legend = html.slice(html.indexOf('class="trip-map-legend"'))
// Day 1 drove nowhere the router would say: no "0 m", but that it was not routed...
expect(legend).toContain('<span class="trip-map-leg-dist">map.overview.notRouted</span>')
expect(legend).not.toContain('>0 m<')
// ...and the 12 km of day 2 are a floor for the trip, said as such.
expect(html).toContain('pdf.distanceLabel: map.overview.distanceAtLeast')
expect(html).toContain('<div class="trip-map-note">map.overview.unrouted</div>')
})
it('FE-COMP-TRIPPDF-MAP-015: routes what the planner map routes, and reports its progress to the caller', async () => {
const viasByDay = { 1: [{ id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.88, lng: 2.5, created_at: '' }] }
const tourStageStops = new Map()
const controller = new AbortController()
const onProgress = vi.fn<(done: number, total: number) => void>()
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
distance: 0, duration: 0,
legs: points.slice(1).map(() => leg(12000)),
}))
await downloadTripPDF({
...args,
distanceUnit: 'imperial',
route: { viasByDay, tourStageStops, signal: controller.signal, onProgress },
})
const [input, opts] = vi.mocked(routeTrip).mock.calls[0]
expect(input).toMatchObject({ viasByDay, tourStageStops, storedAssignments: args.assignments })
expect(opts).toMatchObject({ unit: 'imperial', signal: controller.signal, onProgress })
// The road picked in the road trip bends day 1 here as it does on the planner map.
expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0].map(p => [p.lat, p.lng]))
.toEqual([[48.86, 2.35], [48.88, 2.5], [48.90, 2.42]])
expect(onProgress).toHaveBeenLastCalledWith(2, 2)
})
})
+21 -50
View File
@@ -16,10 +16,8 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { planCosts } from '../Planner/planCosts'
import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry'
import { buildTripMapSvg } from './tripMapSvg'
import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units'
import { routeDistanceLabel } from '../Map/tripRouteLabels'
import { drawTripRoute, type TripPdfRouteOptions } from './tripPdfRoute'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
@@ -201,6 +199,8 @@ interface downloadTripPDFProps {
costsEnabled?: boolean
/** Prints only this member's plan (#2168): the stops and bookings that name them, and the ones that name nobody. */
onlyUserId?: number
/** The planner's road trip vias and tour stages, and the routing progress and stop (#2876). */
route?: TripPdfRouteOptions
}
/**
@@ -221,7 +221,7 @@ function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean)
// `assignments` is normalised here once, to the plan's own list; every read below
// (and fetchPlacePhotos) relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId }: downloadTripPDFProps) {
export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId, route }: downloadTripPDFProps) {
const { assignments: stored, reservations } = onlyUserId != null
? onlyMyPlan(allStored, allReservations, onlyUserId)
: { assignments: allStored, reservations: allReservations }
@@ -252,56 +252,25 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
// just means no extra sections, the core export is untouched.
const pluginSections = await pluginsApi.pdfSections(trip.id).then(r => r.sections || []).catch(() => [])
// The trip's route as one map (#1736), drawn from the same builder the planner map
// uses so the document and the screen agree. Fail-safe and time-boxed: whatever the
// router answered inside the budget is drawn, the rest stay straight lines, and any
// failure at all simply means no map rather than no PDF.
const unit: DistanceUnit = (_distanceUnit || useSettingsStore.getState().settings.distance_unit) === 'imperial'
? 'imperial' : 'metric'
let tripRoute: TripRouteSummary | null = null
const toursEnabled = useAddonStore.getState().isEnabled('tours')
const hasInvalidTour = toursEnabled && Object.values(assignments).some(list =>
projectDayItinerary(list || [], true, places).some(item => item.kind === 'tour' && !item.valid))
try {
tripRoute = await routeTrip(
{
days: sorted,
assignments,
reservations,
accommodations: accommodationList,
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
toursEnabled,
places,
},
{ profile: 'driving', tripId: trip.id, timeoutMs: 8000 },
)
} catch (err) {
// Logged rather than swallowed: a map that is silently absent looks exactly like a
// trip that has no route, and the two need different answers from whoever is
// looking. The export itself carries on — the itinerary matters more than the map.
console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err)
}
// A real basemap first — at city scale the bundled outlines are a country-sized
// blank, and only streets carry context that small. The outline map is what is left
// when there is no WebGL, no network, or a style that will not load.
const mapFrame = { width: 720, height: 420, formatDistance: (km: number) => formatDistance(km, unit) }
const tripMapSvg = tripRoute
? (await renderTripMapImage(tripRoute.days, {
...mapFrame,
style: useSettingsStore.getState().settings.maplibre_style,
})) ?? buildTripMapSvg(tripRoute.days, mapFrame)
: null
// The other way to end up mapless, and the one that is not a failure: nothing in the
// trip routed. Only worth saying when there were stops to route — a trip nobody has
// planned yet is meant to print without a map.
if (tripRoute && !tripMapSvg && Object.values(assignments).some(list => list?.length)) {
console.warn(
`[tripPdfMap] no map drawn: ${tripRoute.days.length} of ${sorted.length} day(s) produced a route.`
+ ' A day needs two located stops, or one located stop with an accommodation either side of it.',
)
}
const { route: tripRoute, mapSvg: tripMapSvg } = await drawTripRoute({
days: sorted,
assignments,
storedAssignments: allStored,
reservations,
accommodations: accommodationList,
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
toursEnabled,
places,
}, { tripId: trip.id, unit, options: route })
// Legs left straight, refused or skipped, make every figure below a floor; each says so.
const unroutedLegs = tripRoute?.unroutedLegs ?? 0
const totalDistanceLabel = tripRoute && tripRoute.totalDistance > 0
? formatDistance(tripRoute.totalDistance / 1000, unit)
? routeDistanceLabel(tripRoute.totalDistance, unroutedLegs, unit, tr)
: null
// Each day named and coloured exactly as the legend on the planner map, so the two
// read as the same picture. Days that routed to nothing are already out of `days`.
@@ -311,12 +280,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
<span class="trip-map-title">${escHtml(tr('pdf.mapTitle'))}</span>
${totalDistanceLabel ? `<span class="trip-map-total">${escHtml(tr('pdf.distanceLabel'))}: ${escHtml(totalDistanceLabel)}</span>` : ''}
</div>
${unroutedLegs ? `<div class="trip-map-note">${escHtml(tr('map.overview.unrouted', { count: unroutedLegs }))}</div>` : ''}
${tripMapSvg}
<div class="trip-map-legend">
${tripRoute.days.filter(d => d.lines.length).map(d => `<span class="trip-map-leg">
<span class="trip-map-dot" style="background:${hexColour(d.color.line)}"></span>
${escHtml(d.title || tr('dayplan.dayN', { n: d.dayNumber }))}
<span class="trip-map-leg-dist">${escHtml(formatDistance(d.distance / 1000, unit))}</span>
<span class="trip-map-leg-dist">${escHtml(routeDistanceLabel(d.distance, d.unroutedLegs ?? 0, unit, tr))}</span>
</span>`).join('')}
</div>
<div class="trip-map-credit">${escHtml(tr('pdf.mapCredit'))}</div>
@@ -744,6 +714,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
.trip-map-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.trip-map-title { font-size: 11px; font-weight: 600; letter-spacing: 1.4px; text-transform: uppercase; color: #64748b; }
.trip-map-total { font-size: 12px; font-weight: 600; color: #334155; }
.trip-map-note { font-size: 9px; font-weight: 600; margin: -4px 0 10px; }
.trip-map-svg { width: 100%; height: auto; border-radius: 8px; border: 1px solid #e2e8f0; display: block; }
.trip-map-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; }
.trip-map-leg { display: flex; align-items: center; gap: 6px; font-size: 9px; color: #475569; }
+72
View File
@@ -0,0 +1,72 @@
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit } from '../../types';
import type { ViasByDay } from '../../utils/dayRouteVias';
import { formatDistance } from '../../utils/units';
import type { TourStageStops } from '../Map/dayTourProjection';
import {
routeTrip,
type TripRouteInputs,
type TripRouteProgress,
type TripRouteSummary,
} from '../Map/tripRouteGeometry';
import { renderTripMapImage } from './tripMapImage';
import { buildTripMapSvg } from './tripMapSvg';
/** What the planner knows about the trip's route beyond the plan, and how it follows the routing. */
export interface TripPdfRouteOptions {
/** The roads picked in the road trip (#2580), so the print bends where the planner map does. */
viasByDay?: ViasByDay;
/** Where the stages of the trip's multi-day tours run. */
tourStageStops?: TourStageStops;
/** Stops routing early: the legs asked by then are printed, the rest stay straight and are marked. */
signal?: AbortSignal;
onProgress?: TripRouteProgress;
}
interface TripPdfRoute {
route: TripRouteSummary | null;
mapSvg: string | null;
}
const MAP_FRAME = { width: 720, height: 420 };
/**
* The trip's route as one map (#1736), from the builder the planner overview uses and
* fed what the overview is fed, so the document and the screen show the same roads.
*
* Every leg is waited for: a deadline here printed a month-long trip with most of its
* days as straight lines and a total that looked final (#2876). Fail-safe all the same:
* a routing failure means no map rather than no PDF.
*/
export async function drawTripRoute(
input: Omit<TripRouteInputs, 'viasByDay' | 'tourStageStops'>,
{ tripId, unit, options = {} }: { tripId: number; unit: DistanceUnit; options?: TripPdfRouteOptions }
): Promise<TripPdfRoute> {
const route = await routeTrip(
{ ...input, viasByDay: options.viasByDay, tourStageStops: options.tourStageStops },
{ profile: 'driving', tripId, unit, signal: options.signal, onProgress: options.onProgress }
).catch((err: unknown) => {
// Logged rather than swallowed: a map that is silently absent looks exactly like a
// trip that has no route, and the two need different answers from whoever is
// looking. The export itself carries on — the itinerary matters more than the map.
console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err);
return null;
});
if (!route) return { route: null, mapSvg: null };
// A real basemap first — at city scale the bundled outlines are a country-sized
// blank, and only streets carry context that small. The outline map is what is left
// when there is no WebGL, no network, or a style that will not load.
const frame = { ...MAP_FRAME, formatDistance: (km: number) => formatDistance(km, unit) };
const style = useSettingsStore.getState().settings.maplibre_style;
const mapSvg = (await renderTripMapImage(route.days, { ...frame, style })) ?? buildTripMapSvg(route.days, frame);
// The other way to end up mapless, and the one that is not a failure: nothing in the
// trip routed. Only worth saying when there were stops to route — a trip nobody has
// planned yet is meant to print without a map.
if (!mapSvg && Object.values(input.assignments).some((list) => list?.length)) {
console.warn(
`[tripPdfMap] no map drawn: ${route.days.length} of ${input.days.length} day(s) produced a route.` +
' A day needs two located stops, or one located stop with an accommodation either side of it.'
);
}
return { route, mapSvg };
}
@@ -1455,7 +1455,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
days={days}
places={places}
categories={categories}
assignments={assignments}
assignments={assignments} roadtripViasByDay={props.roadtripViasByDay} tourStageStops={props.tourStageStops}
reservations={reservations}
allConnectionsShown={allConnectionsShown}
onToggleAllConnections={onToggleAllConnections}
@@ -10,6 +10,8 @@ import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import type { TourStageStops } from '../Map/dayTourProjection'
import type { ViasByDay } from '../../utils/dayRouteVias'
interface DayPlanSidebarToolbarProps {
tripId: number
@@ -19,6 +21,9 @@ interface DayPlanSidebarToolbarProps {
categories: Category[]
assignments: AssignmentsMap
reservations: Reservation[]
/** Handed on to the export, so the PDF's route map bends where the planner's does. */
roadtripViasByDay?: ViasByDay
tourStageStops?: TourStageStops
allConnectionsShown?: boolean
onToggleAllConnections?: () => void
dayNotes: Record<string, DayNote[]>
@@ -50,7 +55,7 @@ interface DayPlanSidebarToolbarProps {
}
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
tripId, trip, days, places, categories, assignments, reservations, dayNotes, roadtripViasByDay, tourStageStops,
allConnectionsShown = false, onToggleAllConnections,
t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
@@ -95,6 +100,8 @@ export function DayPlanSidebarToolbar({
assignments={assignments}
reservations={reservations}
dayNotes={dayNotes}
roadtripViasByDay={roadtripViasByDay}
tourStageStops={tourStageStops}
t={t}
locale={locale}
toast={toast}
@@ -6,6 +6,8 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
import { SoftPill } from './planParts'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
import type { TourStageStops } from '../Map/dayTourProjection'
import type { ViasByDay } from '../../utils/dayRouteVias'
import { useTripExport } from './useTripExport'
/**
@@ -31,6 +33,9 @@ interface TripExportModalProps {
assignments: AssignmentsMap
reservations: Reservation[]
dayNotes: Record<string, DayNote[]>
/** The road trip's vias and the tour stages, so the PDF's route map matches the planner's. */
roadtripViasByDay?: ViasByDay
tourStageStops?: TourStageStops
t: (key: string, params?: Record<string, string | number>) => string
locale: string
toast: ReturnType<typeof useToast>
@@ -53,15 +58,15 @@ interface TripExportModalProps {
*/
export function TripExportModal({
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, canManageShare = true,
roadtripViasByDay, tourStageStops, t, locale, toast, canManageShare = true,
}: TripExportModalProps) {
const [subscribeOpen, setSubscribeOpen] = useState(false)
const titleId = useId()
const {
fileBase, offerMine, isRunning, anyRunning, exportPdf, downloadIcs, downloadGpx,
fileBase, offerMine, isRunning, anyRunning, exportPdf, pdfRouting, pdfRoutingLabel, skipPdfRouting, downloadIcs, downloadGpx,
} = useTripExport({
tripId,
data: { trip, days, places, assignments, categories, reservations, dayNotes },
data: { trip, days, places, assignments, categories, reservations, dayNotes, viasByDay: roadtripViasByDay, tourStageStops },
t,
locale,
toast,
@@ -94,7 +99,7 @@ export function TripExportModal({
<ExportRow
icon={FileText}
title={t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
sub={pdfRoutingLabel('pdf') ?? t('dayplan.pdfTooltip')}
busy={isRunning('pdf')}
disabled={anyRunning}
onClick={() => exportPdf()}
@@ -103,12 +108,22 @@ export function TripExportModal({
<ExportRow
icon={UserRound}
title={t('dayplan.pdfMine')}
sub={t('dayplan.pdfMineSub')}
sub={pdfRoutingLabel('pdf:mine') ?? t('dayplan.pdfMineSub')}
busy={isRunning('pdf:mine')}
disabled={anyRunning}
onClick={() => exportPdf(true)}
/>
)}
{pdfRouting && (
<button
type="button"
onClick={skipPdfRouting}
className="self-end rounded-lg px-2 py-1 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
style={fs(11.5)}
>
{t('dayplan.pdfSkipRouting')}
</button>
)}
</div>
</DialogSection>
@@ -1,4 +1,4 @@
// FE-PLANNER-TRIPEXPORT-001 to -010: the trip exports behind the desktop export
// FE-PLANNER-TRIPEXPORT-001 to -011: the trip exports behind the desktop export
// dialog and the phone's export sheet.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
@@ -238,4 +238,42 @@ describe('useTripExport', () => {
expect(opts.onExported).toHaveBeenCalledTimes(1);
expect(result.current.anyRunning).toBe(false);
});
it('FE-PLANNER-TRIPEXPORT-011: the PDF routes what the planner draws, counts its legs and can print without the rest', async () => {
const pdf = deferred();
vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise);
const viasByDay = { 7: [] };
const tourStageStops = new Map();
const opts = options({ data: { ...options().data, viasByDay, tourStageStops } });
const { result } = renderHook(() => useTripExport(opts));
let running!: Promise<void>;
act(() => {
running = result.current.exportPdf();
});
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled());
const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0];
expect(route).toMatchObject({ viasByDay, tourStageStops });
expect(result.current.pdfRouting).toBeNull();
act(() => route?.onProgress?.(0, 60));
act(() => route?.onProgress?.(23, 60));
expect(result.current.pdfRouting).toEqual({ done: 23, total: 60 });
expect(result.current.pdfRoutingLabel('pdf')).toBe('dayplan.pdfRouting');
expect(result.current.pdfRoutingLabel('pdf:mine')).toBeNull();
// Skipping stops the routing, not the export: the document still comes out.
act(() => result.current.skipPdfRouting());
expect(route?.signal?.aborted).toBe(true);
expect(result.current.pdfRouting).toBeNull();
// The leg that was already on its way still reports; the row does not come back for it.
act(() => route?.onProgress?.(24, 60));
expect(result.current.pdfRouting).toBeNull();
expect(result.current.isRunning('pdf')).toBe(true);
await act(async () => {
pdf.resolve();
await running;
});
expect(result.current.anyRunning).toBe(false);
expect(opts.onExported).toHaveBeenCalledTimes(1);
});
});
+51 -1
View File
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { AssignmentsMap, Category, Day, DayNote, Place, Reservation, Trip } from '../../types';
import { importChunk } from '../../utils/chunkReload';
import type { ViasByDay } from '../../utils/dayRouteVias';
import type { TourStageStops } from '../Map/dayTourProjection';
import { hasPersonalPlan } from '../PDF/pdfScope';
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -21,6 +23,15 @@ export interface TripExportData {
categories: Category[];
reservations: Reservation[];
dayNotes: Record<string, DayNote[]>;
/** The road trip's vias and the multi-day tour stages, so the PDF map routes what the planner map does. */
viasByDay?: ViasByDay;
tourStageStops?: TourStageStops;
}
/** How far the PDF's route map has got: legs answered or given up on, out of all of them. */
interface PdfRouting {
done: number;
total: number;
}
export interface TripExportOptions {
@@ -67,6 +78,34 @@ export function flatDayNotes(dayNotes: Record<string, DayNote[]>) {
return Object.entries(dayNotes).flatMap(([dayId, notes]) => notes.map((n) => ({ ...n, day_id: Number(dayId) })));
}
/**
* The PDF's routing round as the export rows show it. A cold month-long trip routes for
* minutes, one leg a second against the shared router, so the row counts the legs and
* offers to print without the ones still to come; those are marked in the document (#2876).
*/
function usePdfRouting() {
const [progress, setProgress] = useState<PdfRouting | null>(null);
const controller = useRef<AbortController | null>(null);
const start = () => {
const routing = new AbortController();
controller.current = routing;
// A leg already on its way still reports after a skip; the row stays quiet about it.
const onProgress = (done: number, total: number) =>
setProgress(!routing.signal.aborted && done < total ? { done, total } : null);
return { signal: routing.signal, onProgress };
};
const finish = () => {
controller.current = null;
setProgress(null);
};
const skip = () => {
controller.current?.abort();
setProgress(null);
};
return { progress, start, finish, skip };
}
/**
* Every way a trip leaves TREK, behind the desktop export dialog and the phone's
* export sheet: the day plan as a PDF (the whole plan, or my plan once somebody
@@ -88,6 +127,7 @@ export function useTripExport({
// Which export is working, per lane, so a row can say so instead of looking inert
// while a 226 kB PDF builder is fetched and a document is rendered.
const [running, setRunning] = useState<Record<string, ExportJob>>({});
const pdfRouting = usePdfRouting();
// The PDF is built outside React, so it cannot read these itself (#2066).
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h';
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
@@ -108,11 +148,16 @@ export function useTripExport({
return next;
});
/** What a PDF row says while its route map is still being routed, instead of its own line. */
const pdfRoutingLabel = (job: ExportJob) =>
isRunning(job) && pdfRouting.progress ? t('dayplan.pdfRouting', { ...pdfRouting.progress }) : null;
const exportPdf = async (mine = false) => {
const job: ExportJob = mine ? 'pdf:mine' : 'pdf';
if ((requireTrip && !data.trip) || running[laneOf(job)]) return;
begin(job);
const flatNotes = flatDayNotes(data.dayNotes);
const routing = pdfRouting.start();
try {
// Loaded on click: the PDF builder is ~226 kB, so every trip used to pay for it
// whether or not anyone exported. A missing chunk lands in the catch and shows
@@ -132,12 +177,14 @@ export function useTripExport({
distanceUnit,
showServiceStops,
onlyUserId: mine ? myId : undefined,
route: { viasByDay: data.viasByDay, tourStageStops: data.tourStageStops, ...routing },
});
if (closeAfterPdf) onExported();
} catch (e) {
if (logPdfErrors) console.error('PDF error:', e);
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`);
} finally {
pdfRouting.finish();
end(job);
}
};
@@ -187,6 +234,9 @@ export function useTripExport({
isRunning,
anyRunning: Object.keys(running).length > 0,
exportPdf,
pdfRouting: pdfRouting.progress,
pdfRoutingLabel,
skipPdfRouting: pdfRouting.skip,
downloadIcs,
downloadGpx,
};
@@ -23,7 +23,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
// applies the day plan's filter itself, so both shells print the same. The PDF stays
// open here; a calendar or GPX file closes the sheet.
const { offerMine, isRunning, exportPdf, downloadIcs, downloadGpx } = useTripExport({
const { offerMine, isRunning, exportPdf, pdfRouting, pdfRoutingLabel, skipPdfRouting, downloadIcs, downloadGpx } = useTripExport({
tripId: planner.tripId,
data: {
trip: planner.trip,
@@ -33,6 +33,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
categories: planner.categories,
reservations: planner.reservations,
dayNotes,
viasByDay: planner.roadtripVias.byDay,
tourStageStops: planner.tourStageStops,
},
t,
locale,
@@ -67,17 +69,26 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
<ExportRow
icon={FileDown}
title={isRunning('pdf') ? t('common.loading') : t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
sub={pdfRoutingLabel('pdf') ?? t('dayplan.pdfTooltip')}
onClick={() => void exportPdf()}
/>
{offerMine && (
<ExportRow
icon={UserRound}
title={isRunning('pdf:mine') ? t('common.loading') : t('dayplan.pdfMine')}
sub={t('dayplan.pdfMineSub')}
sub={pdfRoutingLabel('pdf:mine') ?? t('dayplan.pdfMineSub')}
onClick={() => void exportPdf(true)}
/>
)}
{pdfRouting && (
<button
type="button"
onClick={skipPdfRouting}
className="self-end rounded-[10px] px-2 py-1 text-caption font-semibold text-m-muted"
>
{t('dayplan.pdfSkipRouting')}
</button>
)}
<ExportRow
icon={FileDown}
title={isRunning('ics') ? t('common.loading') : t('mobileTrip.icsDownload')}
+2
View File
@@ -60,6 +60,8 @@ const dayplan: TranslationStrings = {
'dayplan.pendingRes': 'قيد الانتظار',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfMine': 'خطتي بصيغة PDF',
'dayplan.pdfRouting': 'جارٍ حساب المسارات: {done} من {total}',
'dayplan.pdfSkipRouting': 'التصدير دون المسارات المتبقية',
'dayplan.pdfMineSub': 'الأنشطة والحجوزات التي تشارك فيها فقط',
'dayplan.gpxTooltip': 'تصدير كملف GPX للخرائط دون اتصال وأجهزة GPS',
'dayplan.gpxAll': 'الرحلة كاملة',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'إخفاء الرحلة كاملة',
'map.overview.total': 'المسافة الإجمالية',
'map.attribution': 'مصادر الخريطة',
'map.overview.distanceAtLeast': '{distance} على الأقل',
'map.overview.notRouted': 'لم يُحسب المسار',
'map.overview.unrouted': 'تعذّر حساب {count} مرحلة، لذا المسافات غير مكتملة.',
'map.overview.unrouted.zero': 'لا توجد مراحل تعذّر حسابها.',
'map.overview.unrouted.one': 'تعذّر حساب مرحلة واحدة، لذا المسافات غير مكتملة.',
+2
View File
@@ -82,6 +82,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Günlük planı PDF kimi ixrac et',
'dayplan.pdfMine': 'Mənim planım PDF kimi',
'dayplan.pdfRouting': 'Marşrutlar hesablanır: {done} / {total}',
'dayplan.pdfSkipRouting': 'Qalan marşrutlar olmadan ixrac et',
'dayplan.pdfMineSub': 'Yalnız iştirak etdiyiniz fəaliyyətlər və rezervasiyalar',
'dayplan.gpxTooltip': 'Oflayn xəritələr və GPS cihazları üçün GPX kimi ixrac et',
'dayplan.gpxAll': 'Bütün səyahət',
+2
View File
@@ -40,6 +40,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Bütün səyahəti gizlət',
'map.overview.total': 'Ümumi məsafə',
'map.attribution': 'Xəritə mənbələri',
'map.overview.distanceAtLeast': 'ən azı {distance}',
'map.overview.notRouted': 'marşrut hesablanmayıb',
'map.overview.unrouted': '{count} marşrut hissəsini hesablamaq mümkün olmadığından məsafələr tam deyil.',
'map.overview.unrouted.one': '{count} marşrut hissəsini hesablamaq mümkün olmadığından məsafələr tam deyil.',
'map.overview.dayUnrouted': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
'dayplan.pdfMine': 'Meu plano em PDF',
'dayplan.pdfRouting': 'Calculando rotas: {done} de {total}',
'dayplan.pdfSkipRouting': 'Exportar sem as rotas restantes',
'dayplan.pdfMineSub': 'Somente as atividades e reservas de que você participa',
'dayplan.gpxTooltip': 'Exportar como GPX para mapas offline e aparelhos de GPS',
'dayplan.gpxAll': 'Viagem inteira',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Ocultar a viagem inteira',
'map.overview.total': 'Distância total',
'map.attribution': 'Créditos do mapa',
'map.overview.distanceAtLeast': 'pelo menos {distance}',
'map.overview.notRouted': 'rota não calculada',
'map.overview.unrouted':
'Não foi possível calcular a rota de {count} trechos, por isso as distâncias estão incompletas.',
'map.overview.unrouted.one':
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporta el pla diari com a PDF',
'dayplan.pdfMine': 'El meu pla en PDF',
'dayplan.pdfRouting': 'Calculant rutes: {done} de {total}',
'dayplan.pdfSkipRouting': 'Exporta sense les rutes restants',
'dayplan.pdfMineSub': 'Només les activitats i reserves en què participes',
'dayplan.gpxTooltip': 'Exporta com a GPX per a mapes fora de línia i dispositius GPS',
'dayplan.gpxAll': 'Tot el viatge',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Amaga tot el viatge',
'map.overview.total': 'Distància total',
'map.attribution': 'Crèdits del mapa',
'map.overview.distanceAtLeast': 'almenys {distance}',
'map.overview.notRouted': 'ruta no calculada',
'map.overview.unrouted': 'No s’han pogut calcular {count} trams, per això les distàncies són incompletes.',
'map.overview.unrouted.one': 'No s’ha pogut calcular {count} tram, per això les distàncies són incompletes.',
'map.overview.dayUnrouted': 'No s’han pogut calcular {count} trams d’aquest dia',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportovat denní plán do PDF',
'dayplan.pdfMine': 'Můj plán jako PDF',
'dayplan.pdfRouting': 'Výpočet tras: {done} z {total}',
'dayplan.pdfSkipRouting': 'Exportovat bez zbývajících tras',
'dayplan.pdfMineSub': 'Jen aktivity a rezervace, kterých se účastníte',
'dayplan.gpxTooltip': 'Exportovat jako GPX pro offline mapy a GPS zařízení',
'dayplan.gpxAll': 'Celá cesta',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Skrýt celou cestu',
'map.overview.total': 'Celková vzdálenost',
'map.attribution': 'Zdroje mapy',
'map.overview.distanceAtLeast': 'nejméně {distance}',
'map.overview.notRouted': 'trasa nevypočtena',
'map.overview.unrouted': '{count} úseků se nepodařilo spočítat, vzdálenosti jsou neúplné.',
'map.overview.unrouted.one': '{count} úsek se nepodařilo spočítat, vzdálenosti jsou neúplné.',
'map.overview.unrouted.few': '{count} úseky se nepodařilo spočítat, vzdálenosti jsou neúplné.',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
'dayplan.pdfMine': 'Mein Plan als PDF',
'dayplan.pdfRouting': 'Routen werden berechnet: {done} von {total}',
'dayplan.pdfSkipRouting': 'Ohne die restlichen Routen exportieren',
'dayplan.pdfMineSub': 'Nur die Aktivitäten und Buchungen, an denen du teilnimmst',
'dayplan.gpxTooltip': 'Als GPX für Offline-Karten und GPS-Geräte exportieren',
'dayplan.gpxAll': 'Ganze Reise',
+2
View File
@@ -37,6 +37,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Ganze Reise ausblenden',
'map.overview.total': 'Gesamtstrecke',
'map.attribution': 'Kartenquellen',
'map.overview.distanceAtLeast': 'mindestens {distance}',
'map.overview.notRouted': 'nicht berechnet',
'map.overview.unrouted': '{count} Etappen konnten nicht berechnet werden, die Entfernungen sind unvollständig.',
'map.overview.unrouted.one': '{count} Etappe konnte nicht berechnet werden, die Entfernungen sind unvollständig.',
'map.overview.dayUnrouted': '{count} Etappen dieses Tages konnten nicht berechnet werden',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Export day plan as PDF',
'dayplan.pdfMine': 'My plan as PDF',
'dayplan.pdfRouting': 'Calculating routes: {done} of {total}',
'dayplan.pdfSkipRouting': 'Export without the remaining routes',
'dayplan.pdfMineSub': 'Only the activities and bookings you take part in',
'dayplan.gpxTooltip': 'Export as GPX for offline maps and GPS devices',
'dayplan.gpxAll': 'Whole trip',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Hide whole trip',
'map.overview.total': 'Total distance',
'map.attribution': 'Map credits',
'map.overview.distanceAtLeast': 'at least {distance}',
'map.overview.notRouted': 'not routed',
'map.overview.unrouted': '{count} legs could not be routed, so the distances are incomplete.',
'map.overview.unrouted.one': '{count} leg could not be routed, so the distances are incomplete.',
'map.overview.dayUnrouted': '{count} legs of this day could not be routed',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
'dayplan.pdfMine': 'Mi plan en PDF',
'dayplan.pdfRouting': 'Calculando rutas: {done} de {total}',
'dayplan.pdfSkipRouting': 'Exportar sin las rutas restantes',
'dayplan.pdfMineSub': 'Solo las actividades y reservas en las que participas',
'dayplan.gpxTooltip': 'Exportar como GPX para mapas sin conexión y dispositivos GPS',
'dayplan.gpxAll': 'Todo el viaje',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Ocultar todo el viaje',
'map.overview.total': 'Distancia total',
'map.attribution': 'Créditos del mapa',
'map.overview.distanceAtLeast': 'al menos {distance}',
'map.overview.notRouted': 'ruta no calculada',
'map.overview.unrouted': 'No se pudieron calcular {count} tramos, así que las distancias están incompletas.',
'map.overview.unrouted.one': 'No se pudo calcular {count} tramo, así que las distancias están incompletas.',
'map.overview.dayUnrouted': 'No se pudieron calcular {count} tramos de este día',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Ekspordi päevaplaan PDF-ina',
'dayplan.pdfMine': 'Minu plaan PDF-ina',
'dayplan.pdfRouting': 'Marsruutide arvutamine: {done}/{total}',
'dayplan.pdfSkipRouting': 'Ekspordi ilma ülejäänud marsruutideta',
'dayplan.pdfMineSub': 'Ainult tegevused ja broneeringud, milles osaled',
'dayplan.gpxTooltip': 'Ekspordi GPX-ina võrguühenduseta kaartide ja GPS-seadmete jaoks',
'dayplan.gpxAll': 'Kogu reis',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Peida kogu reis',
'map.overview.total': 'Kogupikkus',
'map.attribution': 'Kaardi allikad',
'map.overview.distanceAtLeast': 'vähemalt {distance}',
'map.overview.notRouted': 'marsruut arvutamata',
'map.overview.unrouted': '{count} lõigu marsruuti ei saanud arvutada, seega on vahemaad puudulikud.',
'map.overview.unrouted.one': '{count} lõigu marsruuti ei saanud arvutada, seega on vahemaad puudulikud.',
'map.overview.dayUnrouted': 'Selle päeva {count} lõigu marsruuti ei saanud arvutada',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
'dayplan.pdfMine': 'Mon plan en PDF',
'dayplan.pdfRouting': 'Calcul des itinéraires : {done} sur {total}',
'dayplan.pdfSkipRouting': 'Exporter sans les itinéraires restants',
'dayplan.pdfMineSub': 'Uniquement les activités et réservations auxquelles vous participez',
'dayplan.gpxTooltip': 'Exporter en GPX pour les cartes hors ligne et les GPS',
'dayplan.gpxAll': 'Tout le voyage',
+2
View File
@@ -36,6 +36,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Masquer tout le voyage',
'map.overview.total': 'Distance totale',
'map.attribution': 'Crédits de la carte',
'map.overview.distanceAtLeast': 'au moins {distance}',
'map.overview.notRouted': 'itinéraire non calculé',
'map.overview.unrouted': '{count} étapes n’ont pas pu être calculées, les distances sont donc incomplètes.',
'map.overview.unrouted.one': '{count} étape n’a pas pu être calculée, les distances sont donc incomplètes.',
'map.overview.dayUnrouted': '{count} étapes de cette journée n’ont pas pu être calculées',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Εξαγωγή ημερήσιου πλάνου ως PDF',
'dayplan.pdfMine': 'Το πλάνο μου σε PDF',
'dayplan.pdfRouting': 'Υπολογισμός διαδρομών: {done} από {total}',
'dayplan.pdfSkipRouting': 'Εξαγωγή χωρίς τις υπόλοιπες διαδρομές',
'dayplan.pdfMineSub': 'Μόνο οι δραστηριότητες και οι κρατήσεις στις οποίες συμμετέχετε',
'dayplan.gpxTooltip': 'Εξαγωγή ως GPX για χάρτες εκτός σύνδεσης και συσκευές GPS',
'dayplan.gpxAll': 'Όλο το ταξίδι',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού',
'map.overview.total': 'Συνολική απόσταση',
'map.attribution': 'Πηγές χάρτη',
'map.overview.distanceAtLeast': 'τουλάχιστον {distance}',
'map.overview.notRouted': 'χωρίς υπολογισμό διαδρομής',
'map.overview.unrouted': '{count} σκέλη δεν μπόρεσαν να υπολογιστούν, οπότε οι αποστάσεις είναι ελλιπείς.',
'map.overview.unrouted.one': '{count} σκέλος δεν μπόρεσε να υπολογιστεί, οπότε οι αποστάσεις είναι ελλιπείς.',
'map.overview.dayUnrouted': '{count} σκέλη αυτής της ημέρας δεν μπόρεσαν να υπολογιστούν',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Napi terv exportálása PDF-be',
'dayplan.pdfMine': 'Saját tervem PDF-ként',
'dayplan.pdfRouting': 'Útvonalak számítása: {done}/{total}',
'dayplan.pdfSkipRouting': 'Exportálás a hátralévő útvonalak nélkül',
'dayplan.pdfMineSub': 'Csak azok a programok és foglalások, amelyekben részt vesz',
'dayplan.gpxTooltip': 'Exportálás GPX-ként offline térképekhez és GPS-eszközökhöz',
'dayplan.gpxAll': 'A teljes utazás',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Teljes utazás elrejtése',
'map.overview.total': 'Teljes távolság',
'map.attribution': 'Térkép forrásai',
'map.overview.distanceAtLeast': 'legalább {distance}',
'map.overview.notRouted': 'nincs kiszámítva',
'map.overview.unrouted': '{count} szakaszt nem sikerült kiszámítani, ezért a távolságok hiányosak.',
'map.overview.unrouted.one': '{count} szakaszt nem sikerült kiszámítani, ezért a távolságok hiányosak.',
'map.overview.dayUnrouted': 'Ennek a napnak {count} szakaszát nem sikerült kiszámítani',
+2
View File
@@ -67,6 +67,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Ekspor rencana hari sebagai PDF',
'dayplan.pdfMine': 'Rencanaku sebagai PDF',
'dayplan.pdfRouting': 'Menghitung rute: {done} dari {total}',
'dayplan.pdfSkipRouting': 'Ekspor tanpa rute yang tersisa',
'dayplan.pdfMineSub': 'Hanya aktivitas dan pemesanan yang kamu ikuti',
'dayplan.gpxTooltip': 'Ekspor sebagai GPX untuk peta offline dan perangkat GPS',
'dayplan.gpxAll': 'Seluruh perjalanan',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Sembunyikan seluruh perjalanan',
'map.overview.total': 'Jarak total',
'map.attribution': 'Kredit peta',
'map.overview.distanceAtLeast': 'minimal {distance}',
'map.overview.notRouted': 'rute belum dihitung',
'map.overview.unrouted': 'Rute untuk {count} ruas tidak dapat dihitung, jadi jaraknya belum lengkap.',
'map.overview.dayUnrouted': 'Rute untuk {count} ruas pada hari ini tidak dapat dihitung',
'map.baseLayer.topo': 'Topografi',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Esporta il programma del giorno come PDF',
'dayplan.pdfMine': 'Il mio programma in PDF',
'dayplan.pdfRouting': 'Calcolo dei percorsi: {done} di {total}',
'dayplan.pdfSkipRouting': 'Esporta senza i percorsi rimanenti',
'dayplan.pdfMineSub': 'Solo le attività e le prenotazioni a cui partecipi',
'dayplan.gpxTooltip': 'Esporta in GPX per mappe offline e dispositivi GPS',
'dayplan.gpxAll': 'Tutto il viaggio',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Nascondi tutto il viaggio',
'map.overview.total': 'Distanza totale',
'map.attribution': 'Crediti della mappa',
'map.overview.distanceAtLeast': 'almeno {distance}',
'map.overview.notRouted': 'percorso non calcolato',
'map.overview.unrouted':
'Per {count} tratte non è stato possibile calcolare il percorso, quindi le distanze sono incomplete.',
'map.overview.unrouted.one':
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '日別計画をPDFで書き出し',
'dayplan.pdfMine': '自分の計画をPDFで',
'dayplan.pdfRouting': 'ルートを計算中: {done}/{total}',
'dayplan.pdfSkipRouting': '残りのルートなしでエクスポート',
'dayplan.pdfMineSub': '自分が参加するアクティビティと予約のみ',
'dayplan.gpxTooltip': 'オフライン地図やGPS機器向けにGPXで書き出す',
'dayplan.gpxAll': '旅行全体',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': '旅程全体を非表示',
'map.overview.total': '合計距離',
'map.attribution': '地図のクレジット',
'map.overview.distanceAtLeast': '{distance}以上',
'map.overview.notRouted': 'ルート未計算',
'map.overview.unrouted': '{count}区間のルートを計算できなかったため、距離は不完全です。',
'map.overview.dayUnrouted': 'この日の{count}区間のルートを計算できませんでした',
'map.baseLayer.topo': '地形図',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '일별 계획을 PDF로 내보내기',
'dayplan.pdfMine': '내 계획을 PDF로',
'dayplan.pdfRouting': '경로 계산 중: {done}/{total}',
'dayplan.pdfSkipRouting': '남은 경로 없이 내보내기',
'dayplan.pdfMineSub': '내가 참여하는 활동과 예약만',
'dayplan.gpxTooltip': '오프라인 지도와 GPS 기기용 GPX로 내보내기',
'dayplan.gpxAll': '여행 전체',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': '전체 여행 숨기기',
'map.overview.total': '총 거리',
'map.attribution': '지도 출처',
'map.overview.distanceAtLeast': '최소 {distance}',
'map.overview.notRouted': '경로 미계산',
'map.overview.unrouted': '{count}개 구간의 경로를 계산할 수 없어 거리가 불완전합니다.',
'map.overview.dayUnrouted': '이 날의 {count}개 구간 경로를 계산할 수 없습니다',
'map.baseLayer.topo': '지형도',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
'dayplan.pdfMine': 'Mijn plan als PDF',
'dayplan.pdfRouting': 'Routes berekenen: {done} van {total}',
'dayplan.pdfSkipRouting': 'Exporteren zonder de resterende routes',
'dayplan.pdfMineSub': 'Alleen de activiteiten en boekingen waaraan je deelneemt',
'dayplan.gpxTooltip': 'Exporteren als GPX voor offline kaarten en gps-apparaten',
'dayplan.gpxAll': 'Hele reis',
+2
View File
@@ -36,6 +36,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Hele reis verbergen',
'map.overview.total': 'Totale afstand',
'map.attribution': 'Kaartvermeldingen',
'map.overview.distanceAtLeast': 'minstens {distance}',
'map.overview.notRouted': 'niet berekend',
'map.overview.unrouted': '{count} etappes konden niet worden berekend, dus de afstanden zijn onvolledig.',
'map.overview.unrouted.one': '{count} etappe kon niet worden berekend, dus de afstanden zijn onvolledig.',
'map.overview.dayUnrouted': '{count} etappes van deze dag konden niet worden berekend',
+2
View File
@@ -68,6 +68,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Eksportuj plan dnia jako PDF',
'dayplan.pdfMine': 'Mój plan jako PDF',
'dayplan.pdfRouting': 'Obliczanie tras: {done} z {total}',
'dayplan.pdfSkipRouting': 'Eksportuj bez pozostałych tras',
'dayplan.pdfMineSub': 'Tylko aktywności i rezerwacje, w których bierzesz udział',
'dayplan.gpxTooltip': 'Eksportuj jako GPX do map offline i urządzeń GPS',
'dayplan.gpxAll': 'Cała podróż',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Ukryj całą podróż',
'map.overview.total': 'Łączny dystans',
'map.attribution': 'Źródła mapy',
'map.overview.distanceAtLeast': 'co najmniej {distance}',
'map.overview.notRouted': 'trasa nieobliczona',
'map.overview.unrouted': 'Nie udało się wyznaczyć {count} odcinka, więc odległości są niepełne.',
'map.overview.unrouted.one': 'Nie udało się wyznaczyć {count} odcinka, więc odległości są niepełne.',
'map.overview.unrouted.few': 'Nie udało się wyznaczyć {count} odcinków, więc odległości są niepełne.',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
'dayplan.pdfMine': 'Мой план в PDF',
'dayplan.pdfRouting': 'Расчёт маршрутов: {done} из {total}',
'dayplan.pdfSkipRouting': 'Экспортировать без оставшихся маршрутов',
'dayplan.pdfMineSub': 'Только занятия и бронирования, в которых вы участвуете',
'dayplan.gpxTooltip': 'Экспорт в GPX для офлайн-карт и GPS-устройств',
'dayplan.gpxAll': 'Вся поездка',
+2
View File
@@ -36,6 +36,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Скрыть всю поездку',
'map.overview.total': 'Общее расстояние',
'map.attribution': 'Источники карты',
'map.overview.distanceAtLeast': 'не менее {distance}',
'map.overview.notRouted': 'маршрут не рассчитан',
'map.overview.unrouted': 'Не удалось построить маршрут для {count} участка, поэтому расстояния неполные.',
'map.overview.unrouted.one': 'Не удалось построить маршрут для {count} участка, поэтому расстояния неполные.',
'map.overview.unrouted.few': 'Не удалось построить маршрут для {count} участков, поэтому расстояния неполные.',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportovať denný plán do PDF',
'dayplan.pdfMine': 'Môj plán ako PDF',
'dayplan.pdfRouting': 'Výpočet trás: {done} z {total}',
'dayplan.pdfSkipRouting': 'Exportovať bez zostávajúcich trás',
'dayplan.pdfMineSub': 'Len aktivity a rezervácie, na ktorých sa zúčastňujete',
'dayplan.gpxTooltip': 'Exportovať ako GPX pre offline mapy a GPS zariadenia',
'dayplan.gpxAll': 'Celá cesta',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Skryť celú cestu',
'map.overview.total': 'Celková vzdialenosť',
'map.attribution': 'Zdroje mapy',
'map.overview.distanceAtLeast': 'najmenej {distance}',
'map.overview.notRouted': 'trasa nevypočítaná',
'map.overview.unrouted': 'Trasu pre {count} úsekov sa nepodarilo vypočítať, takže vzdialenosti sú neúplné.',
'map.overview.unrouted.one': 'Trasu pre {count} úsek sa nepodarilo vypočítať, takže vzdialenosti sú neúplné.',
'map.overview.unrouted.few': 'Trasu pre {count} úseky sa nepodarilo vypočítať, takže vzdialenosti sú neúplné.',
+2
View File
@@ -70,6 +70,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportera dagsplanen som PDF',
'dayplan.pdfMine': 'Min plan som PDF',
'dayplan.pdfRouting': 'Beräknar rutter: {done} av {total}',
'dayplan.pdfSkipRouting': 'Exportera utan återstående rutter',
'dayplan.pdfMineSub': 'Bara aktiviteter och bokningar du deltar i',
'dayplan.gpxTooltip': 'Exportera som GPX för offlinekartor och GPS-enheter',
'dayplan.gpxAll': 'Hela resan',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Dölj hela resan',
'map.overview.total': 'Total sträcka',
'map.attribution': 'Kartkällor',
'map.overview.distanceAtLeast': 'minst {distance}',
'map.overview.notRouted': 'ej beräknad',
'map.overview.unrouted': '{count} delsträckor kunde inte beräknas, så avstånden är ofullständiga.',
'map.overview.unrouted.one': '{count} delsträcka kunde inte beräknas, så avstånden är ofullständiga.',
'map.overview.dayUnrouted': '{count} delsträckor för den här dagen kunde inte beräknas',
+2
View File
@@ -68,6 +68,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'ส่งออกแผนวันเป็น PDF',
'dayplan.pdfMine': 'แผนของฉันเป็น PDF',
'dayplan.pdfRouting': 'กำลังคำนวณเส้นทาง: {done} จาก {total}',
'dayplan.pdfSkipRouting': 'ส่งออกโดยไม่มีเส้นทางที่เหลือ',
'dayplan.pdfMineSub': 'เฉพาะกิจกรรมและการจองที่คุณเข้าร่วม',
'dayplan.gpxTooltip': 'ส่งออกเป็น GPX สำหรับแผนที่ออฟไลน์และอุปกรณ์ GPS',
'dayplan.gpxAll': 'ทั้งทริป',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'ซ่อนทั้งทริป',
'map.overview.total': 'ระยะทางรวม',
'map.attribution': 'เครดิตแผนที่',
'map.overview.distanceAtLeast': 'อย่างน้อย {distance}',
'map.overview.notRouted': 'ยังไม่ได้คำนวณเส้นทาง',
'map.overview.unrouted': 'มี {count} ช่วงที่คำนวณเส้นทางไม่ได้ ระยะทางจึงไม่ครบถ้วน',
'map.overview.dayUnrouted': 'มี {count} ช่วงของวันนี้ที่คำนวณเส้นทางไม่ได้',
'map.baseLayer.topo': 'ภูมิประเทศ',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': "PDF'ler",
'dayplan.pdfTooltip': 'Gün planını PDF olarak dışa aktar',
'dayplan.pdfMine': 'Planım PDF olarak',
'dayplan.pdfRouting': 'Rotalar hesaplanıyor: {done}/{total}',
'dayplan.pdfSkipRouting': 'Kalan rotalar olmadan dışa aktar',
'dayplan.pdfMineSub': 'Yalnızca katıldığın etkinlikler ve rezervasyonlar',
'dayplan.gpxTooltip': 'Çevrimdışı haritalar ve GPS cihazları için GPX olarak dışa aktar',
'dayplan.gpxAll': 'Tüm gezi',
+2
View File
@@ -34,6 +34,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Tüm geziyi gizle',
'map.overview.total': 'Toplam mesafe',
'map.attribution': 'Harita kaynakları',
'map.overview.distanceAtLeast': 'en az {distance}',
'map.overview.notRouted': 'rota hesaplanmadı',
'map.overview.unrouted': '{count} etap hesaplanamadı, bu yüzden mesafeler eksik.',
'map.overview.unrouted.one': '{count} etap hesaplanamadı, bu yüzden mesafeler eksik.',
'map.overview.dayUnrouted': 'Bu günün {count} etabı hesaplanamadı',
+2
View File
@@ -60,6 +60,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Експортувати план дня в PDF',
'dayplan.pdfMine': 'Мій план у PDF',
'dayplan.pdfRouting': 'Розрахунок маршрутів: {done} з {total}',
'dayplan.pdfSkipRouting': 'Експортувати без решти маршрутів',
'dayplan.pdfMineSub': 'Лише активності та бронювання, у яких ви берете участь',
'dayplan.gpxTooltip': 'Експорт у GPX для офлайн-карт і GPS-пристроїв',
'dayplan.gpxAll': 'Уся подорож',
+2
View File
@@ -35,6 +35,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Сховати всю подорож',
'map.overview.total': 'Загальна відстань',
'map.attribution': 'Джерела карти',
'map.overview.distanceAtLeast': 'щонайменше {distance}',
'map.overview.notRouted': 'маршрут не розраховано',
'map.overview.unrouted': 'Не вдалося прокласти {count} відрізка, тому відстані неповні.',
'map.overview.unrouted.one': 'Не вдалося прокласти {count} відрізок, тому відстані неповні.',
'map.overview.unrouted.few': 'Не вдалося прокласти {count} відрізки, тому відстані неповні.',
+2
View File
@@ -69,6 +69,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Kế hoạch ngày xuất dưới dạng PDF',
'dayplan.pdfMine': 'Kế hoạch của tôi dạng PDF',
'dayplan.pdfRouting': 'Đang tính tuyến đường: {done}/{total}',
'dayplan.pdfSkipRouting': 'Xuất mà không có các tuyến còn lại',
'dayplan.pdfMineSub': 'Chỉ các hoạt động và đặt chỗ bạn tham gia',
'dayplan.gpxTooltip': 'Xuất ra GPX cho bản đồ ngoại tuyến và thiết bị GPS',
'dayplan.gpxAll': 'Toàn bộ chuyến đi',
+2
View File
@@ -37,6 +37,8 @@ const map: TranslationStrings = {
'map.overview.hide': 'Ẩn toàn bộ chuyến đi',
'map.overview.total': 'Tổng quãng đường',
'map.attribution': 'Nguồn bản đồ',
'map.overview.distanceAtLeast': 'ít nhất {distance}',
'map.overview.notRouted': 'chưa tính tuyến',
'map.overview.unrouted': 'Không thể tính đường đi cho {count} chặng, nên quãng đường chưa đầy đủ.',
'map.overview.dayUnrouted': 'Không thể tính đường đi cho {count} chặng của ngày này',
'map.baseLayer.topo': 'Địa hình',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '匯出當天計劃為 PDF',
'dayplan.pdfMine': '我的計劃 PDF',
'dayplan.pdfRouting': '正在計算路線:{done}/{total}',
'dayplan.pdfSkipRouting': '匯出時略過剩餘路線',
'dayplan.pdfMineSub': '只包含你參與的活動與預訂',
'dayplan.gpxTooltip': '匯出為 GPX,用於離線地圖和 GPS 裝置',
'dayplan.gpxAll': '整趟行程',
+2
View File
@@ -33,6 +33,8 @@ const map: TranslationStrings = {
'map.overview.hide': '隱藏整趟行程',
'map.overview.total': '總距離',
'map.attribution': '地圖來源',
'map.overview.distanceAtLeast': '至少 {distance}',
'map.overview.notRouted': '未計算路線',
'map.overview.unrouted': '有 {count} 段無法計算路線,因此距離不完整。',
'map.overview.dayUnrouted': '本日有 {count} 段無法計算路線',
'map.baseLayer.topo': '地形圖',
+2
View File
@@ -58,6 +58,8 @@ const dayplan: TranslationStrings = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '导出当天计划为 PDF',
'dayplan.pdfMine': '我的计划 PDF',
'dayplan.pdfRouting': '正在计算路线:{done}/{total}',
'dayplan.pdfSkipRouting': '导出时跳过剩余路线',
'dayplan.pdfMineSub': '仅包含你参与的活动和预订',
'dayplan.gpxTooltip': '导出为 GPX,用于离线地图和 GPS 设备',
'dayplan.gpxAll': '整趟行程',
+2
View File
@@ -33,6 +33,8 @@ const map: TranslationStrings = {
'map.overview.hide': '隐藏整个行程',
'map.overview.total': '总距离',
'map.attribution': '地图来源',
'map.overview.distanceAtLeast': '至少 {distance}',
'map.overview.notRouted': '未计算路线',
'map.overview.unrouted': '有 {count} 段无法计算路线,因此距离不完整。',
'map.overview.dayUnrouted': '本日有 {count} 段无法计算路线',
'map.baseLayer.topo': '地形图',