From 48cd46f818edd0c1c64187be19780304e6dd64e6 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Fri, 11 Sep 2026 15:59:27 +0300 Subject: [PATCH] feat(pdf): put the trip's route map in the export --- .../components/Map/tripRouteGeometry.test.ts | 107 +++++++++ .../src/components/Map/tripRouteGeometry.ts | 215 ++++++++++++++++++ .../components/Map/useTripRouteOverview.ts | 194 ++-------------- client/src/components/PDF/TripPDF.map.test.ts | 213 +++++++++++++++++ client/src/components/PDF/TripPDF.tsx | 110 ++++++++- .../src/components/PDF/tripMapImage.test.ts | 47 ++++ client/src/components/PDF/tripMapImage.ts | 108 +++++++++ client/src/components/PDF/tripMapSvg.test.ts | 80 +++++++ client/src/components/PDF/tripMapSvg.ts | 175 ++++++++++++++ .../components/Planner/TripExportModal.tsx | 3 +- .../screens/trip/sheets/MExportSheet.tsx | 2 + shared/src/i18n/ar/pdf.ts | 3 + shared/src/i18n/br/pdf.ts | 3 + shared/src/i18n/ca/pdf.ts | 3 + shared/src/i18n/cs/pdf.ts | 3 + shared/src/i18n/de/pdf.ts | 3 + shared/src/i18n/en/pdf.ts | 3 + shared/src/i18n/es/pdf.ts | 3 + shared/src/i18n/fr/pdf.ts | 3 + shared/src/i18n/gr/pdf.ts | 3 + shared/src/i18n/hu/pdf.ts | 3 + shared/src/i18n/id/pdf.ts | 3 + shared/src/i18n/it/pdf.ts | 3 + shared/src/i18n/ja/pdf.ts | 3 + shared/src/i18n/ko/pdf.ts | 3 + shared/src/i18n/nl/pdf.ts | 3 + shared/src/i18n/pl/pdf.ts | 3 + shared/src/i18n/ru/pdf.ts | 3 + shared/src/i18n/sv/pdf.ts | 3 + shared/src/i18n/tr/pdf.ts | 3 + shared/src/i18n/uk/pdf.ts | 3 + shared/src/i18n/vi/pdf.ts | 3 + shared/src/i18n/zh-TW/pdf.ts | 3 + shared/src/i18n/zh/pdf.ts | 3 + wiki/PDF-Export.md | 11 + 35 files changed, 1161 insertions(+), 173 deletions(-) create mode 100644 client/src/components/Map/tripRouteGeometry.test.ts create mode 100644 client/src/components/Map/tripRouteGeometry.ts create mode 100644 client/src/components/PDF/TripPDF.map.test.ts create mode 100644 client/src/components/PDF/tripMapImage.test.ts create mode 100644 client/src/components/PDF/tripMapImage.ts create mode 100644 client/src/components/PDF/tripMapSvg.test.ts create mode 100644 client/src/components/PDF/tripMapSvg.ts diff --git a/client/src/components/Map/tripRouteGeometry.test.ts b/client/src/components/Map/tripRouteGeometry.test.ts new file mode 100644 index 000000000..b082983ae --- /dev/null +++ b/client/src/components/Map/tripRouteGeometry.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { dayColor } from '../Roadtrip/dayColors' +import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry' +import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' +import type { AssignmentsMap, RouteSegment } from '../../types' + +vi.mock('./RouteCalculator', async (importActual) => { + const actual = await importActual() + return { ...actual, calculateRouteWithLegs: vi.fn() } +}) + +const { calculateRouteWithLegs } = await import('./RouteCalculator') + +const leg = (distance: number): RouteSegment => ({ + mid: [0, 0], from: [0, 0], to: [0, 0], distance, duration: 600, + distanceText: '10 km', durationText: '10 min', walkingText: '2 h', drivingText: '10 min', +}) + +/** A router that never answers but honours its signal, exactly as a real fetch does — + * without that the abort has nothing to cut and the pool waits forever. */ +const neverAnswers = () => vi.mocked(calculateRouteWithLegs).mockImplementation( + (_waypoints, opts) => new Promise((_resolve, reject) => { + opts?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }) as ReturnType, +) + +const at = (lat: number, lng: number, order: number, dayId: number) => + buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }) }) + +const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })] +const ASSIGNMENTS: AssignmentsMap = { + '1': [at(48.86, 2.35, 0, 1), at(48.90, 2.42, 1, 1)], + '2': [at(45.76, 4.83, 0, 2), at(45.80, 4.90, 1, 2)], +} +const input = { days: DAYS, assignments: ASSIGNMENTS, reservations: [], accommodations: [], optimizeFromAccommodation: false } + +beforeEach(() => { + vi.mocked(calculateRouteWithLegs).mockReset() + vi.mocked(calculateRouteWithLegs).mockResolvedValue({ + coordinates: [[48.86, 2.35], [48.90, 2.42]], + distance: 10000, duration: 600, + legs: [leg(10000)], + }) +}) + +describe('tripRouteGeometry', () => { + it('FE-MAP-TRG-001: plans one request per day and skips days with nothing to drive', () => { + const plan = planTripRoute({ ...input, assignments: { ...ASSIGNMENTS, '2': [at(45.76, 4.83, 0, 2)] } }, 'driving') + + expect(plan).toHaveLength(1) + expect(plan[0].day.id).toBe(1) + expect(plan[0].runs[0]).toHaveLength(1) + }) + + it('FE-MAP-TRG-002: a day is drawn in the colour the road trip gives it', () => { + // One palette for both readings of the trip, so a day never changes colour. + expect(dayRouteColor(buildDay({ day_number: 1 }))).toEqual(dayColor(1)) + expect(dayRouteColor(buildDay({ day_number: 2 }))).toEqual(dayColor(2)) + }) + + it('FE-MAP-TRG-003: routes every day and sums the trip', async () => { + const summary = await routeTrip(input, { profile: 'driving', tripId: 7 }) + + expect(summary.days).toHaveLength(2) + expect(summary.totalDistance).toBe(20000) + expect(summary.lines).toHaveLength(2) + expect(summary.lineColors).toHaveLength(2) + }) + + it('FE-MAP-TRG-004: an empty trip summarises to nothing rather than throwing', async () => { + const summary = await routeTrip({ ...input, assignments: {} }, { profile: 'driving', tripId: 7 }) + + expect(summary).toEqual(summariseTripRoute([])) + expect(calculateRouteWithLegs).not.toHaveBeenCalled() + }) + + it('FE-MAP-TRG-005: the deadline gives back what answered and leaves the rest as straight lines', async () => { + neverAnswers() + vi.useFakeTimers() + try { + const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 8000 }) + await vi.advanceTimersByTimeAsync(8100) + 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) + } finally { + vi.useRealTimers() + } + }) + + it('FE-MAP-TRG-006: a caller\'s own abort cuts the routing short too', async () => { + neverAnswers() + const controller = new AbortController() + const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 60_000, signal: controller.signal }) + controller.abort() + + await expect(pending).resolves.toMatchObject({ totalDistance: 0 }) + }) +}) diff --git a/client/src/components/Map/tripRouteGeometry.ts b/client/src/components/Map/tripRouteGeometry.ts new file mode 100644 index 000000000..b48405f01 --- /dev/null +++ b/client/src/components/Map/tripRouteGeometry.ts @@ -0,0 +1,215 @@ +import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator' +import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan' +import { resolveLegMode } from '../Planner/legMode' +import { dayColor } from '../Roadtrip/dayColors' +import type { Day, RouteSegment } from '../../types' + +/** One travel day of the overview: the roads it covers, in its own colour. */ +export interface TripOverviewDay { + dayId: number + dayNumber: number + date: string | null + title: string | null + /** The core and casing this day is drawn in — the road trip's palette, so a day is + * the same colour whichever way the trip is being read. */ + color: { line: string; casing: string } + /** One polyline per run of the day, `[lat, lng]`. */ + lines: [number, number][][] + segments: RouteSegment[] + /** Metres and seconds, summed over the day's legs. */ + distance: number + duration: number + /** The modes this day is actually travelled in, first use first. */ + modes: string[] +} + +export interface TripRouteSummary { + days: TripOverviewDay[] + /** Every day's polylines flattened in trip order — what the map draws. */ + lines: [number, number][][] + /** The colour of each entry of `lines`, same index. */ + lineColors: { line: string; casing: string }[] + segments: RouteSegment[] + /** Every drawn coordinate, so a map can frame the whole trip at once. */ + focusPoints: [number, number][] + totalDistance: number + totalDuration: number +} + +/** Neighbouring legs of one run that resolve to the same mode travel as one request, + * exactly as the single-day route builds them. */ +interface Chunk { points: DayRoutePoint[]; mode: string } + +/** A day reduced to the routing requests it needs, in the order they draw. */ +export interface TripRoutePlanDay { day: Day; runs: Chunk[][] } + +type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null + +/** The palette entry a day keeps, whatever else is added to the trip around it. */ +export const dayRouteColor = (day: Day): { line: string; casing: string } => + dayColor(day.day_number ?? 0) + +function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] { + const chunks: Chunk[] = [] + let i = 0 + while (i < run.length - 1) { + const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode) + let end = i + 1 + while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++ + chunks.push({ points: run.slice(i, end + 1), mode }) + i = end + } + return chunks +} + +const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng]) + +/** + * Every travel day of the trip reduced to the routing requests it needs. + * + * Pure and synchronous, so a caller can compare two plans (the map keys its routing + * round on one) before spending a single request. + */ +export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] { + return [...input.days] + .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) + .map(day => ({ day, runs: buildDayRouteRuns(day.id, input) })) + .filter(entry => entry.runs.length > 0) + .map(({ day, runs }) => ({ + day, + runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)), + })) +} + +/** What a plan looks like before any leg has answered: straight lines, no distances. */ +export const emptyAnswers = (plan: TripRoutePlanDay[]): Answer[][][] => + plan.map(({ runs }) => runs.map(chunks => chunks.map(() => null))) + +/** Stitch the routed answers back onto the plan, in the order the days are travelled. */ +export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]): TripOverviewDay[] { + return plan.map(({ day, runs }, d) => { + const lines: [number, number][][] = [] + const segments: RouteSegment[] = [] + runs.forEach((chunks, r) => { + const polyline: [number, number][] = [] + chunks.forEach((chunk, c) => { + const answer = routed[d]?.[r]?.[c] + const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points) + for (const point of coords) { + // Drop the point shared with the previous chunk so concatenated legs + // don't leave a duplicate at each junction. + const last = polyline[polyline.length - 1] + if (last && last[0] === point[0] && last[1] === point[1]) continue + polyline.push(point) + } + if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode }) + }) + if (polyline.length >= 2) lines.push(polyline) + }) + const modes: string[] = [] + for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode) + return { + dayId: day.id, + dayNumber: day.day_number ?? 0, + date: day.date ?? null, + title: day.title ?? null, + color: dayRouteColor(day), + lines, + segments, + distance: segments.reduce((sum, s) => sum + s.distance, 0), + duration: segments.reduce((sum, s) => sum + s.duration, 0), + modes, + } + }) +} + +export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary { + const lines = days.flatMap(d => d.lines) + return { + days, + lines, + lineColors: days.flatMap(d => d.lines.map(() => d.color)), + segments: days.flatMap(d => d.segments), + focusPoints: lines.flat(), + totalDistance: days.reduce((sum, d) => sum + d.distance, 0), + totalDuration: days.reduce((sum, d) => sum + d.duration, 0), + } +} + +/** + * Ask the router for every leg of every day, a few at a time. + * + * A fortnight is dozens of legs and the routing host is usually a shared OSRM, so the + * pool is deliberately small — the same one the sidebar connectors use. RouteCalculator's + * cache is shared with the day route, so a day already drawn on screen costs nothing. + * + * A leg the router refuses keeps its straight line and contributes no distance, exactly + * as a failed leg of a day route does. Nothing here throws. + */ +export async function routeTripLegs( + plan: TripRoutePlanDay[], + { tripId, signal, onAnswer }: { + tripId: number | null + signal?: AbortSignal + /** Called after each answer, for a caller that wants to draw as they arrive. */ + onAnswer?: (routed: Answer[][][]) => void + }, +): Promise { + const routed = emptyAnswers(plan) + const tasks: (() => Promise)[] = [] + plan.forEach(({ day, runs }, d) => { + runs.forEach((chunks, r) => { + chunks.forEach((chunk, c) => { + tasks.push(async () => { + try { + const answer = await calculateRouteWithLegs( + chunk.points.map(p => ({ lat: p.lat, lng: p.lng })), + { signal, profile: chunk.mode, tripId, dayId: day.id }, + ) + routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs } + onAnswer?.(routed) + } catch { + // Refused (usually a rate limit) — the straight line stands. + } + }) + }) + }) + }) + + let next = 0 + const worker = async () => { + while (next < tasks.length && !signal?.aborted) await tasks[next++]() + } + await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker)) + return routed +} + +/** + * 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. + */ +export async function routeTrip( + input: DayRouteInputs, + { profile, tripId, signal, timeoutMs }: { + profile: RouteProfileKey + tripId: number | null + signal?: AbortSignal + timeoutMs?: number + }, +): Promise { + 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 }) + return summariseTripRoute(assembleTripRoute(plan, routed)) + } finally { + if (timer) clearTimeout(timer) + } +} diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts index 5487e3bb4..16e64dee4 100644 --- a/client/src/components/Map/useTripRouteOverview.ts +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -1,68 +1,20 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator' -import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan' -import { resolveLegMode } from '../Planner/legMode' +import { type RouteProfileKey } from './RouteCalculator' +import { + assembleTripRoute, emptyAnswers, planTripRoute, routeTripLegs, summariseTripRoute, + type TripRouteSummary, +} from './tripRouteGeometry' import { useSettingsStore } from '../../store/settingsStore' -import { dayColor } from '../Roadtrip/dayColors' -import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types' +import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' -/** One travel day of the overview: the roads it covers, in its own colour. */ -export interface TripOverviewDay { - dayId: number - dayNumber: number - date: string | null - title: string | null - /** The core and casing this day is drawn in — the road trip's palette, so a day is - * the same colour whichever way the trip is being read. */ - color: { line: string; casing: string } - /** One polyline per run of the day, `[lat, lng]`. */ - lines: [number, number][][] - segments: RouteSegment[] - /** Metres and seconds, summed over the day's legs. */ - distance: number - duration: number - /** The modes this day is actually travelled in, first use first. */ - modes: string[] -} +export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry' -export interface TripRouteOverview { - days: TripOverviewDay[] - /** Every day's polylines flattened in trip order — what the map draws. */ - lines: [number, number][][] - /** The colour of each entry of `lines`, same index. */ - lineColors: { line: string; casing: string }[] - segments: RouteSegment[] - /** Every drawn coordinate, so the map can frame the whole trip at once. */ - focusPoints: [number, number][] - totalDistance: number - totalDuration: number +export interface TripRouteOverview extends TripRouteSummary { /** True until every leg has answered — the totals are a partial sum until then. */ loading: boolean } -const EMPTY: TripRouteOverview = { - days: [], lines: [], lineColors: [], segments: [], focusPoints: [], - totalDistance: 0, totalDuration: 0, loading: false, -} - -/** Neighbouring legs of one run that resolve to the same mode travel as one request, - * exactly as the single-day route builds them. */ -interface Chunk { points: DayRoutePoint[]; mode: string } - -function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] { - const chunks: Chunk[] = [] - let i = 0 - while (i < run.length - 1) { - const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode) - let end = i + 1 - while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++ - chunks.push({ points: run.slice(i, end + 1), mode }) - i = end - } - return chunks -} - -const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng]) +const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false } /** * Every travel day's route at once, each day in its own colour, with the trip's total @@ -70,11 +22,9 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p = * * The geometry is the day view's own — `buildDayRouteRuns` is the same builder * `useRouteCalculation` draws the selected day from, so a day looks identical whether - * you are looking at it alone or at the whole trip. Only the aggregation is new. - * - * Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and - * the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the - * day route and the sidebar connectors, so the day already on screen costs nothing. + * you are looking at it alone or at the whole trip. This hook is the React wrapper: + * the planning and routing themselves live in `tripRouteGeometry`, so the PDF export + * can draw the same trip without a component to hang a hook on. */ export function useTripRouteOverview( tripId: number | null, @@ -91,22 +41,18 @@ export function useTripRouteOverview( const [result, setResult] = useState(EMPTY) const abortRef = useRef(null) - const plan = useMemo(() => { - if (!enabled) return [] - return [...days] - .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) - .map(day => ({ - day, - runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }), - })) - .filter(entry => entry.runs.length > 0) - }, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation]) + const plan = useMemo( + () => (enabled + ? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation }, profile) + : []), + [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile], + ) // Only geometry and mode decide whether legs have to be fetched again: renaming a // place or editing its notes must not fire a routing round. const planKey = useMemo( () => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs - .map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|')) + .map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+')) .join('/')}`).join(';'), [plan], ) @@ -118,110 +64,20 @@ export function useTripRouteOverview( const controller = new AbortController() abortRef.current = controller - const colorFor = (day: Day) => dayColor(day.day_number ?? 0) - - // Chunk every day up front, so the routed answers can be filed by position and - // reassembled in order however they come back. - const chunked = plan.map(({ day, runs }) => { - const dayDefaultMode = day.default_transport_mode || profile - return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) } - }) - - const assemble = ( - routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][], - ): TripRouteOverview => { - const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => { - const lines: [number, number][][] = [] - const segments: RouteSegment[] = [] - runs.forEach((chunks, r) => { - const polyline: [number, number][] = [] - chunks.forEach((chunk, c) => { - const answer = routed[d][r][c] - const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points) - for (const point of coords) { - // Drop the point shared with the previous chunk so concatenated legs - // don't leave a duplicate at each junction. - const last = polyline[polyline.length - 1] - if (last && last[0] === point[0] && last[1] === point[1]) continue - polyline.push(point) - } - if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode }) - }) - if (polyline.length >= 2) lines.push(polyline) - }) - const modes: string[] = [] - for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode) - return { - dayId: day.id, - dayNumber: day.day_number ?? 0, - date: day.date ?? null, - title: day.title ?? null, - color: colorFor(day), - lines, - segments, - distance: segments.reduce((sum, s) => sum + s.distance, 0), - duration: segments.reduce((sum, s) => sum + s.duration, 0), - modes, - } - }) - const lines = overviewDays.flatMap(d => d.lines) - return { - days: overviewDays, - lines, - lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)), - segments: overviewDays.flatMap(d => d.segments), - focusPoints: lines.flat(), - totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0), - totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0), - loading: false, - } - } - - const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as - ({ coordinates: [number, number][]; legs: RouteSegment[] }) | null))) - // Straight lines first so the shape of the trip is on screen immediately, then the // real roads replace them — the same two-step the single-day route draws with. - setResult({ ...assemble(routed), loading: true }) + setResult({ ...summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan))), loading: true }) - ;(async () => { - const tasks: (() => Promise)[] = [] - chunked.forEach(({ day, runs }, d) => { - runs.forEach((chunks, r) => { - chunks.forEach((chunk, c) => { - tasks.push(async () => { - try { - const answer = await calculateRouteWithLegs( - chunk.points.map(p => ({ lat: p.lat, lng: p.lng })), - { signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id }, - ) - routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs } - } catch { - // Routing refused this leg (usually a rate limit) — it keeps its - // straight line and contributes no distance, exactly as a day route's - // failed leg does. - } - }) - }) - }) - }) - - // Small pool on purpose, the same one the sidebar connectors use: a fortnight is - // dozens of legs and the routing host is often a shared OSRM. - let next = 0 - const worker = async () => { - while (next < tasks.length && !controller.signal.aborted) await tasks[next++]() - } - await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker)) - - if (!controller.signal.aborted) setResult(assemble(routed)) - })() + routeTripLegs(plan, { tripId, signal: controller.signal }).then(routed => { + if (controller.signal.aborted) return + setResult({ ...summariseTripRoute(assembleTripRoute(plan, routed)), loading: false }) + }) return () => controller.abort() // planKey is derived from the same inputs as plan, so keying on the string is // equivalent while staying stable across unrelated renders. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [planKey, profile, tripId, distanceUnit]) + }, [planKey, tripId, distanceUnit]) return result } diff --git a/client/src/components/PDF/TripPDF.map.test.ts b/client/src/components/PDF/TripPDF.map.test.ts new file mode 100644 index 000000000..99494353b --- /dev/null +++ b/client/src/components/PDF/TripPDF.map.test.ts @@ -0,0 +1,213 @@ +// FE-COMP-TRIPPDF-MAP-001 onward — the trip route map and its distance figure (#1736). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '../../../tests/helpers/msw/server' +import type { RouteSegment } from '../../types' + +vi.mock('../Map/RouteCalculator', async (importActual) => { + const actual = await importActual() + return { ...actual, calculateRouteWithLegs: vi.fn() } +}) + +// Real by default — one test below forces it to reject, to pin the logging contract. +vi.mock('../Map/tripRouteGeometry', async (importActual) => { + const actual = await importActual() + return { ...actual, routeTrip: vi.fn(actual.routeTrip) } +}) + +// jsdom has no WebGL, so the real renderer always declines — mocked so both the +// basemap branch and the outline fallback can be exercised. +vi.mock('./tripMapImage', () => ({ renderTripMapImage: vi.fn(async () => null) })) + +const { calculateRouteWithLegs } = await import('../Map/RouteCalculator') +const { renderTripMapImage } = await import('./tripMapImage') +const { routeTrip } = await import('../Map/tripRouteGeometry') +const { downloadTripPDF } = await import('./TripPDF') + +const leg = (distance: number): RouteSegment => ({ + mid: [0, 0], from: [0, 0], to: [0, 0], + distance, duration: 900, + distanceText: '12 km', durationText: '15 min', walkingText: '2 h', drivingText: '15 min', +}) + +const place = (id: number, lat: number, lng: number) => ({ id, name: `P${id}`, lat, lng }) +const assign = (id: number, dayId: number, order: number, lat: number, lng: number) => + ({ id, day_id: dayId, place_id: id, order_index: order, place: place(id, lat, lng) }) + +const args = { + trip: { id: 1, title: 'My Trip', description: null, cover_image: null, currency: 'EUR' } as any, + days: [ + { id: 1, day_number: 1, title: null, date: '2025-06-01' }, + { id: 2, day_number: 2, title: 'Coast road', date: '2025-06-02' }, + ] as any[], + places: [], + assignments: { + '1': [assign(1, 1, 0, 48.86, 2.35), assign(2, 1, 1, 48.90, 2.42)], + '2': [assign(3, 2, 0, 45.76, 4.83), assign(4, 2, 1, 45.80, 4.90)], + } as any, + categories: [], + dayNotes: [], + reservations: [], + t: (key: string, params?: any) => (params?.n !== undefined ? `Day ${params.n}` : key), + locale: 'en-US', +} + +const srcdoc = () => + (document.querySelector('#pdf-preview-overlay iframe') as HTMLIFrameElement).srcdoc + +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:3000', pathname: '/', href: 'http://localhost:3000/', search: '' }, + writable: true, + }) + server.use( + http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: [] })), + http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [] })), + ) + vi.mocked(calculateRouteWithLegs).mockReset() + vi.mocked(calculateRouteWithLegs).mockResolvedValue({ + coordinates: [[48.86, 2.35], [48.88, 2.39], [48.90, 2.42]], + distance: 12000, duration: 900, + legs: [leg(12000)], + }) +}) + +afterEach(() => { + document.getElementById('pdf-preview-overlay')?.remove() +}) + +describe('trip route map in the PDF', () => { + it('FE-COMP-TRIPPDF-MAP-001: puts the route map and its legend in the document', async () => { + await downloadTripPDF(args) + const html = srcdoc() + + expect(html).toContain('class="trip-map"') + expect(html).toContain(' { + await downloadTripPDF(args) + const html = srcdoc() + + // One routed chunk per day, 12 km each. + expect(html).toContain('24 km') + expect(html).toContain('pdf.distanceLabel') + }) + + it('FE-COMP-TRIPPDF-MAP-003: prints the reader\'s own unit', async () => { + await downloadTripPDF({ ...args, distanceUnit: 'imperial' }) + expect(srcdoc()).toContain('14.9 mi') + }) + + it('FE-COMP-TRIPPDF-MAP-004: credits the boundary data it draws', async () => { + await downloadTripPDF(args) + expect(srcdoc()).toContain('pdf.mapCredit') + }) + + // A city trip often holds one stop a day and gets its whole route from the hotel + // legs either side of it. That is a different path through the builder from a day + // with two stops of its own, and it is the common shape (#1736). + it('FE-COMP-TRIPPDF-MAP-004b: a day that only routes via its hotel still draws', async () => { + // The envelope the endpoint actually answers with — a bare array here is what let + // the missing hotel legs through unnoticed. + server.use(http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ + accommodations: [{ + id: 2, trip_id: 1, place_id: 5, start_day_id: 1, end_day_id: 2, + check_in: null, check_in_end: null, check_out: null, + place_name: 'Hotel', place_lat: 48.8714, place_lng: 2.3426, + }], + }))) + + await downloadTripPDF({ + ...args, + assignments: { '1': [assign(1, 1, 0, 48.8583, 2.2945)], '2': [assign(2, 2, 0, 48.8611, 2.3357)] } as any, + }) + const html = srcdoc() + + expect(html).toContain(' { + vi.mocked(renderTripMapImage).mockResolvedValueOnce( + '', + ) + await downloadTripPDF(args) + const html = srcdoc() + + expect(html).toContain('data:image/png;base64,AAA') + // The outline map's sea rectangle is the tell that the fallback drew instead. + expect(html).not.toContain('#eef3f7') + }) + + it('FE-COMP-TRIPPDF-MAP-012: falls back to the outline map when the basemap declines', async () => { + vi.mocked(renderTripMapImage).mockResolvedValueOnce(null) + await downloadTripPDF(args) + const html = srcdoc() + + expect(html).toContain(' { + await downloadTripPDF({ ...args, assignments: {} }) + const html = srcdoc() + + expect(html).not.toContain('class="trip-map"') + expect(html).toContain('pdf.travelPlan') + }) + + it('FE-COMP-TRIPPDF-MAP-008: says why the map is missing when the route builder throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A refused leg is caught per-leg and leaves a straight line; this is the other + // kind — the builder itself failing on data it cannot read, which used to vanish + // without trace and leave a PDF that looked like it simply had no route. + vi.mocked(routeTrip).mockRejectedValueOnce(new Error('boom')) + await downloadTripPDF(args) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[tripPdfMap] routing the trip failed'), + expect.any(Error), + ) + // The itinerary still prints — the map is an extra, not a precondition. + expect(srcdoc()).toContain('pdf.travelPlan') + expect(srcdoc()).not.toContain('class="trip-map"') + warn.mockRestore() + }) + + it('FE-COMP-TRIPPDF-MAP-009: says so when the trip has stops but nothing that routes', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // One lone stop on the day, and no accommodation to bookend it — no route exists. + await downloadTripPDF({ ...args, assignments: { '1': [assign(1, 1, 0, 48.86, 2.35)] } as any }) + + expect(srcdoc()).not.toContain('class="trip-map"') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[tripPdfMap] no map drawn: 0 of 2 day(s)')) + warn.mockRestore() + }) + + it('FE-COMP-TRIPPDF-MAP-010: stays quiet for a trip nobody has planned yet', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await downloadTripPDF({ ...args, assignments: {} }) + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('[tripPdfMap]')) + warn.mockRestore() + }) + + it('FE-COMP-TRIPPDF-MAP-006: a router that refuses still prints the map and the days', async () => { + vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429')) + await downloadTripPDF(args) + const html = srcdoc() + + // Straight lines stand in for the roads... + expect(html).toContain(' '%' + c.codePointAt(0).toString(16).padStart(2, '0')) } +// The day colours come from a fixed palette, but this is a style attribute being built +// by string concatenation — the same place cssUrl exists for. Anything that is not a +// plain hex triple is not a colour, and gets the route blue instead of a chance to +// close the declaration. +function hexColour(value) { + return /^#[0-9a-f]{6}$/i.test(String(value)) ? String(value) : '#0a84ff' +} + function absUrl(url) { if (!url) return null if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url @@ -185,11 +197,13 @@ interface downloadTripPDFProps { * way `locale` does (#2066). */ timeFormat?: string + /** 'metric' | 'imperial'. Same reasoning as `timeFormat` — read as a prop, not a hook. */ + distanceUnit?: string } // `assignments` is normalised here once — every read below (and fetchPlacePhotos) // relies on it being an object. -export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat }: downloadTripPDFProps) { +export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit }: downloadTripPDFProps) { const breaksPerDay = pageBreakPerDay() const loc = _locale || undefined const tr = _t || (k => k) @@ -202,12 +216,82 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca const coverImg = safeImg(trip?.cover_image) //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed const accommodations = await accommodationsApi.list(trip.id); + // The endpoint answers `{ accommodations: [...] }`, not a bare array. Unwrapped once + // here so every reader below gets the list itself — passing the envelope on is what + // cost the route map its hotel legs, and silently (#1736). + const accommodationList = Array.isArray(accommodations?.accommodations) ? accommodations.accommodations : [] // Sections contributed by pdfSectionProvider plugins — server-normalized plain // text (counts + lengths capped), appended after the days. Fail-safe: an error // 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 + try { + tripRoute = await routeTrip( + { + days: sorted, + assignments, + reservations, + accommodations: accommodationList, + optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation, + }, + { 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 totalDistanceLabel = tripRoute && tripRoute.totalDistance > 0 + ? formatDistance(tripRoute.totalDistance / 1000, unit) + : 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`. + const tripMapHtml = tripMapSvg ? ` +
+
+ ${escHtml(tr('pdf.mapTitle'))} + ${totalDistanceLabel ? `${escHtml(tr('pdf.distanceLabel'))}: ${escHtml(totalDistanceLabel)}` : ''} +
+ ${tripMapSvg} +
+ ${tripRoute.days.filter(d => d.lines.length).map(d => ` + + ${escHtml(d.title || tr('dayplan.dayN', { n: d.dayNumber }))} + ${escHtml(formatDistance(d.distance / 1000, unit))} + `).join('')} +
+
${escHtml(tr('pdf.mapCredit'))}
+
` : '' + + // Pre-fetch place photos (Google, OSM and coords-only places) const photoMap = await fetchPlacePhotos(assignments, places) @@ -463,7 +547,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca ` }).join('') - const accommodationsForDay = (accommodations.accommodations || []).filter(a => + const accommodationsForDay = accommodationList.filter(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false ).sort((a, b) => { const startA = days.find(d => d.id === a.start_day_id) @@ -604,6 +688,19 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca .cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; } .cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; } + /* ── Trip map ──────────────────────────────────── */ + .trip-map { padding: 26px 30px 20px; page-break-after: always; page-break-inside: avoid; } + .pdf-flow .trip-map { page-break-after: auto; } + .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-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; } + .trip-map-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + .trip-map-leg-dist { color: #94a3b8; } + .trip-map-credit { font-size: 7.5px; color: #94a3b8; margin-top: 10px; } + /* ── Day ───────────────────────────────────────── */ /* .day-section is a real ; its day header repeats on overflow pages. */ .page-break { page-break-before: always; } @@ -763,6 +860,10 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
${totalAssigned}
${escHtml(tr('pdf.planned'))}
+ ${totalDistanceLabel ? `
+
${escHtml(totalDistanceLabel)}
+
${escHtml(tr('pdf.distanceLabel'))}
+
` : ''} ${totalCostLabel ? `
${totalCostLabel}
${escHtml(tr('pdf.costLabel'))}
@@ -771,6 +872,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
+ +${tripMapHtml} + ${daysHtml} ${pluginSectionsHtml} diff --git a/client/src/components/PDF/tripMapImage.test.ts b/client/src/components/PDF/tripMapImage.test.ts new file mode 100644 index 000000000..203192bb7 --- /dev/null +++ b/client/src/components/PDF/tripMapImage.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { TripOverviewDay } from '../Map/tripRouteGeometry' + +vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() })) + +const { hasWebGL } = await import('../../utils/webgl') +const { renderTripMapImage } = await import('./tripMapImage') + +const day = (over: Partial = {}): TripOverviewDay => ({ + dayId: 1, dayNumber: 1, date: null, title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, + lines: [[[48.86, 2.35], [48.88, 2.40]]], + segments: [], distance: 0, duration: 0, modes: [], ...over, +}) + +const opts = { width: 720, height: 420, style: null, formatDistance: (km: number) => `${km} km` } + +beforeEach(() => { + vi.mocked(hasWebGL).mockReturnValue(true) + document.body.innerHTML = '' +}) + +describe('renderTripMapImage', () => { + it('FE-PDF-MAPIMG-001: gives up without WebGL, before touching the map engine', async () => { + vi.mocked(hasWebGL).mockReturnValue(false) + expect(await renderTripMapImage([day()], opts)).toBeNull() + }) + + it('FE-PDF-MAPIMG-002: gives up when there is no route to frame', async () => { + expect(await renderTripMapImage([], opts)).toBeNull() + expect(await renderTripMapImage([day({ lines: [[[48.86, 2.35]]] })], opts)).toBeNull() + }) + + it('FE-PDF-MAPIMG-003: returns null rather than throwing when the engine fails', async () => { + // jsdom has no WebGL implementation behind the probe, so constructing the map + // throws — the export must survive that and fall back to the outline map. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await renderTripMapImage([day()], opts)).toBeNull() + warn.mockRestore() + }) + + it('FE-PDF-MAPIMG-004: leaves no offscreen container behind', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await renderTripMapImage([day()], opts) + expect(document.body.children).toHaveLength(0) + warn.mockRestore() + }) +}) diff --git a/client/src/components/PDF/tripMapImage.ts b/client/src/components/PDF/tripMapImage.ts new file mode 100644 index 000000000..194b28ec3 --- /dev/null +++ b/client/src/components/PDF/tripMapImage.ts @@ -0,0 +1,108 @@ +import { buildRouteOverlay, wrapSvg } from './tripMapSvg' +import { hasWebGL } from '../../utils/webgl' +import { normalizeStyleForProvider } from '../Map/glProviders' +import type { TripOverviewDay } from '../Map/tripRouteGeometry' + +/** + * The trip's route over a real basemap, for the PDF (#1736). + * + * The bundled country outlines this falls back to are built for a journey across a + * region; at the two-kilometre scale of a city trip the only outline in view is the + * country itself, which fills the frame and says nothing. A real basemap is the only + * thing that carries context at that size. + * + * Rendered offscreen through MapLibre rather than fetched as a picture: it is the + * renderer the app already ships, it reads the instance's own style, and OpenFreeMap — + * the default — needs no token and no account. The result is a PNG baked into the + * document, which matters because the preview iframe runs with no `allow-scripts` and + * printing starts on the click: nothing may still be loading by then. + * + * Everything here is best-effort. No WebGL, a style that will not load, a slow tile + * server — each returns null and leaves the caller on the vector fallback. + */ + +/** Beyond this the export stops waiting and prints the outline map instead. */ +const RENDER_TIMEOUT_MS = 6000 + +export interface TripMapImageOptions { + width: number + height: number + /** The instance's MapLibre style, as the planner resolves it. */ + style?: string | null + formatDistance: (km: number) => string +} + +const EARTH_CIRCUMFERENCE_M = 40075016.686 + +export async function renderTripMapImage( + days: TripOverviewDay[], + opts: TripMapImageOptions, +): Promise { + const { width, height } = opts + const drawn = days.filter(d => d.lines.some(line => line.length > 1)) + const points = drawn.flatMap(d => d.lines.flat()) + if (points.length < 2 || !hasWebGL()) return null + + let container: HTMLDivElement | null = null + let map: { remove: () => void } | null = null + try { + const gl = (await import('../Map/engines/maplibre')).default + + container = document.createElement('div') + // Off-screen rather than hidden: MapLibre needs a laid-out box with real + // dimensions, and `display:none` gives it a zero-sized canvas. + container.style.cssText = + `position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;` + document.body.appendChild(container) + + const bounds = new gl.LngLatBounds() + for (const [lat, lng] of points) bounds.extend([lng, lat]) + + const instance = new gl.Map({ + container, + style: normalizeStyleForProvider('maplibre-gl', opts.style), + bounds, + fitBoundsOptions: { padding: 42, animate: false }, + interactive: false, + attributionControl: false, + // Without this the canvas is cleared before anything can read it back. + // MapLibre 5 moved the WebGL flags off the top level into this bag. + canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true }, + fadeDuration: 0, + }) + map = instance + + const ready = await new Promise(resolve => { + const timer = setTimeout(() => resolve(false), RENDER_TIMEOUT_MS) + const done = (ok: boolean) => { clearTimeout(timer); resolve(ok) } + // `idle` rather than `load`: load fires with tiles still arriving, and a + // half-drawn basemap is worse than none. + instance.once('idle', () => done(true)) + instance.once('error', () => done(false)) + }) + if (!ready) return null + + // Projected by the map itself, so the lines land exactly where the roads under + // them do — a second projection of our own would drift at the edges. + const project = (lat: number, lng: number): [number, number] => { + const p = instance.project([lng, lat]) + return [p.x, p.y] + } + const metresPerPx = + (EARTH_CIRCUMFERENCE_M * Math.cos((instance.getCenter().lat * Math.PI) / 180)) + / (512 * Math.pow(2, instance.getZoom())) + + const png = instance.getCanvas().toDataURL('image/png') + if (!png.startsWith('data:image/png')) return null + + return wrapSvg(width, height, + `` + + buildRouteOverlay(drawn, { project, width, height, metresPerPx, formatDistance: opts.formatDistance })) + } catch (err) { + console.warn('[tripPdfMap] the basemap could not be rendered; falling back to outlines', err) + return null + } finally { + map?.remove() + container?.remove() + } +} diff --git a/client/src/components/PDF/tripMapSvg.test.ts b/client/src/components/PDF/tripMapSvg.test.ts new file mode 100644 index 000000000..637e96acb --- /dev/null +++ b/client/src/components/PDF/tripMapSvg.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { buildTripMapSvg } from './tripMapSvg' +import type { TripOverviewDay } from '../Map/tripRouteGeometry' + +const day = (over: Partial = {}): TripOverviewDay => ({ + dayId: 1, dayNumber: 1, date: null, title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, + lines: [[[48.86, 2.35], [48.88, 2.40]]], + segments: [], distance: 0, duration: 0, modes: [], ...over, +}) + +const opts = { width: 720, height: 420, formatDistance: (km: number) => `${km} km` } + +describe('buildTripMapSvg', () => { + it('FE-PDF-MAPSVG-001: draws nothing when there is no route to draw', () => { + expect(buildTripMapSvg([], opts)).toBeNull() + // A single point is not a line. + expect(buildTripMapSvg([day({ lines: [[[48.86, 2.35]]] })], opts)).toBeNull() + }) + + it('FE-PDF-MAPSVG-002: gives each day its own colour over a white casing', () => { + const svg = buildTripMapSvg([ + day({ color: { line: '#0a84ff', casing: '#0a5cc2' } }), + day({ dayId: 2, dayNumber: 2, color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [[[45.76, 4.83], [45.78, 4.85]]] }), + ], opts) as string + + expect(svg).toContain('stroke="#0a84ff"') + expect(svg).toContain('stroke="#ff9f0a"') + // Each core sits on its own darkened casing, not a shared white one. + expect(svg).toContain('stroke="#0a5cc2"') + expect(svg).toContain('stroke="#c2740a"') + }) + + it('FE-PDF-MAPSVG-003: refuses a colour that is not a plain hex triple', () => { + const svg = buildTripMapSvg([day({ color: { line: 'red"/>', casing: '#0a5cc2' } })], opts) as string + + expect(svg).not.toContain(' { + const svg = buildTripMapSvg([day({ lines: [[[48.86, 2.35], [52.52, 13.40]]] })], opts) as string + + // Paris → Berlin: land is drawn, and it is drawn before the route. + expect(svg).toContain(' { + const svg = buildTripMapSvg([day()], opts) as string + expect(svg.match(/ { + const svg = buildTripMapSvg([day({ lines: [[[48.86, 2.35], [52.52, 13.40]]] })], { + ...opts, formatDistance: km => `${km} leagues`, + }) as string + + expect(svg).toContain(' leagues') + }) + + it('FE-PDF-MAPSVG-007: a day inside one city still draws as a route, not a dot', () => { + // Two stops 400m apart: fitting tightly would magnify GPS noise into a continent, + // so the view has a floor. + const svg = buildTripMapSvg([day({ lines: [[[48.8600, 2.3500], [48.8620, 2.3540]]] })], opts) as string + + expect(svg).toContain(' Number(p.split(',')[0])) + // The two stops sit apart but well inside the frame, not pinned to its edges. + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(1) + expect(Math.min(...xs)).toBeGreaterThan(0) + expect(Math.max(...xs)).toBeLessThan(opts.width) + }) + + it('FE-PDF-MAPSVG-008: fills the frame it is given', () => { + const svg = buildTripMapSvg([day()], opts) as string + expect(svg).toContain('viewBox="0 0 720 420"') + expect(svg).toContain('width="720"') + }) +}) diff --git a/client/src/components/PDF/tripMapSvg.ts b/client/src/components/PDF/tripMapSvg.ts new file mode 100644 index 000000000..5c3640651 --- /dev/null +++ b/client/src/components/PDF/tripMapSvg.ts @@ -0,0 +1,175 @@ +import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator } from '../Studio/countryShapes' +import type { TripOverviewDay } from '../Map/tripRouteGeometry' + +/** + * The trip's route as one printable map (#1736). + * + * Vector, and drawn from the country silhouettes the app already bundles rather than + * from tiles. Three reasons, the same ones Studio's map element settles on in + * `mapSources.ts`: covering a page at print resolution is exactly the bulk fetching the + * OSM tile policy names as something not to do, Mapbox asks to be contacted before an + * exported map is printed, and a raster cut for a screen zoom goes soft on paper. The + * outlines are offline, unlicensed at the point of use and sharp at any size. + * + * It also has to be inert: the preview runs in a sandboxed iframe with no + * `allow-scripts`, and printing starts on the click rather than waiting for anything to + * load. An inline SVG needs neither. + */ + +/** Land, sea and the ink over them. Fixed, not themed — this is a printed page. */ +const SEA = '#eef3f7' +const LAND = '#ffffff' +const COAST = '#c9d3dc' +const CASING = '#ffffff' +const STOP_FILL = '#334155' +const SCALE_INK = '#64748b' + +const EARTH_CIRCUMFERENCE_M = 40075016.686 + +/** The palette is fixed, but this goes into an attribute built by concatenation — see + * `hexColour` in TripPDF for the same guard on the same reasoning. */ +const strokeColour = (value: string): string => + /^#[0-9a-f]{6}$/i.test(value) ? value : '#0a84ff' + +export interface TripMapSvgOptions { + width: number + height: number + /** Rendered into the scale bar, so it reads in whatever unit the reader uses. */ + formatDistance: (km: number) => string +} + +/** A round number to put on a scale bar: 1, 2 or 5 at some power of ten. */ +function niceDistanceKm(roughKm: number): number { + const power = Math.pow(10, Math.floor(Math.log10(roughKm))) + const mantissa = roughKm / power + return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power +} + +/** + * Builds the map, or returns null when there is no route to draw. + * + * Null rather than an empty frame on purpose: a trip nobody has planned a day of yet + * should print without a blank rectangle where a map would be. + */ +export function buildTripMapSvg(days: TripOverviewDay[], opts: TripMapSvgOptions): string | null { + const { width, height } = opts + const drawn = days.filter(d => d.lines.some(line => line.length > 1)) + const points = drawn.flatMap(d => d.lines.flat()).map(([lat, lng]) => projectMercator(lng, lat)) + if (points.length < 2) return null + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const p of points) { + if (p.x < minX) minX = p.x + if (p.x > maxX) maxX = p.x + if (p.y < minY) minY = p.y + if (p.y > maxY) maxY = p.y + } + // A day spent inside one city spans almost nothing, and fitting tightly to it would + // magnify GPS noise into a continent. A floor on the span keeps the route a route. + const MIN_SPAN_DEG = 0.08 + const padded = (lo: number, hi: number) => { + const span = Math.max(hi - lo, MIN_SPAN_DEG) + const mid = (lo + hi) / 2 + const half = (span / 2) * 1.18 + return [mid - half, mid + half] as const + } + ;[minX, maxX] = padded(minX, maxX) + ;[minY, maxY] = padded(minY, maxY) + + // One scale for both axes so nothing is squashed; the shorter axis gets the slack. + const scale = Math.min(width / (maxX - minX), height / (maxY - minY)) + const offsetX = (width - (maxX - minX) * scale) / 2 - minX * scale + const offsetY = (height - (maxY - minY) * scale) / 2 - minY * scale + const px = (lat: number, lng: number): [number, number] => { + const p = projectMercator(lng, lat) + return [p.x * scale + offsetX, p.y * scale + offsetY] + } + const round = (n: number) => Math.round(n * 10) / 10 + + // The view in world coordinates, so a country can be tested against it without + // being drawn: 198 outlines is a lot of path to put in a document that needs four. + const viewMinX = -offsetX / scale, viewMaxX = (width - offsetX) / scale + const viewMinY = -offsetY / scale, viewMaxY = (height - offsetY) / scale + const land: string[] = [] + for (const shape of Object.values(COUNTRY_SHAPES)) { + const touches = countryParts(shape).some(([a, b, c, d]) => + a <= viewMaxX && c >= viewMinX && b <= viewMaxY && d >= viewMinY) + if (touches) land.push(countryWorldPath(shape)) + } + + // Metres per horizontal pixel at the middle of the view, which is where a scale bar + // on a Mercator map is honest. + const midLat = (Math.atan(Math.exp((-((minY + maxY) / 2) * Math.PI) / 180)) - Math.PI / 4) * 2 + const metresPerPx = (EARTH_CIRCUMFERENCE_M * Math.cos(midLat)) / 360 / scale + + return wrapSvg(width, height, + `` + + `` + + land.map(d => ``).join('') + + `` + + buildRouteOverlay(drawn, { project: px, width, height, metresPerPx, formatDistance: opts.formatDistance })) +} + +export const wrapSvg = (width: number, height: number, inner: string): string => + `${inner}` + +export interface RouteOverlayOptions { + /** Latitude and longitude to a pixel in the frame. Whatever drew the ground supplies it. */ + project: (lat: number, lng: number) => [number, number] + width: number + height: number + /** Ground metres per horizontal pixel at the middle of the view, for the scale bar. */ + metresPerPx: number + formatDistance: (km: number) => string +} + +/** + * The routes, their stops and the scale bar — everything drawn *over* the ground. + * + * Split out because the ground has two implementations (a real basemap rendered offscreen, + * and the bundled country outlines it falls back to) and the route on top has to look + * identical either way. + */ +export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOptions): string { + const { project, width, height, metresPerPx } = opts + const round = (n: number) => Math.round(n * 10) / 10 + const drawn = days.filter(d => d.lines.some(line => line.length > 1)) + + const routes = drawn.flatMap(day => day.lines.filter(l => l.length > 1).map(line => { + const pts = line.map(([lat, lng]) => project(lat, lng).map(round).join(',')).join(' ') + // The day's own darkened casing rather than a flat white one: it is what keeps a + // warm colour readable over water and forest, and it matches what the map draws. + return `` + + `` + })) + + // Where each run starts and ends — the stops, rather than every vertex of the road. + const stops = drawn.flatMap(day => day.lines.filter(l => l.length > 1).flatMap(line => { + const ends = [line[0], line[line.length - 1]] + return ends.map(([lat, lng]) => { + const [x, y] = project(lat, lng).map(round) + return `` + }) + })) + + const barKm = niceDistanceKm((width * 0.25 * metresPerPx) / 1000) + const barPx = round((barKm * 1000) / metresPerPx) + const barY = height - 16 + // A bar that would run off the frame, or be too short to read, is worse than none. + const scaleBar = barPx > 8 && barPx < width * 0.6 + ? `` + + `` + + `${escapeText(opts.formatDistance(barKm))}` + : '' + + return routes.join('') + stops.join('') + scaleBar +} + +/** The scale label is the only text here, and it is a number and a unit — but it comes + * through a formatter a caller supplies, so it is escaped like anything else. */ +function escapeText(s: string): string { + return String(s).replace(/[&<>]/g, c => (c === '&' ? '&' : c === '<' ? '<' : '>')) +} diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index 894aeaf59..a36ca4ecf 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -60,6 +60,7 @@ export function TripExportModal({ const [busy, setBusy] = useState(null) // The PDF is built outside React, so it cannot read this itself (#2066). const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const fileBase = trip?.title || 'trip' // Shared tail of every download: Firefox and Safari cancel the download when @@ -86,7 +87,7 @@ export function TripExportModal({ // exported. A missing chunk lands in the catch and shows the same error // the export already had. const { downloadTripPDF } = await import('../PDF/TripPDF') - await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat }) + await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit }) onClose() } catch (e) { console.error('PDF error:', e) diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index dbc5444ce..32d0b31d7 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -19,6 +19,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { const { t, locale } = useTranslation() // The PDF is built outside React, so it cannot read this itself (#2066). const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const open = shell.sheet?.id === 'export' const dayNotes = useTripStore(s => s.dayNotes) const [subscribeOpen, setSubscribeOpen] = useState(false) @@ -50,6 +51,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { t, locale, timeFormat, + distanceUnit, }) } catch (e) { planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`) diff --git a/shared/src/i18n/ar/pdf.ts b/shared/src/i18n/ar/pdf.ts index 0eba805b3..d7dbf852e 100644 --- a/shared/src/i18n/ar/pdf.ts +++ b/shared/src/i18n/ar/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'معاينة PDF', 'pdf.saveAsPdf': 'حفظ كـ PDF', 'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم', + 'pdf.mapTitle': 'نظرة عامة على المسار', + 'pdf.distanceLabel': 'المسافة', + 'pdf.mapCredit': 'حدود الدول: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/br/pdf.ts b/shared/src/i18n/br/pdf.ts index b272b6e2e..1c2e0b346 100644 --- a/shared/src/i18n/br/pdf.ts +++ b/shared/src/i18n/br/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Pré-visualização do PDF', 'pdf.saveAsPdf': 'Salvar como PDF', 'pdf.pageBreakPerDay': 'Quebra de página por dia', + 'pdf.mapTitle': 'Visão geral da rota', + 'pdf.distanceLabel': 'Distância', + 'pdf.mapCredit': 'Contornos dos países: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/ca/pdf.ts b/shared/src/i18n/ca/pdf.ts index 667c30e77..8ec1512ee 100644 --- a/shared/src/i18n/ca/pdf.ts +++ b/shared/src/i18n/ca/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Vista prèvia del PDF', 'pdf.saveAsPdf': 'Desa com a PDF', 'pdf.pageBreakPerDay': 'Salt de pàgina per dia', + 'pdf.mapTitle': 'Resum de la ruta', + 'pdf.distanceLabel': 'Distància', + 'pdf.mapCredit': 'Contorns dels països: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/cs/pdf.ts b/shared/src/i18n/cs/pdf.ts index bd610b9f3..51ac4fefa 100644 --- a/shared/src/i18n/cs/pdf.ts +++ b/shared/src/i18n/cs/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Náhled PDF', 'pdf.saveAsPdf': 'Uložit jako PDF', 'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den', + 'pdf.mapTitle': 'Přehled trasy', + 'pdf.distanceLabel': 'Vzdálenost', + 'pdf.mapCredit': 'Obrysy zemí: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/de/pdf.ts b/shared/src/i18n/de/pdf.ts index 2fb75fa61..fcbc51ed2 100644 --- a/shared/src/i18n/de/pdf.ts +++ b/shared/src/i18n/de/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF Vorschau', 'pdf.saveAsPdf': 'Als PDF speichern', 'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag', + 'pdf.mapTitle': 'Routenübersicht', + 'pdf.distanceLabel': 'Strecke', + 'pdf.mapCredit': 'Ländergrenzen: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/en/pdf.ts b/shared/src/i18n/en/pdf.ts index c6f092b62..973032283 100644 --- a/shared/src/i18n/en/pdf.ts +++ b/shared/src/i18n/en/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF Preview', 'pdf.saveAsPdf': 'Save as PDF', 'pdf.pageBreakPerDay': 'Page break per day', + 'pdf.mapTitle': 'Route overview', + 'pdf.distanceLabel': 'Distance', + 'pdf.mapCredit': 'Country outlines: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/es/pdf.ts b/shared/src/i18n/es/pdf.ts index af12208e3..75eb8e42d 100644 --- a/shared/src/i18n/es/pdf.ts +++ b/shared/src/i18n/es/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Vista previa PDF', 'pdf.saveAsPdf': 'Guardar como PDF', 'pdf.pageBreakPerDay': 'Salto de página por día', + 'pdf.mapTitle': 'Resumen de la ruta', + 'pdf.distanceLabel': 'Distancia', + 'pdf.mapCredit': 'Contornos de países: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/fr/pdf.ts b/shared/src/i18n/fr/pdf.ts index 1ddc9cee1..6cf56540c 100644 --- a/shared/src/i18n/fr/pdf.ts +++ b/shared/src/i18n/fr/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Aperçu PDF', 'pdf.saveAsPdf': 'Enregistrer en PDF', 'pdf.pageBreakPerDay': 'Saut de page par jour', + 'pdf.mapTitle': 'Aperçu de l\'itinéraire', + 'pdf.distanceLabel': 'Distance', + 'pdf.mapCredit': 'Contours des pays : geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/gr/pdf.ts b/shared/src/i18n/gr/pdf.ts index 2ece28623..d0c1467fc 100644 --- a/shared/src/i18n/gr/pdf.ts +++ b/shared/src/i18n/gr/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Προεπισκόπηση PDF', 'pdf.saveAsPdf': 'Αποθήκευση ως PDF', 'pdf.pageBreakPerDay': 'Αλλαγή σελίδας ανά ημέρα', + 'pdf.mapTitle': 'Επισκόπηση διαδρομής', + 'pdf.distanceLabel': 'Απόσταση', + 'pdf.mapCredit': 'Περιγράμματα χωρών: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/hu/pdf.ts b/shared/src/i18n/hu/pdf.ts index 460941102..81a191a53 100644 --- a/shared/src/i18n/hu/pdf.ts +++ b/shared/src/i18n/hu/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF előnézet', 'pdf.saveAsPdf': 'Mentés PDF-ként', 'pdf.pageBreakPerDay': 'Oldaltörés naponta', + 'pdf.mapTitle': 'Útvonal áttekintése', + 'pdf.distanceLabel': 'Távolság', + 'pdf.mapCredit': 'Országhatárok: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/id/pdf.ts b/shared/src/i18n/id/pdf.ts index d0f7f3391..b80353be9 100644 --- a/shared/src/i18n/id/pdf.ts +++ b/shared/src/i18n/id/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Pratinjau PDF', 'pdf.saveAsPdf': 'Simpan sebagai PDF', 'pdf.pageBreakPerDay': 'Pemisah halaman per hari', + 'pdf.mapTitle': 'Ringkasan rute', + 'pdf.distanceLabel': 'Jarak', + 'pdf.mapCredit': 'Garis batas negara: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/it/pdf.ts b/shared/src/i18n/it/pdf.ts index 1219600d9..76d2d7061 100644 --- a/shared/src/i18n/it/pdf.ts +++ b/shared/src/i18n/it/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Anteprima PDF', 'pdf.saveAsPdf': 'Salva come PDF', 'pdf.pageBreakPerDay': 'Interruzione di pagina per giorno', + 'pdf.mapTitle': 'Panoramica del percorso', + 'pdf.distanceLabel': 'Distanza', + 'pdf.mapCredit': 'Confini dei paesi: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/ja/pdf.ts b/shared/src/i18n/ja/pdf.ts index 43c64a6c2..580eaf7ff 100644 --- a/shared/src/i18n/ja/pdf.ts +++ b/shared/src/i18n/ja/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDFプレビュー', 'pdf.saveAsPdf': 'PDFとして保存', 'pdf.pageBreakPerDay': '日ごとに改ページ', + 'pdf.mapTitle': 'ルート概要', + 'pdf.distanceLabel': '距離', + 'pdf.mapCredit': '国境線: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/ko/pdf.ts b/shared/src/i18n/ko/pdf.ts index 3241d7bc9..f2b0618c4 100644 --- a/shared/src/i18n/ko/pdf.ts +++ b/shared/src/i18n/ko/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF 미리보기', 'pdf.saveAsPdf': 'PDF로 저장', 'pdf.pageBreakPerDay': '날짜별 페이지 나누기', + 'pdf.mapTitle': '경로 개요', + 'pdf.distanceLabel': '거리', + 'pdf.mapCredit': '국가 윤곽: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/nl/pdf.ts b/shared/src/i18n/nl/pdf.ts index c447ad9c3..4d9dbbee6 100644 --- a/shared/src/i18n/nl/pdf.ts +++ b/shared/src/i18n/nl/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF-voorbeeld', 'pdf.saveAsPdf': 'Opslaan als PDF', 'pdf.pageBreakPerDay': 'Pagina-einde per dag', + 'pdf.mapTitle': 'Routeoverzicht', + 'pdf.distanceLabel': 'Afstand', + 'pdf.mapCredit': 'Landcontouren: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/pl/pdf.ts b/shared/src/i18n/pl/pdf.ts index 793842215..bd2e9a821 100644 --- a/shared/src/i18n/pl/pdf.ts +++ b/shared/src/i18n/pl/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Podgląd PDF', 'pdf.saveAsPdf': 'Zapisz jako PDF', 'pdf.pageBreakPerDay': 'Podział strony dla każdego dnia', + 'pdf.mapTitle': 'Przegląd trasy', + 'pdf.distanceLabel': 'Dystans', + 'pdf.mapCredit': 'Kontury krajów: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/ru/pdf.ts b/shared/src/i18n/ru/pdf.ts index c3455396b..1eb1ba32e 100644 --- a/shared/src/i18n/ru/pdf.ts +++ b/shared/src/i18n/ru/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Предпросмотр PDF', 'pdf.saveAsPdf': 'Сохранить как PDF', 'pdf.pageBreakPerDay': 'Разрыв страницы для каждого дня', + 'pdf.mapTitle': 'Обзор маршрута', + 'pdf.distanceLabel': 'Расстояние', + 'pdf.mapCredit': 'Контуры стран: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/sv/pdf.ts b/shared/src/i18n/sv/pdf.ts index 2a523c439..e41cca761 100644 --- a/shared/src/i18n/sv/pdf.ts +++ b/shared/src/i18n/sv/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Förhandsgranskning av PDF', 'pdf.saveAsPdf': 'Spara som PDF', 'pdf.pageBreakPerDay': 'Sidbrytning per dag', + 'pdf.mapTitle': 'Ruttöversikt', + 'pdf.distanceLabel': 'Sträcka', + 'pdf.mapCredit': 'Landskonturer: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/tr/pdf.ts b/shared/src/i18n/tr/pdf.ts index 13865a40e..33b06e590 100644 --- a/shared/src/i18n/tr/pdf.ts +++ b/shared/src/i18n/tr/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF Önizleme', 'pdf.saveAsPdf': 'PDF olarak Kaydet', 'pdf.pageBreakPerDay': 'Her gün için sayfa sonu', + 'pdf.mapTitle': 'Rota genel görünümü', + 'pdf.distanceLabel': 'Mesafe', + 'pdf.mapCredit': 'Ülke sınırları: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/uk/pdf.ts b/shared/src/i18n/uk/pdf.ts index 7e301e6ff..b40cbe797 100644 --- a/shared/src/i18n/uk/pdf.ts +++ b/shared/src/i18n/uk/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'Попередній перегляд PDF', 'pdf.saveAsPdf': 'Зберегти як PDF', 'pdf.pageBreakPerDay': 'Розрив сторінки для кожного дня', + 'pdf.mapTitle': 'Огляд маршруту', + 'pdf.distanceLabel': 'Відстань', + 'pdf.mapCredit': 'Контури країн: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/vi/pdf.ts b/shared/src/i18n/vi/pdf.ts index e513e5cf6..af5b928ab 100644 --- a/shared/src/i18n/vi/pdf.ts +++ b/shared/src/i18n/vi/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF Xem trước', 'pdf.saveAsPdf': 'Lưu dưới dạng PDF', 'pdf.pageBreakPerDay': 'Ngắt trang cho mỗi ngày', + 'pdf.mapTitle': 'Tổng quan lộ trình', + 'pdf.distanceLabel': 'Quãng đường', + 'pdf.mapCredit': 'Đường viền quốc gia: geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/zh-TW/pdf.ts b/shared/src/i18n/zh-TW/pdf.ts index 7b61fc94c..ab624ce0f 100644 --- a/shared/src/i18n/zh-TW/pdf.ts +++ b/shared/src/i18n/zh-TW/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF 預覽', 'pdf.saveAsPdf': '儲存為 PDF', 'pdf.pageBreakPerDay': '每天分頁', + 'pdf.mapTitle': '路線總覽', + 'pdf.distanceLabel': '距離', + 'pdf.mapCredit': '國家輪廓:geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/shared/src/i18n/zh/pdf.ts b/shared/src/i18n/zh/pdf.ts index 09610dbec..30e89da2f 100644 --- a/shared/src/i18n/zh/pdf.ts +++ b/shared/src/i18n/zh/pdf.ts @@ -7,5 +7,8 @@ const pdf: TranslationStrings = { 'pdf.preview': 'PDF 预览', 'pdf.saveAsPdf': '保存为 PDF', 'pdf.pageBreakPerDay': '每天分页', + 'pdf.mapTitle': '路线总览', + 'pdf.distanceLabel': '距离', + 'pdf.mapCredit': '国家轮廓:geoBoundaries (CC BY 4.0)', }; export default pdf; diff --git a/wiki/PDF-Export.md b/wiki/PDF-Export.md index e5242b42e..73f3c131d 100644 --- a/wiki/PDF-Export.md +++ b/wiki/PDF-Export.md @@ -21,8 +21,19 @@ Open the Day Plan sidebar in the trip planner and click the **Export** button in - **Days** — total number of days in the trip - **Places** — total places in your trip's place list - **Planned** — number of unique places assigned to at least one day + - **Distance** — the whole trip's routed distance, in your own unit (hidden when no day has a route) - **Cost** — sum of all assigned place prices, shown in the trip's currency (hidden if zero). Mixed currencies are converted at current rates and prefixed with "≈"; when a rate is missing, the tile shows a per-currency breakdown instead +### Route map + +After the cover comes one map of the whole trip: every planned day's route drawn in its own colour, the stops marked, and the total distance beside the heading. Each day is named under the map with the distance it covers, so the colours on the map and the days in the plan read as the same thing. The same total also appears as a **Distance** tile on the cover. + +The backdrop is the same basemap the planner uses, rendered once during the export and baked into the document as a picture — so the map is already there when the print dialog opens, rather than still loading. It follows your own map style, and the default (OpenFreeMap) needs no account or key. A scale bar sits in the corner. + +If that basemap cannot be drawn — no WebGL in the browser, no connection, or a style that will not load — the map falls back to country outlines the app bundles, which need neither network nor key. That fallback is built for a route across a region: a trip inside one city becomes a route on a plain ground, with the scale bar carrying the sense of size. + +Routes come from the same router the planner uses, and the export does not wait indefinitely for it: legs that answer in time are drawn as real roads, and anything slower stays a straight line between its stops. A trip whose routing is unavailable altogether still prints — the map shows straight lines and the distance figures are left off rather than printed as zero. A trip with no planned day prints with no map at all. + ### Per-day pages Each day starts on a new page (unless you turn off **Page break per day**) with a dark header bar showing the day number, day title, date, and the day's estimated cost.