Add PDF export of route, fix bugs
This commit is contained in:
parent
0ce1b7f376
commit
756dfc7966
10 changed files with 438 additions and 31 deletions
12
src/App.tsx
12
src/App.tsx
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AboutView } from './components/AboutView';
|
import { AboutView } from './components/AboutView';
|
||||||
import { AccessCard } from './components/AccessCard';
|
import { AccessCard } from './components/AccessCard';
|
||||||
|
import { RoutePrint, type PrintPlan } from './components/RoutePrint';
|
||||||
import { DashboardView } from './components/DashboardView';
|
import { DashboardView } from './components/DashboardView';
|
||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/LoginView';
|
||||||
import { MapView, type JourneyOverlay } from './components/MapView';
|
import { MapView, type JourneyOverlay } from './components/MapView';
|
||||||
|
|
@ -248,6 +249,8 @@ export default function App() {
|
||||||
const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]);
|
const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]);
|
||||||
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
|
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
|
||||||
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
|
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
|
||||||
|
// the sheet keeps the plan it was opened with: the timetable re-plans every 30 s and must not redraw a page being printed
|
||||||
|
const [printing, setPrinting] = useState<{ plan: PrintPlan; ends: { from: LatLon; to: LatLon }; fromText: string; toText: string } | null>(null);
|
||||||
const [routeTab, setRouteTab] = useState<'walk' | 'transit'>('walk');
|
const [routeTab, setRouteTab] = useState<'walk' | 'transit'>('walk');
|
||||||
const transitStops = useMemo(() => realPlaces.filter((p) => p.cat === 'transit'), [realPlaces]);
|
const transitStops = useMemo(() => realPlaces.filter((p) => p.cat === 'transit'), [realPlaces]);
|
||||||
// warm the timetable as soon as a route exists, so the "Komunikacja" tab is usually instant (skipped on metered links)
|
// warm the timetable as soon as a route exists, so the "Komunikacja" tab is usually instant (skipped on metered links)
|
||||||
|
|
@ -267,6 +270,11 @@ export default function App() {
|
||||||
stops: j.rides.flatMap((r) => [{ at: [r.from.lat, r.from.lon] as LatLon, label: `${r.dep.line} · ${r.from.name ?? ''}` }, { at: [r.to.lat, r.to.lon] as LatLon, label: `${r.dep.line} · ${r.to.name ?? ''}` }]),
|
stops: j.rides.flatMap((r) => [{ at: [r.from.lat, r.from.lon] as LatLon, label: `${r.dep.line} · ${r.from.name ?? ''}` }, { at: [r.to.lat, r.to.lon] as LatLon, label: `${r.dep.line} · ${r.to.name ?? ''}` }]),
|
||||||
};
|
};
|
||||||
}, [view, routeTab, planner]);
|
}, [view, routeTab, planner]);
|
||||||
|
const printPlan = useMemo<PrintPlan | null>(() => {
|
||||||
|
if (routeTab === 'walk') return route && route.status !== 'none' ? { kind: 'walk', result: route } : null;
|
||||||
|
const j = planner.status === 'ready' ? planner.journeys[planner.selected] : undefined;
|
||||||
|
return j ? { kind: 'transit', journey: j } : null;
|
||||||
|
}, [routeTab, route, planner]);
|
||||||
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
|
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
|
||||||
setRouteOptions(o);
|
setRouteOptions(o);
|
||||||
const sel = Math.max(0, o?.findIndex((x) => x.recommended) ?? 0); // the recommended variant is the default
|
const sel = Math.max(0, o?.findIndex((x) => x.recommended) ?? 0); // the recommended variant is the default
|
||||||
|
|
@ -530,7 +538,7 @@ export default function App() {
|
||||||
{view === 'route' && (
|
{view === 'route' && (
|
||||||
<RouteView
|
<RouteView
|
||||||
graph={graph} labels={labels} profile={profile}
|
graph={graph} labels={labels} profile={profile}
|
||||||
tab={routeTab} onTab={setRouteTab} transitUnavailable={transitUnavailable} planner={planner} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
|
tab={routeTab} onTab={setRouteTab} transitUnavailable={transitUnavailable} planner={planner} onPrint={() => { if (printPlan && routeEnds) setPrinting({ plan: printPlan, ends: routeEnds, fromText: routeForm.fromText, toText: routeForm.toText }); }} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
|
||||||
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
|
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
|
||||||
onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
|
onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -565,6 +573,8 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{printing && <RoutePrint {...printing} basemap={online ? basemap : 'map'} onClose={() => setPrinting(null)} />}
|
||||||
|
|
||||||
{card && profile && (
|
{card && profile && (
|
||||||
<AccessCard
|
<AccessCard
|
||||||
place={card}
|
place={card}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as maplibregl from 'maplibre-gl';
|
import * as maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, Map as MLMap, StyleSpecification } from 'maplibre-gl';
|
import type { GeoJSONSource, Map as MLMap } from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
|
|
@ -9,34 +9,13 @@ import { HARD_FLAGS, type RouteResult } from '../lib/route';
|
||||||
import type { Place, Verdict } from '../lib/types';
|
import type { Place, Verdict } from '../lib/types';
|
||||||
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
|
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
|
||||||
import { KRAKOW_BOUNDS } from '../config';
|
import { KRAKOW_BOUNDS } from '../config';
|
||||||
import { registerTileProtocol, tileTemplate } from '../lib/tileProtocol';
|
import { registerTileProtocol } from '../lib/tileProtocol';
|
||||||
|
import { MAP_STYLE } from '../lib/mapStyle';
|
||||||
import { VERDICT_STYLE } from './ui';
|
import { VERDICT_STYLE } from './ui';
|
||||||
|
|
||||||
registerTileProtocol();
|
registerTileProtocol();
|
||||||
maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href);
|
maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href);
|
||||||
|
|
||||||
const STYLE: StyleSpecification = {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
osm: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [tileTemplate('osm')],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 19,
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
|
||||||
};
|
|
||||||
STYLE.sources.sat = {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [tileTemplate('sat')],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 19,
|
|
||||||
attribution: 'Imagery © Esri, Maxar, Earthstar Geographics',
|
|
||||||
};
|
|
||||||
STYLE.layers.push({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: 'none' } });
|
|
||||||
|
|
||||||
function applyBasemap(map: MLMap, b: 'map' | 'satellite') {
|
function applyBasemap(map: MLMap, b: 'map' | 'satellite') {
|
||||||
map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none');
|
map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none');
|
||||||
map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none');
|
map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none');
|
||||||
|
|
@ -170,7 +149,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
|
||||||
const [s, w, n, e] = bbox;
|
const [s, w, n, e] = bbox;
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: ref.current,
|
container: ref.current,
|
||||||
style: STYLE,
|
style: MAP_STYLE,
|
||||||
center: [(w + e) / 2, (s + n) / 2],
|
center: [(w + e) / 2, (s + n) / 2],
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
minZoom: 12,
|
minZoom: 12,
|
||||||
|
|
|
||||||
235
src/components/RoutePrint.tsx
Normal file
235
src/components/RoutePrint.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,9 +49,11 @@ interface Props {
|
||||||
transitUnavailable: boolean;
|
transitUnavailable: boolean;
|
||||||
planner: PlannerState;
|
planner: PlannerState;
|
||||||
onShowTransit: () => void;
|
onShowTransit: () => void;
|
||||||
|
/** Open the printable one-page sheet for what is on screen. */
|
||||||
|
onPrint: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteView({ graph, labels, profile, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, onFocus, tab, onTab, transitUnavailable, planner, onShowTransit }: Props) {
|
export function RouteView({ graph, labels, profile, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, onFocus, tab, onTab, transitUnavailable, planner, onShowTransit, onPrint }: Props) {
|
||||||
const result = options?.[selected]?.result ?? null;
|
const result = options?.[selected]?.result ?? null;
|
||||||
const { t, tm } = useI18n();
|
const { t, tm } = useI18n();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -68,6 +70,7 @@ export function RouteView({ graph, labels, profile, form, onForm, options, ends,
|
||||||
window.setTimeout(() => setLocMsg(null), 5000),
|
window.setTimeout(() => setLocMsg(null), 5000),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
const canPrint = tab === 'walk' ? !!result && result.status !== 'none' : planner.status === 'ready' && planner.journeys.length > 0;
|
||||||
const resultRef = useRef<HTMLDivElement>(null);
|
const resultRef = useRef<HTMLDivElement>(null);
|
||||||
// a long walk, or no barrier-free walk at all, is where public transport is the better answer
|
// a long walk, or no barrier-free walk at all, is where public transport is the better answer
|
||||||
const recommendTransit = transitUnavailable ? null : transitRecommendation(result);
|
const recommendTransit = transitUnavailable ? null : transitRecommendation(result);
|
||||||
|
|
@ -149,6 +152,7 @@ export function RouteView({ graph, labels, profile, form, onForm, options, ends,
|
||||||
<div aria-live="polite" ref={resultRef} tabIndex={-1} className="margin-top-3 kbb-result">
|
<div aria-live="polite" ref={resultRef} tabIndex={-1} className="margin-top-3 kbb-result">
|
||||||
{error && <Alert kind="error" role="alert">{error}</Alert>}
|
{error && <Alert kind="error" role="alert">{error}</Alert>}
|
||||||
{ends && (
|
{ends && (
|
||||||
|
<div className="kbb-modebar">
|
||||||
<div className="kbb-modes" role="group" aria-label={t('route.tabs')}>
|
<div className="kbb-modes" role="group" aria-label={t('route.tabs')}>
|
||||||
{(['walk', 'transit'] as const).map((m) => (
|
{(['walk', 'transit'] as const).map((m) => (
|
||||||
<button key={m} type="button" aria-pressed={tab === m} disabled={m === 'transit' && transitUnavailable} className={`kbb-mode ${tab === m ? 'is-on' : ''}`} onClick={() => onTab(m)}>
|
<button key={m} type="button" aria-pressed={tab === m} disabled={m === 'transit' && transitUnavailable} className={`kbb-mode ${tab === m ? 'is-on' : ''}`} onClick={() => onTab(m)}>
|
||||||
|
|
@ -157,6 +161,12 @@ export function RouteView({ graph, labels, profile, form, onForm, options, ends,
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{canPrint && (
|
||||||
|
<button type="button" className="kbb-iconbtn kbb-modebar__print" onClick={onPrint} aria-label={t('print.btn')} title={t('print.btn')}>
|
||||||
|
<Icon name="print" size={3} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{ends && transitUnavailable && <p className="font-sans-3xs text-base-dark margin-y-05" role="status">{t('tr.unavailable')}</p>}
|
{ends && transitUnavailable && <p className="font-sans-3xs text-base-dark margin-y-05" role="status">{t('tr.unavailable')}</p>}
|
||||||
{ends && tab === 'walk' && recommendTransit && (
|
{ends && tab === 'walk' && recommendTransit && (
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ const stopAccess = (p: Place): Key => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Distance and barriers of one walking leg; every leg is routed with the user's limits. */
|
/** Distance and barriers of one walking leg; every leg is routed with the user's limits. */
|
||||||
function WalkMeta({ walk }: { walk: RouteResult }) {
|
export function WalkMeta({ walk }: { walk: RouteResult }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const notes = walk.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
|
const notes = walk.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
|
||||||
const hard = notes.filter((b) => HARD_FLAGS.has(b.flag)).length;
|
const hard = notes.filter((b) => HARD_FLAGS.has(b.flag)).length;
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ import { Icon } from './ui';
|
||||||
type T = ReturnType<typeof useI18n>['t'];
|
type T = ReturnType<typeof useI18n>['t'];
|
||||||
|
|
||||||
/** Where a segment or barrier is: its own name, else the park it crosses / the street it runs along. */
|
/** Where a segment or barrier is: its own name, else the park it crosses / the street it runs along. */
|
||||||
function placeName(x: { name: string | null; near?: AreaLabel | undefined }, t: T): string | null {
|
export function placeName(x: { name: string | null; near?: AreaLabel | undefined }, t: T): string | null {
|
||||||
if (x.name) return x.name;
|
if (x.name) return x.name;
|
||||||
return x.near ? t(`seg.near.${x.near.kind}` as Key, { name: x.near.name }) : null;
|
return x.near ? t(`seg.near.${x.near.kind}` as Key, { name: x.near.name }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
|
export function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
|
||||||
const kindKey = `seg.hw.${s.hw}`;
|
const kindKey = `seg.hw.${s.hw}`;
|
||||||
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
|
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
|
||||||
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
|
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
|
||||||
|
|
@ -27,10 +27,13 @@ function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI1
|
||||||
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
|
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Barriers worth showing: everything except "surface unknown" and "ramp with steps" (those only feed the route quality figure). */
|
||||||
|
export const realBarriers = (r: RouteResult) => r.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
|
||||||
|
|
||||||
/** Barriers and cautions of a walking route plus its turn-by-turn steps: the same on the walking tab and in every walking leg of a transit journey. */
|
/** Barriers and cautions of a walking route plus its turn-by-turn steps: the same on the walking tab and in every walking leg of a transit journey. */
|
||||||
export function WalkDetails({ result, onFocus }: { result: RouteResult; onFocus: (p: LatLon) => void }) {
|
export function WalkDetails({ result, onFocus }: { result: RouteResult; onFocus: (p: LatLon) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
|
const notes = realBarriers(result);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -543,6 +543,14 @@ const pl = {
|
||||||
'card.generated': 'Wygenerowano {date} z danych OpenStreetMap i ZTP Kraków. To informacja pomocnicza, nie gwarancja.',
|
'card.generated': 'Wygenerowano {date} z danych OpenStreetMap i ZTP Kraków. To informacja pomocnicza, nie gwarancja.',
|
||||||
'card.print': 'Drukuj lub zapisz jako PDF',
|
'card.print': 'Drukuj lub zapisz jako PDF',
|
||||||
'card.close': 'Zamknij kartę',
|
'card.close': 'Zamknij kartę',
|
||||||
|
'print.btn': 'Drukuj trasę',
|
||||||
|
'print.walkTitle': 'Trasa pieszo',
|
||||||
|
'print.transitTitle': 'Komunikacja miejska',
|
||||||
|
'print.steps': 'Jak iść',
|
||||||
|
'print.mapAlt': 'Mapa trasy z zaznaczonymi barierami i przystankami',
|
||||||
|
'print.mapLoading': 'Przygotowuję mapę…',
|
||||||
|
'print.mapFail': 'Nie udało się przygotować mapy. Opis trasy obok jest kompletny.',
|
||||||
|
'print.legend': 'Numery na mapie odpowiadają liście barier i uwag. Linia ciągła — brak barier, kreskowana — uwaga, kropkowana — bariera.',
|
||||||
'card.legend': 'Wiarygodność: potwierdzone = ma datę kontroli; zmapowane = wpis w OSM; mogło się zmienić = starszy niż 24 mies.; wywnioskowane = z obiektów w pobliżu.',
|
'card.legend': 'Wiarygodność: potwierdzone = ma datę kontroli; zmapowane = wpis w OSM; mogło się zmienić = starszy niż 24 mies.; wywnioskowane = z obiektów w pobliżu.',
|
||||||
'reach.title': 'Zasięg bez barier',
|
'reach.title': 'Zasięg bez barier',
|
||||||
'reach.intro': 'Dokąd dojdziesz stąd bez schodów i barier, według Twojego profilu — w czasie:',
|
'reach.intro': 'Dokąd dojdziesz stąd bez schodów i barier, według Twojego profilu — w czasie:',
|
||||||
|
|
@ -1336,6 +1344,14 @@ const en: Dict = {
|
||||||
'card.generated': 'Generated {date} from OpenStreetMap and ZTP Kraków data. Guidance only, not a guarantee.',
|
'card.generated': 'Generated {date} from OpenStreetMap and ZTP Kraków data. Guidance only, not a guarantee.',
|
||||||
'card.print': 'Print or save as PDF',
|
'card.print': 'Print or save as PDF',
|
||||||
'card.close': 'Close card',
|
'card.close': 'Close card',
|
||||||
|
'print.btn': 'Print route',
|
||||||
|
'print.walkTitle': 'Walking route',
|
||||||
|
'print.transitTitle': 'Public transport',
|
||||||
|
'print.steps': 'How to walk',
|
||||||
|
'print.mapAlt': 'Route map with barriers and stops marked',
|
||||||
|
'print.mapLoading': 'Preparing the map…',
|
||||||
|
'print.mapFail': 'The map could not be prepared. The directions beside it are complete.',
|
||||||
|
'print.legend': 'Numbers on the map match the list of barriers and cautions. Solid line — no barriers, dashed — caution, dotted — barrier.',
|
||||||
'card.legend': 'Reliability: confirmed = has a survey date; mapped = OSM entry; may have changed = older than 24 months; inferred = from nearby objects.',
|
'card.legend': 'Reliability: confirmed = has a survey date; mapped = OSM entry; may have changed = older than 24 months; inferred = from nearby objects.',
|
||||||
'reach.title': 'Barrier-free reach',
|
'reach.title': 'Barrier-free reach',
|
||||||
'reach.intro': 'Where you can get from here without steps or barriers, for your profile — within:',
|
'reach.intro': 'Where you can get from here without steps or barriers, for your profile — within:',
|
||||||
|
|
|
||||||
25
src/lib/mapStyle.ts
Normal file
25
src/lib/mapStyle.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// The raster base map shared by the live map and the printed route sheet: street map and satellite, switched by layer visibility.
|
||||||
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
|
import { tileTemplate } from './tileProtocol';
|
||||||
|
|
||||||
|
export const MAP_STYLE: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
osm: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [tileTemplate('osm')],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||||
|
};
|
||||||
|
MAP_STYLE.sources.sat = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [tileTemplate('sat')],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 19,
|
||||||
|
attribution: 'Imagery © Esri, Maxar, Earthstar Geographics',
|
||||||
|
};
|
||||||
|
MAP_STYLE.layers.push({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: 'none' } });
|
||||||
93
src/lib/printMap.ts
Normal file
93
src/lib/printMap.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Renders a route to a still image for the printed sheet. It uses its own off-screen map (so it works whatever the live map is doing,
|
||||||
|
// even hidden behind the phone's list view) and draws the route on top with the 2D canvas, so no map layers are needed.
|
||||||
|
import * as maplibregl from 'maplibre-gl';
|
||||||
|
import type { LatLon } from './geo';
|
||||||
|
import { MAP_STYLE } from './mapStyle';
|
||||||
|
import { registerTileProtocol } from './tileProtocol';
|
||||||
|
|
||||||
|
export interface PrintLine { coords: LatLon[]; color: string; width: number; /** Dash and gap lengths in multiples of the line width. */ dash?: readonly number[] }
|
||||||
|
export interface PrintPin { at: LatLon; label: string; color: string; textColor: string }
|
||||||
|
export interface MapImageSpec { lines: PrintLine[]; pins: PrintPin[]; basemap: 'map' | 'satellite'; width: number; height: number }
|
||||||
|
|
||||||
|
const RATIO = 2;
|
||||||
|
const IDLE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
const ATTRIBUTION = { map: '© OpenStreetMap', satellite: 'Imagery © Esri, Maxar, Earthstar Geographics' } as const;
|
||||||
|
|
||||||
|
function stroke(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[], color: string, width: number, dash?: readonly number[]) {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
ctx.beginPath();
|
||||||
|
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = dash ? 'butt' : 'round';
|
||||||
|
ctx.setLineDash(dash ? dash.map((d) => d * width) : []);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pill(ctx: CanvasRenderingContext2D, x: number, y: number, pin: PrintPin) {
|
||||||
|
const fontPx = 13 * RATIO;
|
||||||
|
ctx.font = `700 ${fontPx}px sans-serif`;
|
||||||
|
const w = Math.max(fontPx * 1.7, ctx.measureText(pin.label).width + fontPx * 0.9);
|
||||||
|
const h = fontPx * 1.7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x - w / 2, y - h / 2, w, h, h / 2);
|
||||||
|
ctx.fillStyle = pin.color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 2 * RATIO;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = pin.textColor;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(pin.label, x, y + RATIO * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A JPEG data URL of the route on the chosen base map, framed to fit `width` x `height` (CSS px, drawn at double resolution). */
|
||||||
|
export async function renderMapImage(spec: MapImageSpec): Promise<string> {
|
||||||
|
registerTileProtocol();
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
[...spec.lines.flatMap((l) => l.coords), ...spec.pins.map((p) => p.at)].forEach((c) => bounds.extend([c[1], c[0]]));
|
||||||
|
if (bounds.isEmpty()) throw new Error('nothing to draw');
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.cssText = `position:fixed;left:-10000px;top:0;width:${spec.width}px;height:${spec.height}px;visibility:hidden`;
|
||||||
|
document.body.appendChild(box);
|
||||||
|
const style = structuredClone(MAP_STYLE);
|
||||||
|
for (const l of style.layers) l.layout = { ...l.layout, visibility: (l.id === 'sat') === (spec.basemap === 'satellite') ? 'visible' : 'none' };
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: box, style, bounds, fitBoundsOptions: { padding: 50 * 1, maxZoom: 18 }, interactive: false, attributionControl: false,
|
||||||
|
fadeDuration: 0, pixelRatio: RATIO, canvasContextAttributes: { preserveDrawingBuffer: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const done = () => { window.clearTimeout(timer); resolve(); };
|
||||||
|
const timer = window.setTimeout(done, IDLE_TIMEOUT_MS); // a tile that never comes must not block printing
|
||||||
|
map.once('idle', done);
|
||||||
|
});
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = spec.width * RATIO;
|
||||||
|
out.height = spec.height * RATIO;
|
||||||
|
const ctx = out.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('no 2d canvas');
|
||||||
|
ctx.drawImage(map.getCanvas(), 0, 0, out.width, out.height);
|
||||||
|
const px = (c: LatLon) => { const p = map.project([c[1], c[0]]); return { x: p.x * RATIO, y: p.y * RATIO }; };
|
||||||
|
for (const l of spec.lines) stroke(ctx, l.coords.map(px), '#ffffff', (l.width + 4) * RATIO);
|
||||||
|
for (const l of spec.lines) stroke(ctx, l.coords.map(px), l.color, l.width * RATIO, l.dash);
|
||||||
|
for (const p of spec.pins) { const { x, y } = px(p.at); pill(ctx, x, y, p); }
|
||||||
|
ctx.font = `${10 * RATIO}px sans-serif`;
|
||||||
|
const text = ATTRIBUTION[spec.basemap];
|
||||||
|
const tw = ctx.measureText(text).width;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||||
|
ctx.fillRect(out.width - tw - 12 * RATIO, out.height - 18 * RATIO, tw + 12 * RATIO, 18 * RATIO);
|
||||||
|
ctx.fillStyle = '#1b1b1b';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, out.width - 6 * RATIO, out.height - 9 * RATIO);
|
||||||
|
return out.toDataURL('image/jpeg', 0.88);
|
||||||
|
} finally {
|
||||||
|
map.remove();
|
||||||
|
box.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -636,3 +636,39 @@ button:focus:not(:focus-visible), summary:focus:not(:focus-visible), [role='tab'
|
||||||
.kbb-more.usa-button:hover { background: #f0f0f0; color: #005ea2; text-decoration: underline; }
|
.kbb-more.usa-button:hover { background: #f0f0f0; color: #005ea2; text-decoration: underline; }
|
||||||
.kbb-more.usa-button:focus-visible { outline: 3px solid #2491ff; outline-offset: 1px; }
|
.kbb-more.usa-button:focus-visible { outline: 3px solid #2491ff; outline-offset: 1px; }
|
||||||
.kbb-more .usa-icon { flex: none; fill: currentColor; }
|
.kbb-more .usa-icon { flex: none; fill: currentColor; }
|
||||||
|
|
||||||
|
/* ---- printed route sheet (A4 landscape, one page) ---- */
|
||||||
|
.kbb-card--route .kbb-card__sheet { box-sizing: border-box; width: 297mm; max-width: none; padding: 8mm; display: flex; flex-direction: column; }
|
||||||
|
.kbb-card--route .kbb-card__bar { flex: none; }
|
||||||
|
.kbb-rp__head { flex: none; padding-bottom: 0.4rem; }
|
||||||
|
.kbb-rp__title { flex: 2; min-width: 0; margin: 0; font-size: 1.05rem; line-height: 1.25; text-align: center; }
|
||||||
|
.kbb-rp__title:focus { outline: none; }
|
||||||
|
.kbb-rp__ab { display: inline-block; min-width: 1.4em; padding: 0 0.25em; border-radius: 0.8em; background: #005ea2; color: #fff; text-align: center; font-weight: 700; }
|
||||||
|
.kbb-rp__ab--b { background: #1b1b1b; }
|
||||||
|
.kbb-rp__body { flex: none; height: 160mm; display: grid; grid-template-columns: 135mm minmax(0, 1fr); gap: 5mm; margin-top: 3mm; }
|
||||||
|
.kbb-rp__map { min-height: 0; background: #e9ecef; border: 1px solid #dfe1e2; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||||
|
.kbb-rp__map img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.kbb-rp__note { margin: 0; padding: 1rem; text-align: center; }
|
||||||
|
.kbb-rp__text { min-height: 0; overflow: hidden; font-size: 10pt; line-height: 1.3; column-count: 1; column-gap: 5mm; column-fill: auto; }
|
||||||
|
.kbb-rp__text .font-sans-xs { font-size: inherit !important; }
|
||||||
|
.kbb-rp__text h3, .kbb-rp__leg, .kbb-rp__sum { break-inside: avoid; }
|
||||||
|
.kbb-rp__text h3 { break-after: avoid; }
|
||||||
|
.kbb-rp__text h3 { margin: 0.6em 0 0.2em; font-size: 1.05em; }
|
||||||
|
.kbb-rp__text p { margin: 0 0 0.4em; }
|
||||||
|
.kbb-rp__sum { padding: 0.35em 0.6em; border-left: 0.35em solid #005ea2; background: #f0f4f8; }
|
||||||
|
.kbb-rp__barriers, .kbb-rp__steps, .kbb-rp__legs { margin: 0.2em 0; padding: 0; list-style: none; }
|
||||||
|
.kbb-rp__barriers li { margin: 0.15em 0; }
|
||||||
|
.kbb-rp__barriers li.is-hard { color: #8b0a03; }
|
||||||
|
.kbb-rp__num { display: inline-block; min-width: 1.5em; margin-right: 0.4em; border-radius: 0.8em; background: #ffbe2e; color: #1b1b1b; text-align: center; font-weight: 700; }
|
||||||
|
.is-hard > .kbb-rp__num { background: #b50909; color: #fff; }
|
||||||
|
.kbb-rp__steps { list-style: decimal; padding-left: 1.4em; font-size: 0.92em; }
|
||||||
|
.kbb-rp__leg { display: flex; gap: 0.5em; align-items: flex-start; margin: 0 0 0.45em; padding-bottom: 0.35em; border-bottom: 1px solid #dfe1e2; }
|
||||||
|
.kbb-rp__leg > .usa-icon { flex: none; fill: #005ea2; }
|
||||||
|
.kbb-rp__foot { flex: none; display: flex; justify-content: space-between; gap: 1rem; margin-top: 2mm; padding-top: 1.5mm; border-top: 1px solid #dfe1e2; font-size: 7.5pt; color: #565c65; }
|
||||||
|
@media print {
|
||||||
|
.kbb-card--route .kbb-card__sheet { width: 281mm; height: 194mm; padding: 0; overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbb-modebar { display: flex; align-items: stretch; gap: 0.5rem; margin: 0 0 1rem; }
|
||||||
|
.kbb-modebar .kbb-modes { flex: 1; margin: 0; }
|
||||||
|
.kbb-modebar__print { flex: none; width: 2.75rem; height: auto; }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue