import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useI18n, type Key } from '../lib/i18n'; import type { LatLon } from '../lib/geo'; import { renderMapImage, type PrintLine, type PrintPin } from '../lib/printMap'; import { HARD_FLAGS, type RouteResult } from '../lib/route'; import { clock, type Journey } from '../lib/transit'; import { BrandMark } from './BrandMark'; import { LineBadge, WalkMeta } from './TransitUi'; import { placeName, realBarriers, segmentText } from './WalkDetails'; import { Icon } from './ui'; export type PrintPlan = { kind: 'walk'; result: RouteResult } | { kind: 'transit'; journey: Journey }; interface Props { plan: PrintPlan; ends: { from: LatLon; to: LatLon }; fromText: string; toText: string; basemap: 'map' | 'satellite'; onClose: () => void; } const MAP_W = 675, MAP_H = 800; // CSS px of the map image: the same shape as its slot on the sheet (135 x 160 mm) const START_PT = 10, ONE_COLUMN_MIN_PT = 8, MIN_PT = 6; const walkLegs = (plan: PrintPlan): RouteResult[] => plan.kind === 'walk' ? [plan.result] : [plan.journey.walkTo, ...(plan.journey.transfer ? [plan.journey.transfer.walk] : []), plan.journey.walkFrom]; /** Walking lines look as on screen: solid blue, brown dashes at cautions, red dots at hard barriers. */ function walkLines(r: RouteResult): PrintLine[] { return r.segments.map((s) => { const hard = s.flags.some((f) => HARD_FLAGS.has(f)); const warn = s.flags.some((f) => f !== 'surface-unknown' && f !== 'ramp-steps' && !HARD_FLAGS.has(f)); return hard ? { coords: s.coords, color: '#b50909', width: 6, dash: [0.4, 1.6] } : warn ? { coords: s.coords, color: '#a2461c', width: 5, dash: [2, 1.5] } : { coords: s.coords, color: '#005ea2', width: 5 }; }); } /** Everything drawn on the map; barrier pins carry the numbers used in the lists beside it. */ function overlay(plan: PrintPlan, ends: Props['ends'], starts: number[]): { lines: PrintLine[]; pins: PrintPin[] } { const legs = walkLegs(plan); const lines = legs.flatMap(walkLines); const pins: PrintPin[] = []; legs.forEach((r, i) => realBarriers(r).forEach((b, k) => { const hard = HARD_FLAGS.has(b.flag); pins.push({ at: b.at, label: String(starts[i]! + k + 1), color: hard ? '#b50909' : '#ffbe2e', textColor: hard ? '#ffffff' : '#1b1b1b' }); })); if (plan.kind === 'transit') { for (const r of plan.journey.rides) { const color = r.dep.route.mode === 'tram' ? '#a2461c' : '#54278f'; lines.push({ coords: r.path, color, width: 6 }); pins.push({ at: [r.from.lat, r.from.lon], label: r.dep.line, color, textColor: '#ffffff' }, { at: [r.to.lat, r.to.lon], label: r.dep.line, color, textColor: '#ffffff' }); } } pins.push({ at: ends.from, label: 'A', color: '#005ea2', textColor: '#ffffff' }, { at: ends.to, label: 'B', color: '#1b1b1b', textColor: '#ffffff' }); return { lines, pins }; } /** Barriers of one walking leg (numbered as on the map) and its turn-by-turn steps. */ function WalkLeg({ result, start, showSteps = true }: { result: RouteResult; start: number; showSteps?: boolean }) { const { t } = useI18n(); const notes = realBarriers(result); return ( <> {notes.length > 0 && ( )} {showSteps && (
    {result.segments.map((s, i) =>
  1. {segmentText(s, i === 0, t)}
  2. )}
)} ); } /** One A4 landscape sheet: the route on a map, framed to fit, and beside it where to go, when, and how to walk. */ export function RoutePrint({ plan, ends, fromText, toText, basemap, onClose }: Props) { const { t, date } = useI18n(); const heading = useRef(null); const textRef = useRef(null); const [image, setImage] = useState<{ state: 'loading' } | { state: 'ready'; url: string } | { state: 'failed' }>({ state: 'loading' }); const legs = walkLegs(plan); const starts = useMemo(() => legs.reduce((acc, r, i) => [...acc, i === 0 ? 0 : acc[i - 1]! + realBarriers(legs[i - 1]!).length], []), [plan]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { heading.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); // the page default is A4 portrait (access card); this sheet is landscape const style = document.createElement('style'); style.textContent = '@page { size: A4 landscape; margin: 8mm; }'; document.head.appendChild(style); return () => { window.removeEventListener('keydown', onKey); style.remove(); }; }, [onClose]); useEffect(() => { let alive = true; const o = overlay(plan, ends, starts); renderMapImage({ ...o, basemap, width: MAP_W, height: MAP_H }).then( (url) => { if (alive) setImage({ state: 'ready', url }); }, () => { if (alive) setImage({ state: 'failed' }); }, ); return () => { alive = false; }; }, [plan, ends, basemap, starts]); // everything has to fit on the one sheet: one column while it stays readable, two columns only when it does not useLayoutEffect(() => { const el = textRef.current; if (!el) return; const fits = (cols: number, pt: number) => { el.style.columnCount = String(cols); el.style.fontSize = `${pt}pt`; return el.scrollHeight <= el.clientHeight + 1 && el.scrollWidth <= el.clientWidth + 1; // two columns overflow sideways }; for (const [cols, floor] of [[1, ONE_COLUMN_MIN_PT], [2, MIN_PT]] as const) { for (let pt = START_PT; pt >= floor; pt -= 0.25) if (fits(cols, pt)) return; } fits(2, MIN_PT); // nothing fits: smallest, clipped }, [plan, fromText, toText]); const today = date(new Date().toISOString().slice(0, 10)); return (
{t('app.name')} {t(plan.kind === 'walk' ? 'print.walkTitle' : 'print.transitTitle')}

A {fromText} B {toText}

{today}
{image.state === 'ready' && {t('print.mapAlt')}} {image.state === 'loading' &&

{t('print.mapLoading')}

} {image.state === 'failed' &&

{t('print.mapFail')}

}
{plan.kind === 'walk' ? : }
{t('print.legend')} {t('footer.attr')}
); } function WalkText({ result }: { result: RouteResult }) { const { t } = useI18n(); const notes = realBarriers(result); const km = (result.distanceM / 1000).toFixed(2); const min = Math.max(1, Math.round(result.durationMin)); return ( <>

{result.status === 'blocked' ? t('route.blocked') : notes.length ? t('route.okCheck', { n: notes.length }) : t('route.ok')}
{t('route.summary', { km, min })}

{t('route.barriers')}

{notes.length === 0 ?

{t('route.barriers.none')}

: }

{t('print.steps')}

); } function TransitText({ journey: j, starts }: { journey: Journey; starts: number[] }) { const { t } = useI18n(); const first = j.rides[0]!, last = j.rides[j.rides.length - 1]!; const leave = first.boardMs - (j.walkTo.durationMin + 1) * 60000; const walkTo = Math.max(1, Math.round(j.walkTo.durationMin)), walkEnd = Math.max(1, Math.round(j.walkFrom.durationMin)); const ride = (r: Journey['rides'][number]) => (
  • {t('tr.ride', { line: r.dep.line, dir: r.dep.headsign })}
    {t('tr.ride.detail', { time: clock(r.boardMs), from: r.from.name ?? '', end: clock(r.alightMs), stops: r.stops })}
  • ); return ( <>

    {clock(leave)} → {clock(j.arriveMs)} · {t('tr.total', { n: Math.max(1, Math.round((j.arriveMs - leave) / 60000)) })} · {j.transfer ? t('tr.transferTag') : t('tr.direct')}
    {j.hard === 0 && j.caution === 0 ? t('tr.access.clean') : t('tr.access.some', { hard: j.hard, caution: j.caution })}

    1. {t('tr.leave', { time: clock(leave) })} · {t('tr.walkTo', { n: walkTo, stop: first.from.name ?? '' })}
    2. {ride(first)} {j.transfer && ( <>
    3. {t('tr.transfer', { m: j.transfer.distM, n: j.transfer.walkMin, w: j.transfer.waitMin })}
    4. {ride(j.rides[1]!)} )}
    5. {t('tr.walkEnd', { n: walkEnd, stop: last.to.name ?? '' })} · {t('tr.arrive', { time: clock(j.arriveMs) })}
    {j.next.length > 0 &&

    {t('tr.next', { list: j.next.map((d) => clock(d.expected)).join(', ') })}

    } ); }