@motionstudies/web 0.1.0-alpha.3 → 0.1.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -2
- package/components/AirportBoard.d.ts +27 -0
- package/components/AirportBoard.js +28 -0
- package/components/AirportHeroCard.d.ts +60 -0
- package/components/AirportHeroCard.js +31 -0
- package/components/BusStopHeroCard.d.ts +37 -0
- package/components/BusStopHeroCard.js +14 -0
- package/components/DotMatrixBoard.d.ts +31 -0
- package/components/DotMatrixBoard.js +84 -0
- package/components/LazyButtonTooltips.d.ts +2 -0
- package/components/LazyButtonTooltips.js +8 -0
- package/components/NetworkVehicleHeroCard.d.ts +13 -0
- package/components/NetworkVehicleHeroCard.js +13 -0
- package/components/RailStationHeroCard.d.ts +46 -0
- package/components/RailStationHeroCard.js +20 -0
- package/components/SbbDepartureBoard.d.ts +26 -0
- package/components/SbbDepartureBoard.js +29 -0
- package/components/SplitFlapBoard.d.ts +25 -0
- package/components/SplitFlapBoard.js +58 -0
- package/components/StudyTimeline.d.ts +25 -0
- package/components/StudyTimeline.js +14 -0
- package/components/TimelineScrubber.d.ts +19 -0
- package/components/TimelineScrubber.js +26 -0
- package/components/TransportHeroBoard.d.ts +28 -0
- package/components/TransportHeroBoard.js +15 -0
- package/components/VehicleHeroCard.d.ts +51 -0
- package/components/VehicleHeroCard.js +21 -0
- package/components/airport-hero-card.css +68 -0
- package/components/dot-matrix-board.css +49 -0
- package/components/dot-matrix-font.d.ts +2 -0
- package/components/dot-matrix-font.js +61 -0
- package/components/sbb-departure-board.css +49 -0
- package/components/split-flap-board.css +45 -0
- package/components/study-timeline.css +8 -0
- package/components/timeline-scrubber.css +16 -0
- package/components/transport-hero-cards.css +39 -0
- package/components/vehicle-hero-card.css +64 -0
- package/fonts/DM-Mono-OFL.txt +93 -0
- package/fonts/Inter-OFL.txt +93 -0
- package/fonts/dm-mono-latin-300.woff2 +0 -0
- package/fonts/dm-mono-latin-400.woff2 +0 -0
- package/fonts/dm-mono-latin-500.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-300.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-400.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-500.woff2 +0 -0
- package/fonts/inter-cyrillic-400-600.woff2 +0 -0
- package/fonts/inter-cyrillic-ext-400-600.woff2 +0 -0
- package/fonts/inter-greek-400-600.woff2 +0 -0
- package/fonts/inter-greek-ext-400-600.woff2 +0 -0
- package/fonts/inter-latin-400-600.woff2 +0 -0
- package/fonts/inter-latin-ext-400-600.woff2 +0 -0
- package/fonts/inter-vietnamese-400-600.woff2 +0 -0
- package/fonts.css +105 -0
- package/mount-motion-study.d.ts +7 -2
- package/mount-motion-study.js +2 -2
- package/package.json +96 -5
- package/study-layout.css +65 -0
- package/use-airport-feed.d.ts +9 -0
- package/use-airport-feed.js +72 -0
- package/use-browser-location.d.ts +13 -0
- package/use-browser-location.js +39 -0
- package/use-json-asset.d.ts +9 -0
- package/use-json-asset.js +45 -0
- package/use-now-clock.d.ts +11 -0
- package/use-now-clock.js +45 -0
- package/use-pattern-network-day.d.ts +17 -0
- package/use-pattern-network-day.js +41 -0
- package/use-progressive-air-day.d.ts +1 -0
- package/use-progressive-chunks.d.ts +3 -0
- package/use-progressive-chunks.js +13 -4
- package/use-progressive-network-day.d.ts +1 -0
- package/use-progressive-road-study.d.ts +1 -0
- package/use-transition-value.d.ts +12 -0
- package/use-transition-value.js +46 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
3
|
+
import { matrixPath } from './dot-matrix-font.js';
|
|
4
|
+
function bounded(value, fallback, min, max) {
|
|
5
|
+
return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.floor(value))) : fallback;
|
|
6
|
+
}
|
|
7
|
+
function MatrixText({ value, characters, align = 'left', preserveCase = false }) {
|
|
8
|
+
const patternId = useId();
|
|
9
|
+
const letters = Array.from(preserveCase ? value : value.toLocaleUpperCase());
|
|
10
|
+
const visible = letters.length > characters ? [...letters.slice(0, characters - 1), '…'] : letters;
|
|
11
|
+
const text = visible.join('');
|
|
12
|
+
const path = matrixPath(text);
|
|
13
|
+
const offset = align === 'right' ? (characters - visible.length) * 6 : 0;
|
|
14
|
+
return _jsxs("span", { className: "ms-matrix-text", title: value, children: [_jsx("span", { className: "ms-matrix-sr-only", children: value }), _jsxs("svg", { "aria-hidden": "true", viewBox: `0 0 ${characters * 6} 7`, preserveAspectRatio: align === 'right' ? 'xMaxYMid meet' : 'xMinYMid meet', children: [_jsx("defs", { children: _jsx("pattern", { id: patternId, width: "1", height: "1", patternUnits: "userSpaceOnUse", children: _jsx("circle", { cx: ".5", cy: ".5", r: ".36" }) }) }), _jsx("rect", { width: "100%", height: "7", fill: `url(#${patternId})`, className: "ms-matrix-text__unlit" }), path === undefined
|
|
15
|
+
? _jsx("text", { x: align === 'right' ? characters * 6 : 0, y: "6.2", textAnchor: align === 'right' ? 'end' : 'start', fontSize: "7", fontFamily: "ui-monospace, monospace", textLength: Math.max(1, visible.length * 6 - 1), lengthAdjust: "spacingAndGlyphs", fill: "currentColor", children: text })
|
|
16
|
+
: _jsx("path", { transform: `translate(${offset},0)`, d: path, fill: "currentColor" })] })] });
|
|
17
|
+
}
|
|
18
|
+
/** The consumer owns row order, arrival estimates and freshness, as with SplitFlapBoard. */
|
|
19
|
+
export function DotMatrixBoard({ label, columns, rows, lineCount = 6, minRowHeight = 34, loading = false, loadingMessage = 'Loading departures…', emptyMessage = 'No departures.', onSelectRow, selectedRowId, selectionColumn, className = '', style, variant = 'bus', heading, headingColumnSpan = 1, footerLabel, clockLabel }) {
|
|
20
|
+
const screen = useRef(null);
|
|
21
|
+
const boardId = useId();
|
|
22
|
+
const [size, setSize] = useState({ width: 640, height: 244 });
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const element = screen.current;
|
|
25
|
+
if (!element)
|
|
26
|
+
return;
|
|
27
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
28
|
+
const width = Math.floor(entry.contentRect.width);
|
|
29
|
+
const height = Math.floor(entry.contentRect.height);
|
|
30
|
+
setSize((previous) => previous.width === width && previous.height === height ? previous : { width, height });
|
|
31
|
+
});
|
|
32
|
+
observer.observe(element);
|
|
33
|
+
return () => observer.disconnect();
|
|
34
|
+
}, []);
|
|
35
|
+
const count = lineCount === 'auto'
|
|
36
|
+
? bounded(size.height / bounded(minRowHeight, 34, 20, 120), 6, 1, 30)
|
|
37
|
+
: bounded(lineCount, 6, 1, 30);
|
|
38
|
+
const rowHeight = size.height / count;
|
|
39
|
+
const weights = columns.map((column) => bounded(column.characters, 12, 1, 80));
|
|
40
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
41
|
+
const characterWidth = Math.min(15, Math.max(7, rowHeight * .45), Math.max(9, (size.width - columns.length * 16) / Math.max(1, totalWeight)));
|
|
42
|
+
const minimums = columns.map((column) => bounded(column.minCharacters ?? 3, 3, 1, 40) * characterWidth + 16);
|
|
43
|
+
const totalMinimum = minimums.reduce((sum, width) => sum + width, 0);
|
|
44
|
+
const widths = columns.map((_, index) => size.width < totalMinimum
|
|
45
|
+
? size.width * minimums[index] / totalMinimum
|
|
46
|
+
: minimums[index] + (size.width - totalMinimum) * weights[index] / totalWeight);
|
|
47
|
+
const empty = !loading && rows.length === 0;
|
|
48
|
+
const message = loading ? loadingMessage : empty ? emptyMessage : '';
|
|
49
|
+
const rail = variant === 'uk-rail';
|
|
50
|
+
const span = bounded(headingColumnSpan, 1, 1, Math.max(1, columns.length));
|
|
51
|
+
const capacity = (width) => bounded((width - 16) / characterWidth, 1, 1, 80);
|
|
52
|
+
const slots = [];
|
|
53
|
+
if (!loading && !empty) {
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
if (slots.length >= count)
|
|
56
|
+
break;
|
|
57
|
+
// Keep a departure and its detail together unless the board has only one slot.
|
|
58
|
+
if (row.note && count > 1 && slots.length === count - 1)
|
|
59
|
+
break;
|
|
60
|
+
slots.push({ row });
|
|
61
|
+
if (row.note && slots.length < count)
|
|
62
|
+
slots.push({ row, detail: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
while (slots.length < count)
|
|
66
|
+
slots.push({});
|
|
67
|
+
return _jsxs("div", { className: `ms-dot-matrix-board ${className}`, style: { ...style, '--matrix-text-height': `${Math.max(0, Math.min(26, rowHeight - 8))}px` }, "data-variant": variant, "data-lines": count, "data-loading": loading || undefined, children: [_jsx("div", { className: "ms-dot-matrix-board__head", "aria-hidden": "true", children: columns.map((column, index) => {
|
|
68
|
+
if (heading && index > 0 && index < span)
|
|
69
|
+
return null;
|
|
70
|
+
const width = heading && index === 0 ? widths.slice(0, span).reduce((sum, value) => sum + value, 0) : widths[index];
|
|
71
|
+
const value = heading && index === 0 ? heading : column.label;
|
|
72
|
+
return _jsx("span", { className: heading && index === 0 ? 'ms-dot-matrix-board__heading' : undefined, style: { width: `${size.width ? width / size.width * 100 : 0}%`, textAlign: column.align }, children: rail ? _jsx(MatrixText, { value: value, characters: capacity(width), align: column.align, preserveCase: true }) : value }, column.key);
|
|
73
|
+
}) }), _jsxs("div", { className: "ms-dot-matrix-board__screen", ref: screen, children: [_jsxs("table", { "aria-busy": loading, children: [_jsx("caption", { className: "ms-matrix-sr-only", children: label }), _jsx("colgroup", { children: columns.map((column, index) => _jsx("col", { style: { width: `${size.width ? widths[index] / size.width * 100 : 0}%` } }, column.key)) }), _jsx("thead", { className: "ms-matrix-sr-only", children: _jsx("tr", { children: columns.map((column) => _jsx("th", { scope: "col", children: column.label }, column.key)) }) }), _jsx("tbody", { children: slots.map(({ row, detail }, index) => {
|
|
74
|
+
const noteId = `${boardId}-note-${index}`;
|
|
75
|
+
if (detail && row)
|
|
76
|
+
return _jsx("tr", { className: "ms-dot-matrix-board__detail", "aria-hidden": "true", style: { height: `${100 / count}%` }, children: _jsx("td", { colSpan: columns.length, children: _jsx(MatrixText, { value: row.note, characters: capacity(size.width), preserveCase: rail }) }) }, `${row.id}-detail`);
|
|
77
|
+
return _jsx("tr", { "aria-hidden": !row || undefined, "data-tone": row?.tone, "data-selected": row && row.id === selectedRowId || undefined, style: { height: `${100 / count}%` }, children: columns.map((column, columnIndex) => {
|
|
78
|
+
const text = _jsx(MatrixText, { value: row ? row.cells[column.key] || '—' : '', characters: capacity(widths[columnIndex]), align: column.align, preserveCase: rail });
|
|
79
|
+
const selection = selectionColumn ? column.key === selectionColumn : columnIndex === 0;
|
|
80
|
+
return _jsxs("td", { children: [row && onSelectRow && selection
|
|
81
|
+
? _jsx("button", { type: "button", "aria-pressed": row.id === selectedRowId, "aria-describedby": row.note ? noteId : undefined, onClick: () => onSelectRow(row.id), children: text }) : text, row?.note && selection && _jsx("span", { className: "ms-matrix-sr-only", id: noteId, children: row.note })] }, column.key);
|
|
82
|
+
}) }, row?.id ?? `blank-${index}`);
|
|
83
|
+
}) })] }), message && _jsx("div", { className: "ms-dot-matrix-board__message", role: "status", children: message })] }), (footerLabel || clockLabel) && _jsxs("div", { className: "ms-dot-matrix-board__footer", children: [footerLabel && _jsx(MatrixText, { value: footerLabel, characters: capacity(size.width * .6), preserveCase: rail }), clockLabel && _jsx("div", { className: "ms-dot-matrix-board__clock", children: _jsx(MatrixText, { value: clockLabel, characters: Math.max(1, Array.from(clockLabel).length), align: "right", preserveCase: rail }) })] })] });
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense, lazy } from 'react';
|
|
3
|
+
// Desktop button help is not needed for the first view; keep it out of the opening bundle.
|
|
4
|
+
const ButtonTooltips = lazy(() => import('./ButtonTooltips.js').then((module) => ({ default: module.ButtonTooltips })));
|
|
5
|
+
/** Button help in its own Suspense boundary, so the application never waits for it. */
|
|
6
|
+
export function LazyButtonTooltips() {
|
|
7
|
+
return _jsx(Suspense, { fallback: null, children: _jsx(ButtonTooltips, {}) });
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type NetworkSnapshot, type NetworkTrain } from '@motionstudies/core/domain/network';
|
|
2
|
+
import { type VehicleHeroCardProps } from './VehicleHeroCard.tsx';
|
|
3
|
+
export interface NetworkVehicleHeroCardProps extends Omit<VehicleHeroCardProps, 'vehicle' | 'stops'> {
|
|
4
|
+
readonly snapshot: NetworkSnapshot;
|
|
5
|
+
readonly train: NetworkTrain;
|
|
6
|
+
readonly time: number;
|
|
7
|
+
/** Hide clock values for frequency-based or otherwise approximate motion. */
|
|
8
|
+
readonly showTimes?: boolean;
|
|
9
|
+
readonly atStopLabel?: string;
|
|
10
|
+
readonly formatTime?: (time: number) => string;
|
|
11
|
+
}
|
|
12
|
+
/** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
|
|
13
|
+
export declare function NetworkVehicleHeroCard({ snapshot, train, time, showTimes, atStopLabel, formatTime, ...props }: NetworkVehicleHeroCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { formatServiceTime } from '@motionstudies/core/domain/network';
|
|
4
|
+
import { vehicleCalls } from '@motionstudies/core/domain/vehicle-calls';
|
|
5
|
+
import { VehicleHeroCard } from './VehicleHeroCard.js';
|
|
6
|
+
/** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
|
|
7
|
+
export function NetworkVehicleHeroCard({ snapshot, train, time, showTimes = true, atStopLabel = 'At stop', formatTime = (value) => formatServiceTime(value).slice(0, 5), ...props }) {
|
|
8
|
+
const current = useMemo(() => snapshot.trains.find(entry => entry.id === train.id), [snapshot, train.id]);
|
|
9
|
+
const calls = current ? vehicleCalls(snapshot, current, time) : [];
|
|
10
|
+
const identity = current ?? train;
|
|
11
|
+
return _jsx(VehicleHeroCard, { ...props, vehicle: { service: identity.route || identity.shortName,
|
|
12
|
+
destination: identity.headsign.trim() || undefined }, labels: { ...props.labels, ...(calls[0]?.atStop ? { nextStop: atStopLabel } : {}) }, stops: calls.map(call => ({ ...call, time: showTimes ? formatTime(call.time) : undefined })) });
|
|
13
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DotMatrixRow } from './DotMatrixBoard.tsx';
|
|
2
|
+
import { type TransportHeroOptions } from './TransportHeroBoard.tsx';
|
|
3
|
+
export interface RailDeparture {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
/** Consumer-formatted scheduled departure time. */
|
|
6
|
+
readonly time?: string;
|
|
7
|
+
readonly destination: string;
|
|
8
|
+
readonly platform?: string;
|
|
9
|
+
readonly platformSector?: string;
|
|
10
|
+
readonly service?: string;
|
|
11
|
+
readonly serviceCategory?: 'intercity' | 'international' | 'regional' | 'suburban';
|
|
12
|
+
/** Consumer-formatted expected time or status, e.g. "17:08" or "Cancelled". */
|
|
13
|
+
readonly expected?: string;
|
|
14
|
+
readonly via?: string;
|
|
15
|
+
readonly serviceNote?: string;
|
|
16
|
+
readonly tone?: DotMatrixRow['tone'];
|
|
17
|
+
}
|
|
18
|
+
declare const defaultLabels: {
|
|
19
|
+
station: string;
|
|
20
|
+
departures: string;
|
|
21
|
+
service: string;
|
|
22
|
+
time: string;
|
|
23
|
+
destination: string;
|
|
24
|
+
platform: string;
|
|
25
|
+
expected: string;
|
|
26
|
+
via: string;
|
|
27
|
+
loading: string;
|
|
28
|
+
empty: string;
|
|
29
|
+
retry: string;
|
|
30
|
+
detail: string;
|
|
31
|
+
};
|
|
32
|
+
export interface RailStationHeroCardProps extends Omit<TransportHeroOptions, 'presentation'> {
|
|
33
|
+
readonly presentation?: TransportHeroOptions['presentation'] | 'sbb';
|
|
34
|
+
readonly station: {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly code?: string;
|
|
37
|
+
readonly locality?: string;
|
|
38
|
+
};
|
|
39
|
+
readonly departures: readonly RailDeparture[];
|
|
40
|
+
readonly labels?: Partial<typeof defaultLabels>;
|
|
41
|
+
readonly onSelectDeparture?: (id: string) => void;
|
|
42
|
+
readonly selectedDepartureId?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Station identity with time, destination, platform, expected status and service details. */
|
|
45
|
+
export declare function RailStationHeroCard({ station, departures, labels, presentation, lineCount, boardHeight, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className }: RailStationHeroCardProps): import("react").JSX.Element;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
import { TransportHeroBoard } from './TransportHeroBoard.js';
|
|
4
|
+
import { SbbDepartureBoard } from './SbbDepartureBoard.js';
|
|
5
|
+
const defaultLabels = { station: 'Rail station', departures: 'Departures', service: 'Train', time: 'Time', destination: 'Destination', platform: 'Plat', expected: 'Expt',
|
|
6
|
+
via: 'via', loading: 'Loading rail departures…', empty: 'No trains expected.', retry: 'Retry', detail: 'Information' };
|
|
7
|
+
/** Station identity with time, destination, platform, expected status and service details. */
|
|
8
|
+
export function RailStationHeroCard({ station, departures, labels, presentation = 'uk-rail', lineCount = 8, boardHeight = 400, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className = '' }) {
|
|
9
|
+
const titleId = useId();
|
|
10
|
+
const sbb = presentation === 'sbb';
|
|
11
|
+
const copy = { ...defaultLabels, ...(sbb ? { platform: 'Platform' } : {}), ...labels };
|
|
12
|
+
return _jsxs("section", { className: `ms-rail-station-hero ${className}`, "data-presentation": presentation, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-rail-station-hero__masthead", children: [_jsxs("span", { children: [copy.station, station.locality ? ` / ${station.locality}` : ''] }), station.code && _jsx("strong", { children: station.code })] }), _jsxs("div", { className: "ms-rail-station-hero__identity", children: [_jsx("span", { "aria-hidden": "true", children: sbb ? _jsxs("svg", { viewBox: "0 0 40 32", children: [_jsx("rect", { x: "10", y: "3", width: "20", height: "22", rx: "4" }), _jsx("path", { d: "M10 15h20M15 25l-4 5m14-5 4 5" }), _jsx("circle", { cx: "15", cy: "20", r: "1" }), _jsx("circle", { cx: "25", cy: "20", r: "1" })] }) : _jsx("svg", { viewBox: "0 0 40 32", children: _jsx("path", { d: "M3 10h31L23 3M37 22H6l11 7M10 16h20" }) }) }), _jsx("h2", { id: titleId, children: station.name })] }), sbb ? error ? _jsxs("div", { className: "ms-transport-hero__error", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
13
|
+
: _jsx(SbbDepartureBoard, { label: `${station.name} ${copy.departures}`, departures: departures, labels: copy, lineCount: lineCount, height: boardHeight, minRowHeight: minRowHeight, loading: loading, clockLabel: clockLabel, footerLabel: footerLabel, onSelectDeparture: onSelectDeparture, selectedDepartureId: selectedDepartureId })
|
|
14
|
+
: _jsx(TransportHeroBoard, { label: `${station.name} ${copy.departures}`, presentation: presentation, boardHeight: boardHeight, heading: copy.departures, headingColumnSpan: 2, columns: [{ key: 'time', label: copy.time, characters: 5, minCharacters: 5 },
|
|
15
|
+
{ key: 'destination', label: copy.destination, characters: 24, minCharacters: 5 },
|
|
16
|
+
{ key: 'platform', label: copy.platform, characters: 3, minCharacters: 2, align: 'right' },
|
|
17
|
+
{ key: 'expected', label: copy.expected, characters: 9, minCharacters: 9, align: 'right' }], rows: departures.map((entry) => ({ id: entry.id, tone: entry.tone,
|
|
18
|
+
note: [entry.via ? `${copy.via} ${entry.via}` : '', entry.serviceNote].filter(Boolean).join(' · ') || undefined,
|
|
19
|
+
cells: { time: entry.time, destination: entry.destination, platform: entry.platform, expected: entry.expected } })), lineCount: lineCount, minRowHeight: minRowHeight, clockLabel: clockLabel, footerLabel: footerLabel, loading: loading, loadingMessage: copy.loading, emptyMessage: copy.empty, error: error, onRetry: onRetry, retryLabel: copy.retry, detailLabel: copy.detail, onSelectRow: onSelectDeparture, selectedRowId: selectedDepartureId, selectionColumn: "destination" }), _jsx("div", { className: "ms-transport-hero__note", children: note })] });
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RailDeparture } from './RailStationHeroCard.tsx';
|
|
2
|
+
interface SbbDepartureBoardProps {
|
|
3
|
+
readonly label: string;
|
|
4
|
+
readonly departures: readonly RailDeparture[];
|
|
5
|
+
readonly labels: {
|
|
6
|
+
departures: string;
|
|
7
|
+
service: string;
|
|
8
|
+
time: string;
|
|
9
|
+
destination: string;
|
|
10
|
+
platform: string;
|
|
11
|
+
via: string;
|
|
12
|
+
loading: string;
|
|
13
|
+
empty: string;
|
|
14
|
+
};
|
|
15
|
+
readonly lineCount: number | 'auto';
|
|
16
|
+
readonly height: number;
|
|
17
|
+
readonly minRowHeight?: number;
|
|
18
|
+
readonly loading?: boolean;
|
|
19
|
+
readonly clockLabel?: string;
|
|
20
|
+
readonly footerLabel?: string;
|
|
21
|
+
readonly onSelectDeparture?: (id: string) => void;
|
|
22
|
+
readonly selectedDepartureId?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A typographic station board; the surrounding rail hero owns identity and errors. */
|
|
25
|
+
export declare function SbbDepartureBoard({ label, departures, labels, lineCount, height, minRowHeight, loading, clockLabel, footerLabel, onSelectDeparture, selectedDepartureId }: SbbDepartureBoardProps): import("react").JSX.Element;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
/** A typographic station board; the surrounding rail hero owns identity and errors. */
|
|
4
|
+
export function SbbDepartureBoard({ label, departures, labels, lineCount, height, minRowHeight = 64, loading, clockLabel, footerLabel, onSelectDeparture, selectedDepartureId }) {
|
|
5
|
+
const body = useRef(null);
|
|
6
|
+
const [availableHeight, setAvailableHeight] = useState(300);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const element = body.current;
|
|
9
|
+
if (!element)
|
|
10
|
+
return;
|
|
11
|
+
const observer = new ResizeObserver(([entry]) => setAvailableHeight(Math.floor(entry.contentRect.height)));
|
|
12
|
+
observer.observe(element);
|
|
13
|
+
return () => observer.disconnect();
|
|
14
|
+
}, []);
|
|
15
|
+
const targetHeight = Number.isFinite(minRowHeight) ? Math.max(32, Math.min(120, minRowHeight)) : 64;
|
|
16
|
+
const requested = lineCount === 'auto' ? Math.floor(availableHeight / targetHeight) : lineCount;
|
|
17
|
+
const count = Number.isFinite(requested) ? Math.max(1, Math.min(30, Math.floor(requested))) : 6;
|
|
18
|
+
const rowHeight = availableHeight / count;
|
|
19
|
+
const message = loading ? labels.loading : departures.length === 0 ? labels.empty : undefined;
|
|
20
|
+
return _jsxs("div", { className: "ms-sbb-board", "data-lines": count, style: { height: Number.isFinite(height) ? Math.max(140, height) : 400,
|
|
21
|
+
'--sbb-row-count': count, '--sbb-font-size': `${Math.max(8, Math.min(24, rowHeight * .38))}px`,
|
|
22
|
+
'--sbb-detail-size': `${Math.max(7, Math.min(13, rowHeight * .22))}px` }, children: [_jsxs("div", { className: "ms-sbb-board__title", children: [_jsx("strong", { children: labels.departures }), clockLabel && _jsx("span", { className: "ms-sbb-board__clock", children: clockLabel })] }), _jsxs("div", { className: "ms-sbb-board__table", role: "table", "aria-label": label, "aria-busy": loading || false, children: [_jsx("div", { role: "rowgroup", children: _jsx("div", { className: "ms-sbb-board__columns", role: "row", children: [labels.service, labels.time, labels.destination, labels.platform].map((text, index) => _jsx("div", { role: "columnheader", children: text }, index)) }) }), _jsxs("div", { className: "ms-sbb-board__body", ref: body, children: [_jsx("div", { className: "ms-sbb-board__rows", role: "rowgroup", children: Array.from({ length: count }, (_, index) => {
|
|
23
|
+
const entry = message ? undefined : departures[index];
|
|
24
|
+
const details = entry ? [entry.via ? `${labels.via} ${entry.via}` : '', entry.serviceNote].filter(Boolean).join(' · ') : '';
|
|
25
|
+
return _jsxs("div", { className: "ms-sbb-board__row", role: "row", "aria-hidden": !entry || undefined, "data-tone": entry?.tone, "data-selected": entry && selectedDepartureId === entry.id || undefined, children: [_jsx("div", { role: "cell", children: _jsx("span", { className: "ms-sbb-board__service", "data-category": entry?.serviceCategory, title: entry?.service, children: entry ? entry.service || '—' : '' }) }), _jsxs("div", { role: "cell", children: [_jsx("span", { className: "ms-sbb-board__time", children: entry ? entry.time || '—' : '' }), entry?.expected && _jsx("span", { className: "ms-sbb-board__expected", title: entry.expected, children: entry.expected })] }), _jsx("div", { role: "cell", children: entry && onSelectDeparture
|
|
26
|
+
? _jsxs("button", { type: "button", "aria-pressed": selectedDepartureId === entry.id, onClick: () => onSelectDeparture(entry.id), title: entry.destination, children: [_jsx("span", { className: "ms-sbb-board__destination", children: entry.destination }), details && _jsx("span", { className: "ms-sbb-board__details", title: details, children: details })] })
|
|
27
|
+
: _jsxs(_Fragment, { children: [_jsx("span", { className: "ms-sbb-board__destination", title: entry?.destination, children: entry?.destination }), details && _jsx("span", { className: "ms-sbb-board__details", title: details, children: details })] }) }), _jsxs("div", { role: "cell", children: [_jsx("strong", { className: "ms-sbb-board__platform", title: entry?.platform, children: entry ? entry.platform || '—' : '' }), entry?.platformSector && _jsx("span", { className: "ms-sbb-board__sector", title: entry.platformSector, children: entry.platformSector })] })] }, entry?.id ?? `blank-${index}`);
|
|
28
|
+
}) }), message && _jsx("div", { className: "ms-sbb-board__message", role: "status", children: message })] })] }), footerLabel && _jsx("div", { className: "ms-sbb-board__footer", children: footerLabel })] });
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SplitFlapColumn {
|
|
2
|
+
readonly key: string;
|
|
3
|
+
readonly label: string;
|
|
4
|
+
/** Fixed number of flaps. Full values remain available to assistive technology. */
|
|
5
|
+
readonly characters: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SplitFlapRow {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly cells: Readonly<Record<string, string | undefined>>;
|
|
10
|
+
readonly tone?: 'neutral' | 'accent' | 'warning';
|
|
11
|
+
}
|
|
12
|
+
export interface SplitFlapBoardProps {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly columns: readonly SplitFlapColumn[];
|
|
15
|
+
readonly rows: readonly SplitFlapRow[];
|
|
16
|
+
readonly emptyMessage?: string;
|
|
17
|
+
readonly loading?: boolean;
|
|
18
|
+
readonly loadingMessage?: string;
|
|
19
|
+
readonly loadingRows?: number;
|
|
20
|
+
readonly onSelectRow?: (id: string) => void;
|
|
21
|
+
readonly selectedRowId?: string;
|
|
22
|
+
readonly selectionColumn?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Transport-neutral display: consumers own ordering, time formatting and data provenance. */
|
|
25
|
+
export declare function SplitFlapBoard({ label, columns, rows, emptyMessage, loading, loadingMessage, loadingRows, onSelectRow, selectedRowId, selectionColumn }: SplitFlapBoardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
function flapCount(characters) {
|
|
4
|
+
return Math.max(1, Math.min(40, Math.floor(characters) || 1));
|
|
5
|
+
}
|
|
6
|
+
/** Keep the message in the widest display column, wrapping without losing localized text. */
|
|
7
|
+
function emptyBoardRows(message, columns) {
|
|
8
|
+
const column = columns.reduce((widest, next) => !widest || flapCount(next.characters) > flapCount(widest.characters) ? next : widest, undefined);
|
|
9
|
+
if (!column)
|
|
10
|
+
return [];
|
|
11
|
+
const width = flapCount(column.characters);
|
|
12
|
+
const lines = [];
|
|
13
|
+
let line = [];
|
|
14
|
+
for (const word of message.toLocaleUpperCase().trim().split(/\s+/u)) {
|
|
15
|
+
const letters = Array.from(word);
|
|
16
|
+
if (line.length && line.length + 1 + letters.length > width) {
|
|
17
|
+
lines.push(line.join(''));
|
|
18
|
+
line = [];
|
|
19
|
+
}
|
|
20
|
+
while (letters.length > width)
|
|
21
|
+
lines.push(letters.splice(0, width).join(''));
|
|
22
|
+
if (line.length && letters.length)
|
|
23
|
+
line.push(' ');
|
|
24
|
+
line.push(...letters);
|
|
25
|
+
}
|
|
26
|
+
if (line.length)
|
|
27
|
+
lines.push(line.join(''));
|
|
28
|
+
return lines.map((text, index) => ({ id: `empty-${index}`, cells: { [column.key]: text } }));
|
|
29
|
+
}
|
|
30
|
+
function FlapText({ value, characters, offset = 0 }) {
|
|
31
|
+
const count = flapCount(characters);
|
|
32
|
+
const letters = Array.from(value.toLocaleUpperCase());
|
|
33
|
+
const visible = letters.length > count ? [...letters.slice(0, count - 1), '…'] : letters;
|
|
34
|
+
return _jsxs("span", { className: "ms-flap-text", title: value, children: [_jsx("span", { className: "ms-board-sr-only", children: value }), _jsx("span", { className: "ms-flap-text__tiles", "aria-hidden": "true", children: Array.from({ length: count }, (_, index) => {
|
|
35
|
+
const character = visible[index] ?? ' ';
|
|
36
|
+
const phase = index + offset;
|
|
37
|
+
return _jsx("span", { className: "ms-flap", style: {
|
|
38
|
+
'--flap-delay': `${Math.min(phase * 18, 360)}ms`,
|
|
39
|
+
'--flap-phase': `${-(phase % 8) * 240}ms`,
|
|
40
|
+
'--flap-motion-phase': `${-((phase * 37) % 240)}ms`,
|
|
41
|
+
}, children: _jsxs("span", { className: "ms-flap__face", children: [character === ' ' ? '\u00a0' : character, _jsx("span", { className: "ms-flap__drum" })] }) }, `${index}:${character}`);
|
|
42
|
+
}) })] });
|
|
43
|
+
}
|
|
44
|
+
/** Transport-neutral display: consumers own ordering, time formatting and data provenance. */
|
|
45
|
+
export function SplitFlapBoard({ label, columns, rows, emptyMessage = 'No movements in this window.', loading = false, loadingMessage = 'Loading movements…', loadingRows = 5, onSelectRow, selectedRowId, selectionColumn }) {
|
|
46
|
+
const hintId = useId();
|
|
47
|
+
const empty = !loading && rows.length === 0;
|
|
48
|
+
const displayedRows = loading
|
|
49
|
+
? Array.from({ length: Number.isFinite(loadingRows) ? Math.max(1, Math.min(20, Math.floor(loadingRows))) : 5 }, (_, index) => ({ id: `loading-${index}`, cells: {} }))
|
|
50
|
+
: empty ? emptyBoardRows(emptyMessage, columns) : rows;
|
|
51
|
+
return _jsxs("div", { className: "ms-split-flap-board", "data-loading": loading || undefined, "data-empty": empty || undefined, children: [_jsx("span", { className: "ms-board-sr-only", id: hintId, children: "Scroll horizontally to read all columns." }), _jsx("div", { className: "ms-split-flap-board__scroll", role: "region", "aria-label": label, "aria-describedby": hintId, "aria-busy": loading, tabIndex: 0, children: _jsxs("table", { className: "ms-split-flap-board__table", children: [_jsx("caption", { className: "ms-board-sr-only", children: label }), _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { scope: "col", children: column.label }, column.key)) }) }), _jsx("tbody", { "aria-hidden": loading || empty || undefined, children: displayedRows.map((row, rowIndex) => _jsx("tr", { "data-tone": row.tone ?? 'neutral', "data-selected": !loading && !empty && row.id === selectedRowId || undefined, children: columns.map((column, index) => {
|
|
52
|
+
const text = _jsx(FlapText, { value: loading ? ' ' : row.cells[column.key] || (empty ? ' ' : '—'), characters: column.characters, offset: rowIndex * 3 + index * 2 });
|
|
53
|
+
return _jsx("td", { children: !loading && !empty && onSelectRow && (selectionColumn ? column.key === selectionColumn : index === 0)
|
|
54
|
+
? _jsx("button", { className: "ms-split-flap-board__select", type: "button", "aria-pressed": row.id === selectedRowId, onClick: () => onSelectRow(row.id), children: text })
|
|
55
|
+
: text }, column.key);
|
|
56
|
+
}) }, row.id)) })] }) }), loading ? _jsx("p", { className: "ms-split-flap-board__loading", role: "status", children: loadingMessage })
|
|
57
|
+
: empty && _jsx("span", { className: "ms-board-sr-only", role: "status", children: emptyMessage })] });
|
|
58
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type TimelineBin } from '@motionstudies/core/timeline';
|
|
2
|
+
export interface StudyTimelineProps {
|
|
3
|
+
bins: readonly TimelineBin[];
|
|
4
|
+
windowStart: number;
|
|
5
|
+
windowEnd: number;
|
|
6
|
+
time: number;
|
|
7
|
+
onSeek: (time: number) => void;
|
|
8
|
+
label: string;
|
|
9
|
+
ariaLabel?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
variant?: 'bars' | 'line';
|
|
12
|
+
step?: number;
|
|
13
|
+
maximum?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
formatTime?: (time: number) => string;
|
|
16
|
+
formatValue?: (value: number) => string;
|
|
17
|
+
missingLabel?: string;
|
|
18
|
+
emptyLabel?: string;
|
|
19
|
+
scaleLabel?: string;
|
|
20
|
+
onScrubStart?: () => void;
|
|
21
|
+
onScrubEnd?: () => void;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Controlled chart and scrubber. Playback, loading and date/timezone policy belong to the study. */
|
|
25
|
+
export declare function StudyTimeline({ bins, windowStart, windowEnd, time, onSeek, label, ariaLabel, description, variant, step, maximum, disabled, formatTime, formatValue, missingLabel, emptyLabel, scaleLabel, onScrubStart, onScrubEnd, className }: StudyTimelineProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useMemo, useState } from 'react';
|
|
3
|
+
import { TimelineScrubber } from './TimelineScrubber.js';
|
|
4
|
+
import { createTimelineModel, formatTimelineTime, timelineBinAt, timelinePosition, timelineTime } from '@motionstudies/core/timeline';
|
|
5
|
+
const formatCount = (value) => value.toLocaleString();
|
|
6
|
+
/** Controlled chart and scrubber. Playback, loading and date/timezone policy belong to the study. */
|
|
7
|
+
export function StudyTimeline({ bins, windowStart, windowEnd, time, onSeek, label, ariaLabel = label, description, variant = 'bars', step = 1, maximum, disabled = false, formatTime = formatTimelineTime, formatValue = formatCount, missingLabel = 'No observation', emptyLabel = 'No observations in this window', scaleLabel = 'Scale', onScrubStart, onScrubEnd, className = '' }) {
|
|
8
|
+
const id = useId(), [hover, setHover] = useState();
|
|
9
|
+
const model = useMemo(() => createTimelineModel(bins, { start: windowStart, end: windowEnd }, maximum), [bins, windowStart, windowEnd, maximum]);
|
|
10
|
+
const value = Number(timelineTime(timelinePosition(time, model.window), model.window, step).toPrecision(12)), position = timelinePosition(value, model.window);
|
|
11
|
+
const active = timelineBinAt(model, value), inspected = timelineBinAt(model, hover ?? value);
|
|
12
|
+
const reading = (amount) => amount == null ? missingLabel : formatValue(amount);
|
|
13
|
+
return _jsxs("div", { className: `ms-study-timeline ${className}`, "data-variant": variant, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "ms-study-timeline__heading", children: [_jsx("span", { id: `${id}-label`, children: label }), _jsxs("span", { className: "ms-study-timeline__reading", children: [formatTime(hover ?? value), " \u00B7 ", reading(inspected?.value)] })] }), _jsx(TimelineScrubber, { className: "ms-study-timeline__plot", windowStart: windowStart, windowEnd: windowEnd, time: value, onSeek: onSeek, step: step, disabled: disabled, ariaLabel: ariaLabel, describedBy: description ? `${id}-description` : undefined, ariaValueText: `${formatTime(value)} · ${reading(active?.value)}`, onScrubStart: onScrubStart, onScrubEnd: onScrubEnd, onInspect: setHover, children: _jsxs("div", { className: "ms-study-timeline__graph", "aria-hidden": "true", children: [_jsx("svg", { viewBox: "0 0 1000 100", preserveAspectRatio: "none", focusable: "false", children: variant === 'bars' ? model.bars.filter(bar => bar.value !== null && bar.value > 0).map(bar => _jsx("rect", { x: bar.x * 1000, y: (1 - bar.height) * 100, width: bar.width * 1000 * .8, height: bar.height * 100 }, bar.start)) : model.lines.map((line, i) => line.length === 1 ? _jsx("line", { x1: line[0].x * 1000 - 1, x2: line[0].x * 1000 + 1, y1: line[0].y * 100, y2: line[0].y * 100 }, i) : _jsx("polyline", { points: line.map(p => `${p.x * 1000},${p.y * 100}`).join(' ') }, i)) }), _jsx("span", { className: "ms-study-timeline__cursor", style: { left: `${position * 100}%` } }), !model.bars.some(bar => bar.value !== null) && _jsx("span", { className: "ms-study-timeline__empty", children: emptyLabel })] }) }), _jsx("div", { className: "ms-study-timeline__ticks", "aria-hidden": "true", children: [0, .25, .5, .75, 1].map(fraction => _jsx("span", { children: formatTime(windowStart + fraction * (windowEnd - windowStart)) }, fraction)) }), _jsxs("div", { className: "ms-study-timeline__footnote", children: [description && _jsx("span", { id: `${id}-description`, children: description }), _jsxs("span", { children: [scaleLabel, " 0\u2013", formatValue(model.maximum)] })] })] });
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface TimelineScrubberProps {
|
|
3
|
+
windowStart: number;
|
|
4
|
+
windowEnd: number;
|
|
5
|
+
time: number;
|
|
6
|
+
onSeek: (time: number) => void;
|
|
7
|
+
ariaLabel: string;
|
|
8
|
+
ariaValueText?: string;
|
|
9
|
+
describedBy?: string;
|
|
10
|
+
step?: number;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
onScrubStart?: () => void;
|
|
13
|
+
onScrubEnd?: () => void;
|
|
14
|
+
onInspect?: (time: number | undefined) => void;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Shared visible rail and native range interaction; the caller owns the clock. */
|
|
19
|
+
export declare function TimelineScrubber({ windowStart, windowEnd, time, onSeek, ariaLabel, ariaValueText, describedBy, step, disabled, onScrubStart, onScrubEnd, onInspect, children, className }: TimelineScrubberProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useRef } from 'react';
|
|
3
|
+
import { timelinePosition, timelineTime } from '@motionstudies/core/timeline';
|
|
4
|
+
/** Shared visible rail and native range interaction; the caller owns the clock. */
|
|
5
|
+
export function TimelineScrubber({ windowStart, windowEnd, time, onSeek, ariaLabel, ariaValueText, describedBy, step = 1, disabled = false, onScrubStart, onScrubEnd, onInspect, children, className = '' }) {
|
|
6
|
+
const id = useId(), scrubbing = useRef(false);
|
|
7
|
+
const window = { start: windowStart, end: windowEnd };
|
|
8
|
+
const position = timelinePosition(time, window);
|
|
9
|
+
const value = Number.isFinite(time) ? Math.max(windowStart, Math.min(windowEnd, time)) : windowStart;
|
|
10
|
+
if (!Number.isFinite(step) || step <= 0)
|
|
11
|
+
throw new Error('Timeline step must be positive');
|
|
12
|
+
const begin = () => { if (!disabled && !scrubbing.current) {
|
|
13
|
+
scrubbing.current = true;
|
|
14
|
+
onScrubStart?.();
|
|
15
|
+
} };
|
|
16
|
+
const finish = () => { if (scrubbing.current) {
|
|
17
|
+
scrubbing.current = false;
|
|
18
|
+
onScrubEnd?.();
|
|
19
|
+
} };
|
|
20
|
+
return _jsxs("div", { className: `ms-timeline-scrubber ${className}`, "data-disabled": disabled || undefined, style: { '--ms-timeline-position': `${position * 100}%` }, children: [children, _jsxs("div", { className: "ms-timeline-scrubber__rail", "aria-hidden": "true", children: [_jsx("span", { className: "ms-timeline-scrubber__fill" }), _jsx("span", { className: "ms-timeline-scrubber__thumb" })] }), _jsx("input", { id: id, type: "range", "aria-label": ariaLabel, "aria-valuetext": ariaValueText, "aria-describedby": describedBy, min: windowStart, max: windowEnd, step: step, value: value, disabled: disabled, onChange: event => onSeek(Number(event.target.value)), onPointerDown: event => { if (!disabled) {
|
|
21
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
22
|
+
begin();
|
|
23
|
+
} }, onPointerUp: finish, onPointerCancel: finish, onLostPointerCapture: finish, onBlur: () => { finish(); onInspect?.(undefined); }, onKeyDown: event => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key))
|
|
24
|
+
begin(); }, onKeyUp: finish, onPointerMove: event => { if (disabled || event.pointerType === 'touch' || !onInspect)
|
|
25
|
+
return; const box = event.currentTarget.getBoundingClientRect(); onInspect(timelineTime((event.clientX - box.left - 22) / Math.max(1, box.width - 44), window, step)); }, onPointerLeave: () => onInspect?.(undefined) })] });
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { type DotMatrixBoardProps } from './DotMatrixBoard.tsx';
|
|
3
|
+
/** Shared display options; each transport card supplies its own identity and columns. */
|
|
4
|
+
export interface TransportHeroOptions {
|
|
5
|
+
readonly presentation?: 'dot-matrix' | 'uk-rail' | 'split-flap';
|
|
6
|
+
readonly lineCount?: number | 'auto';
|
|
7
|
+
readonly boardHeight?: number;
|
|
8
|
+
readonly minRowHeight?: number;
|
|
9
|
+
readonly clockLabel?: string;
|
|
10
|
+
readonly footerLabel?: string;
|
|
11
|
+
/** Source/freshness explanation owned by the consumer. */
|
|
12
|
+
readonly note: ReactNode;
|
|
13
|
+
readonly loading?: boolean;
|
|
14
|
+
readonly error?: string;
|
|
15
|
+
readonly onRetry?: () => void;
|
|
16
|
+
readonly className?: string;
|
|
17
|
+
}
|
|
18
|
+
interface TransportHeroBoardProps extends Omit<DotMatrixBoardProps, 'style' | 'className'> {
|
|
19
|
+
readonly presentation: NonNullable<TransportHeroOptions['presentation']>;
|
|
20
|
+
readonly boardHeight: number;
|
|
21
|
+
readonly error?: string;
|
|
22
|
+
readonly onRetry?: () => void;
|
|
23
|
+
readonly retryLabel: string;
|
|
24
|
+
readonly detailLabel: string;
|
|
25
|
+
}
|
|
26
|
+
/** Internal adapter: error handling and presentation switching shared by rail and bus. */
|
|
27
|
+
export declare function TransportHeroBoard({ presentation, boardHeight, error, onRetry, retryLabel, detailLabel, ...board }: TransportHeroBoardProps): import("react").JSX.Element;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DotMatrixBoard } from './DotMatrixBoard.js';
|
|
3
|
+
import { SplitFlapBoard } from './SplitFlapBoard.js';
|
|
4
|
+
/** Internal adapter: error handling and presentation switching shared by rail and bus. */
|
|
5
|
+
export function TransportHeroBoard({ presentation, boardHeight, error, onRetry, retryLabel, detailLabel, ...board }) {
|
|
6
|
+
const height = Number.isFinite(boardHeight) ? Math.max(140, boardHeight) : 360;
|
|
7
|
+
if (error)
|
|
8
|
+
return _jsxs("div", { className: "ms-transport-hero__error", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: retryLabel })] });
|
|
9
|
+
if (presentation !== 'split-flap')
|
|
10
|
+
return _jsx(DotMatrixBoard, { ...board, variant: presentation === 'uk-rail' ? 'uk-rail' : 'bus', style: { height } });
|
|
11
|
+
const count = board.lineCount === 'auto' ? Math.floor((height - 60) / (board.minRowHeight ?? 34)) : board.lineCount ?? 6;
|
|
12
|
+
const limit = Number.isFinite(count) ? Math.max(1, Math.min(30, Math.floor(count))) : 6;
|
|
13
|
+
const hasDetails = board.rows.some((row) => row.note);
|
|
14
|
+
return _jsxs("div", { className: "ms-transport-hero__flap", style: { height }, children: [_jsx(SplitFlapBoard, { ...board, columns: hasDetails ? [...board.columns, { key: '__detail', label: detailLabel, characters: 32 }] : board.columns, rows: board.rows.slice(0, limit).map((row) => ({ ...row, cells: { ...row.cells, __detail: row.note } })), loadingRows: limit }), (board.footerLabel || board.clockLabel) && _jsxs("div", { className: "ms-transport-hero__flap-footer", children: [_jsx("span", { children: board.footerLabel }), _jsx("span", { children: board.clockLabel })] })] });
|
|
15
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type VehicleHeroPresentation = 'uk-bus' | 'uk-rail' | 'yellow-bus' | 'sbb';
|
|
3
|
+
export interface VehicleCallingPoint {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Consumer-formatted arrival time or estimate. Omit when unknown. */
|
|
7
|
+
readonly time?: string;
|
|
8
|
+
readonly platform?: string;
|
|
9
|
+
readonly detail?: string;
|
|
10
|
+
/** Set only for a confirmed destination, never just the end of a data chunk. */
|
|
11
|
+
readonly isDestination?: boolean;
|
|
12
|
+
}
|
|
13
|
+
declare const defaultLabels: {
|
|
14
|
+
bus: string;
|
|
15
|
+
rail: string;
|
|
16
|
+
destination: string;
|
|
17
|
+
destinationUnknown: string;
|
|
18
|
+
nextStop: string;
|
|
19
|
+
callingAt: string;
|
|
20
|
+
terminus: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
loading: string;
|
|
23
|
+
empty: string;
|
|
24
|
+
retry: string;
|
|
25
|
+
};
|
|
26
|
+
export interface VehicleHeroCardProps {
|
|
27
|
+
readonly vehicle: {
|
|
28
|
+
readonly service: string;
|
|
29
|
+
readonly destination?: string;
|
|
30
|
+
readonly operator?: string;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Remaining calls in journey order, starting with the next stop. The consumer owns playback/filtering. */
|
|
34
|
+
readonly stops: readonly VehicleCallingPoint[];
|
|
35
|
+
readonly presentation?: VehicleHeroPresentation;
|
|
36
|
+
/** Explicit source/freshness explanation. */
|
|
37
|
+
readonly note: ReactNode;
|
|
38
|
+
readonly status?: string;
|
|
39
|
+
readonly statusTone?: 'neutral' | 'warning';
|
|
40
|
+
readonly clockLabel?: string;
|
|
41
|
+
readonly labels?: Partial<typeof defaultLabels>;
|
|
42
|
+
readonly loading?: boolean;
|
|
43
|
+
readonly error?: string;
|
|
44
|
+
readonly onRetry?: () => void;
|
|
45
|
+
readonly onSelectStop?: (id: string) => void;
|
|
46
|
+
readonly selectedStopId?: string;
|
|
47
|
+
readonly className?: string;
|
|
48
|
+
}
|
|
49
|
+
/** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
|
|
50
|
+
export declare function VehicleHeroCard({ vehicle, stops, presentation, note, status, statusTone, clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className }: VehicleHeroCardProps): import("react").JSX.Element;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
const defaultLabels = {
|
|
4
|
+
bus: 'Bus', rail: 'Train', destination: 'Destination', destinationUnknown: 'Destination unavailable',
|
|
5
|
+
nextStop: 'Next stop', callingAt: 'Then calling at', terminus: 'Destination', platform: 'Platform',
|
|
6
|
+
loading: 'Loading upcoming stops…', empty: 'No upcoming stops available.', retry: 'Retry',
|
|
7
|
+
};
|
|
8
|
+
/** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
|
|
9
|
+
export function VehicleHeroCard({ vehicle, stops, presentation = 'uk-rail', note, status, statusTone = 'neutral', clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className = '' }) {
|
|
10
|
+
const titleId = useId();
|
|
11
|
+
const copy = { ...defaultLabels, ...labels };
|
|
12
|
+
const bus = presentation === 'uk-bus' || presentation === 'yellow-bus';
|
|
13
|
+
const [next, ...following] = stops;
|
|
14
|
+
const stopContent = (stop) => _jsxs(_Fragment, { children: [_jsxs("span", { className: "ms-vehicle-hero__stop-name", children: [stop.name, stop.isDestination && _jsx("small", { children: copy.terminus }), stop.detail && _jsx("span", { className: "ms-vehicle-hero__detail", children: stop.detail })] }), (stop.time || stop.platform) && _jsxs("span", { className: "ms-vehicle-hero__timing", children: [stop.time && _jsx("strong", { children: stop.time }), stop.platform && _jsxs("small", { children: [copy.platform, " ", stop.platform] })] })] });
|
|
15
|
+
const renderStop = (stop) => onSelectStop
|
|
16
|
+
? _jsx("button", { type: "button", className: "ms-vehicle-hero__stop", "aria-pressed": selectedStopId === stop.id, onClick: () => onSelectStop(stop.id), children: stopContent(stop) })
|
|
17
|
+
: _jsx("div", { className: "ms-vehicle-hero__stop", children: stopContent(stop) });
|
|
18
|
+
return _jsxs("section", { className: `ms-vehicle-hero ${className}`, "data-presentation": presentation, "aria-labelledby": titleId, "aria-busy": loading || undefined, children: [_jsxs("div", { className: "ms-vehicle-hero__masthead", children: [_jsxs("span", { children: [bus ? copy.bus : copy.rail, vehicle.operator ? ` · ${vehicle.operator}` : ''] }), clockLabel && _jsx("span", { children: clockLabel })] }), _jsxs("div", { className: "ms-vehicle-hero__identity", children: [_jsx("strong", { className: "ms-vehicle-hero__service", children: vehicle.service }), _jsxs("div", { children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.destination }), _jsx("h2", { id: titleId, children: vehicle.destination || copy.destinationUnknown }), vehicle.description && _jsx("p", { className: "ms-vehicle-hero__description", children: vehicle.description })] })] }), status && _jsx("div", { className: "ms-vehicle-hero__status", "data-tone": statusTone, children: status }), error ? _jsxs("div", { className: "ms-vehicle-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
19
|
+
: loading ? _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.loading })
|
|
20
|
+
: next ? _jsxs("div", { className: "ms-vehicle-hero__journey", children: [_jsxs("div", { className: "ms-vehicle-hero__next", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.nextStop }), renderStop(next)] }), following.length > 0 && _jsxs("div", { className: "ms-vehicle-hero__following", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.callingAt }), _jsx("ol", { children: following.map((stop) => _jsx("li", { "data-terminus": stop.isDestination || undefined, children: renderStop(stop) }, stop.id)) })] })] }) : _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.empty }), _jsx("div", { className: "ms-vehicle-hero__note", children: note })] });
|
|
21
|
+
}
|