mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(pdf): put the trip's route map in the export
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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 === '&' ? '&' : c === '<' ? '<' : '>'))
|
||||
}
|
||||
Reference in New Issue
Block a user