feat(pdf): put the trip's route map in the export

This commit is contained in:
Konstantinos Thermos
2026-09-11 15:59:27 +03:00
parent 2efa843306
commit 48cd46f818
35 changed files with 1161 additions and 173 deletions
@@ -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<typeof import('../Map/RouteCalculator')>()
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<typeof import('../Map/tripRouteGeometry')>()
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('<svg class="trip-map-svg"')
expect(html).toContain('pdf.mapTitle')
// Both days named in the legend, the titled one by its title.
expect(html).toContain('Day 1')
expect(html).toContain('Coast road')
})
it('FE-COMP-TRIPPDF-MAP-002: totals the trip distance on the cover and over the map', async () => {
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('<svg class="trip-map-svg"')
// Between the cover and the days, which is where it is meant to read.
expect(html.indexOf('class="trip-map"')).toBeGreaterThan(html.indexOf('class="cover"'))
expect(html.indexOf('class="trip-map"')).toBeLessThan(html.indexOf('class="day-section"'))
})
it('FE-COMP-TRIPPDF-MAP-011: prefers the real basemap when it renders', async () => {
vi.mocked(renderTripMapImage).mockResolvedValueOnce(
'<svg class="trip-map-svg"><image href="data:image/png;base64,AAA"/></svg>',
)
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('<svg class="trip-map-svg"')
expect(html).toContain('#eef3f7')
})
it('FE-COMP-TRIPPDF-MAP-005: a trip with no planned day prints without a map', async () => {
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('<svg class="trip-map-svg"')
// ...and a total nobody could compute is left off rather than printed as zero.
expect(html).not.toContain('pdf.distanceLabel')
})
})
+107 -3
View File
@@ -3,13 +3,17 @@ import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, ParkingSquare, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
import { useSettingsStore } from '../../store/settingsStore'
import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry'
import { buildTripMapSvg } from './tripMapSvg'
import { renderTripMapImage } from './tripMapImage'
import { formatDistance } from '../../utils/units'
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
@@ -89,6 +93,14 @@ function cssUrl(url) {
return String(url).replace(/["'()\\\s]/g, c => '%' + 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 ? `
<div class="trip-map">
<div class="trip-map-head">
<span class="trip-map-title">${escHtml(tr('pdf.mapTitle'))}</span>
${totalDistanceLabel ? `<span class="trip-map-total">${escHtml(tr('pdf.distanceLabel'))}: ${escHtml(totalDistanceLabel)}</span>` : ''}
</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>`).join('')}
</div>
<div class="trip-map-credit">${escHtml(tr('pdf.mapCredit'))}</div>
</div>` : ''
// 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
</div>`
}).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 <table>; its <thead> 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
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalDistanceLabel ? `<div>
<div class="cover-stat-num">${escHtml(totalDistanceLabel)}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.distanceLabel'))}</div>
</div>` : ''}
${totalCostLabel ? `<div>
<div class="cover-stat-num">${totalCostLabel}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
@@ -771,6 +872,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
</div>
</div>
<!-- Trip map -->
${tripMapHtml}
<!-- Days -->
${daysHtml}
${pluginSectionsHtml}
@@ -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> = {}): 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()
})
})
+108
View File
@@ -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<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())
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<boolean>(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,
`<image href="${png}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none"/>`
+ 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()
}
}
@@ -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> = {}): 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"/><script>x</script>', casing: '#0a5cc2' } })], opts) as string
expect(svg).not.toContain('<script')
expect(svg).toContain('stroke="#0a84ff"')
})
it('FE-PDF-MAPSVG-004: puts the countries the trip crosses under the route', () => {
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('<path d=')
expect(svg.indexOf('<path d=')).toBeLessThan(svg.indexOf('<polyline'))
})
it('FE-PDF-MAPSVG-005: marks where each run starts and ends', () => {
const svg = buildTripMapSvg([day()], opts) as string
expect(svg.match(/<circle /g)?.length).toBe(2)
})
it('FE-PDF-MAPSVG-006: carries a scale bar in the reader\'s own unit', () => {
const svg = buildTripMapSvg([day({ lines: [[[48.86, 2.35], [52.52, 13.40]]] })], {
...opts, formatDistance: km => `${km} leagues`,
}) as string
expect(svg).toContain(' leagues</text>')
})
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('<polyline')
const points = /<polyline points="([^"]+)"/.exec(svg)?.[1].split(' ') ?? []
const xs = points.map(p => 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"')
})
})
+175
View File
@@ -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,
`<rect width="${width}" height="${height}" fill="${SEA}"/>`
+ `<g transform="translate(${round(offsetX)} ${round(offsetY)}) scale(${scale})"`
+ ` fill="${LAND}" stroke="${COAST}" stroke-width="1" vector-effect="non-scaling-stroke">`
+ land.map(d => `<path d="${d}"/>`).join('')
+ `</g>`
+ buildRouteOverlay(drawn, { project: px, width, height, metresPerPx, formatDistance: opts.formatDistance }))
}
export const wrapSvg = (width: number, height: number, inner: string): string =>
`<svg class="trip-map-svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"`
+ ` xmlns="http://www.w3.org/2000/svg" role="img">${inner}</svg>`
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 `<polyline points="${pts}" fill="none" stroke="${strokeColour(day.color.casing)}" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"/>`
+ `<polyline points="${pts}" fill="none" stroke="${strokeColour(day.color.line)}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>`
}))
// 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 `<circle cx="${x}" cy="${y}" r="2.6" fill="${STOP_FILL}" stroke="${CASING}" stroke-width="1.2"/>`
})
}))
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
? `<g stroke="${SCALE_INK}" stroke-width="1.2" fill="none">`
+ `<path d="M16 ${barY - 4}V${barY}H${16 + barPx}V${barY - 4}"/></g>`
+ `<text x="${16 + barPx + 6}" y="${barY + 1}" font-size="9" fill="${SCALE_INK}"`
+ ` font-family="Poppins, system-ui, sans-serif">${escapeText(opts.formatDistance(barKm))}</text>`
: ''
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 === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;'))
}