236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
|
|
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 && (
|
||
|
|
<ul className="kbb-rp__barriers">
|
||
|
|
{notes.map((b, i) => {
|
||
|
|
const where = placeName(b, t);
|
||
|
|
return (
|
||
|
|
<li key={i} className={HARD_FLAGS.has(b.flag) ? 'is-hard' : ''}>
|
||
|
|
<span className="kbb-rp__num" aria-hidden="true">{start + i + 1}</span>
|
||
|
|
<strong>{t(`flag.${b.flag}` as Key)}</strong>{where ? ` · ${where}` : ''}{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
{showSteps && (
|
||
|
|
<ol className="kbb-rp__steps">
|
||
|
|
{result.segments.map((s, i) => <li key={i}>{segmentText(s, i === 0, t)}</li>)}
|
||
|
|
</ol>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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<HTMLHeadingElement>(null);
|
||
|
|
const textRef = useRef<HTMLDivElement>(null);
|
||
|
|
const [image, setImage] = useState<{ state: 'loading' } | { state: 'ready'; url: string } | { state: 'failed' }>({ state: 'loading' });
|
||
|
|
|
||
|
|
const legs = walkLegs(plan);
|
||
|
|
const starts = useMemo(() => legs.reduce<number[]>((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 (
|
||
|
|
<div className="kbb-card kbb-card--route" role="dialog" aria-modal="true" aria-labelledby="rp-title">
|
||
|
|
<div className="kbb-card__sheet">
|
||
|
|
<div className="kbb-card__bar">
|
||
|
|
<button type="button" className="usa-button" onClick={() => window.print()}>
|
||
|
|
<Icon name="print" size={3} className="margin-right-05" />{t('card.print')}
|
||
|
|
</button>
|
||
|
|
<button type="button" className="usa-button usa-button--outline" onClick={onClose}>
|
||
|
|
<Icon name="close" size={3} className="margin-right-05" />{t('card.close')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<header className="kbb-doc__head kbb-rp__head">
|
||
|
|
<BrandMark size={32} />
|
||
|
|
<div className="kbb-doc__brand">
|
||
|
|
<strong>{t('app.name')}</strong>
|
||
|
|
<span>{t(plan.kind === 'walk' ? 'print.walkTitle' : 'print.transitTitle')}</span>
|
||
|
|
</div>
|
||
|
|
<h2 id="rp-title" ref={heading} tabIndex={-1} className="kbb-rp__title">
|
||
|
|
<span className="kbb-rp__ab">A</span> {fromText} <span aria-hidden="true">→</span> <span className="kbb-rp__ab kbb-rp__ab--b">B</span> {toText}
|
||
|
|
</h2>
|
||
|
|
<span className="kbb-doc__date">{today}</span>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="kbb-rp__body">
|
||
|
|
<div className="kbb-rp__map">
|
||
|
|
{image.state === 'ready' && <img src={image.url} alt={t('print.mapAlt')} />}
|
||
|
|
{image.state === 'loading' && <p role="status" className="kbb-rp__note">{t('print.mapLoading')}</p>}
|
||
|
|
{image.state === 'failed' && <p role="alert" className="kbb-rp__note">{t('print.mapFail')}</p>}
|
||
|
|
</div>
|
||
|
|
<div className="kbb-rp__text" ref={textRef}>
|
||
|
|
{plan.kind === 'walk' ? <WalkText result={plan.result} /> : <TransitText journey={plan.journey} starts={starts} />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<footer className="kbb-rp__foot">
|
||
|
|
<span>{t('print.legend')}</span>
|
||
|
|
<span>{t('footer.attr')}</span>
|
||
|
|
</footer>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<>
|
||
|
|
<p className="kbb-rp__sum"><strong>{result.status === 'blocked' ? t('route.blocked') : notes.length ? t('route.okCheck', { n: notes.length }) : t('route.ok')}</strong><br />{t('route.summary', { km, min })}</p>
|
||
|
|
<h3>{t('route.barriers')}</h3>
|
||
|
|
{notes.length === 0 ? <p>{t('route.barriers.none')}</p> : <WalkLeg result={result} start={0} showSteps={false} />}
|
||
|
|
<h3>{t('print.steps')}</h3>
|
||
|
|
<WalkLeg result={{ ...result, barriers: [] }} start={0} />
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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]) => (
|
||
|
|
<li className="kbb-rp__leg kbb-rp__leg--ride">
|
||
|
|
<LineBadge route={r.dep.route} />
|
||
|
|
<div>
|
||
|
|
<strong>{t('tr.ride', { line: r.dep.line, dir: r.dep.headsign })}</strong>
|
||
|
|
<div>{t('tr.ride.detail', { time: clock(r.boardMs), from: r.from.name ?? '', end: clock(r.alightMs), stops: r.stops })}</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<p className="kbb-rp__sum">
|
||
|
|
<strong>{clock(leave)} → {clock(j.arriveMs)} · {t('tr.total', { n: Math.max(1, Math.round((j.arriveMs - leave) / 60000)) })}</strong> · {j.transfer ? t('tr.transferTag') : t('tr.direct')}<br />
|
||
|
|
{j.hard === 0 && j.caution === 0 ? t('tr.access.clean') : t('tr.access.some', { hard: j.hard, caution: j.caution })}
|
||
|
|
</p>
|
||
|
|
<ol className="kbb-rp__legs">
|
||
|
|
<li className="kbb-rp__leg">
|
||
|
|
<Icon name="directions_walk" size={3} />
|
||
|
|
<div>{t('tr.leave', { time: clock(leave) })} · {t('tr.walkTo', { n: walkTo, stop: first.from.name ?? '' })}<WalkMeta walk={j.walkTo} /><WalkLeg result={j.walkTo} start={starts[0]!} /></div>
|
||
|
|
</li>
|
||
|
|
{ride(first)}
|
||
|
|
{j.transfer && (
|
||
|
|
<>
|
||
|
|
<li className="kbb-rp__leg">
|
||
|
|
<Icon name="directions_walk" size={3} />
|
||
|
|
<div>{t('tr.transfer', { m: j.transfer.distM, n: j.transfer.walkMin, w: j.transfer.waitMin })}<WalkMeta walk={j.transfer.walk} /><WalkLeg result={j.transfer.walk} start={starts[1]!} /></div>
|
||
|
|
</li>
|
||
|
|
{ride(j.rides[1]!)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
<li className="kbb-rp__leg">
|
||
|
|
<Icon name="directions_walk" size={3} />
|
||
|
|
<div>{t('tr.walkEnd', { n: walkEnd, stop: last.to.name ?? '' })}<WalkMeta walk={j.walkFrom} /> · {t('tr.arrive', { time: clock(j.arriveMs) })}<WalkLeg result={j.walkFrom} start={starts[starts.length - 1]!} /></div>
|
||
|
|
</li>
|
||
|
|
</ol>
|
||
|
|
{j.next.length > 0 && <p>{t('tr.next', { list: j.next.map((d) => clock(d.expected)).join(', ') })}</p>}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|