mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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': 'الرحلة كاملة',
|
||||
|
||||
@@ -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': 'تعذّر حساب مرحلة واحدة، لذا المسافات غير مكتملة.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Όλο το ταξίδι',
|
||||
|
||||
@@ -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} σκέλη αυτής της ημέρας δεν μπόρεσαν να υπολογιστούν',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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': '旅行全体',
|
||||
|
||||
@@ -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': '地形図',
|
||||
|
||||
@@ -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': '여행 전체',
|
||||
|
||||
@@ -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': '지형도',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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óż',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Вся поездка',
|
||||
|
||||
@@ -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} участков, поэтому расстояния неполные.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ทั้งทริป',
|
||||
|
||||
@@ -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': 'ภูมิประเทศ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -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': 'Уся подорож',
|
||||
|
||||
@@ -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} відрізки, тому відстані неповні.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '整趟行程',
|
||||
|
||||
@@ -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': '地形圖',
|
||||
|
||||
@@ -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': '整趟行程',
|
||||
|
||||
@@ -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': '地形图',
|
||||
|
||||
Reference in New Issue
Block a user