mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Merge pull request #2330 from liketrek/feat/pdf-trip-route-map
Feat/pdf trip route map
This commit is contained in:
@@ -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<typeof import('./RouteCalculator')>()
|
||||
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<typeof calculateRouteWithLegs>,
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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<Answer[][][]> {
|
||||
const routed = emptyAnswers(plan)
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
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<TripRouteSummary> {
|
||||
const plan = planTripRoute(input, profile)
|
||||
if (!plan.length) return summariseTripRoute([])
|
||||
const deadline = timeoutMs ? new AbortController() : null
|
||||
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
|
||||
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
|
||||
try {
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal })
|
||||
return summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -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<TripRouteOverview>(EMPTY)
|
||||
const abortRef = useRef<AbortController | null>(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<void>)[] = []
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 === '<' ? '<' : '>'))
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export function TripExportModal({
|
||||
const [busy, setBusy] = useState<string | null>(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)
|
||||
|
||||
Reference in New Issue
Block a user