@motionstudies/web 0.1.0-alpha.0 → 0.1.0-alpha.10

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.
Files changed (38) hide show
  1. package/README.md +171 -2
  2. package/components/AirportBoard.d.ts +27 -0
  3. package/components/AirportBoard.js +28 -0
  4. package/components/AirportHeroCard.d.ts +58 -0
  5. package/components/AirportHeroCard.js +31 -0
  6. package/components/BusStopHeroCard.d.ts +37 -0
  7. package/components/BusStopHeroCard.js +14 -0
  8. package/components/ButtonTooltips.d.ts +5 -0
  9. package/components/ButtonTooltips.js +146 -0
  10. package/components/DotMatrixBoard.d.ts +31 -0
  11. package/components/DotMatrixBoard.js +84 -0
  12. package/components/RailStationHeroCard.d.ts +46 -0
  13. package/components/RailStationHeroCard.js +20 -0
  14. package/components/SbbDepartureBoard.d.ts +26 -0
  15. package/components/SbbDepartureBoard.js +29 -0
  16. package/components/SplitFlapBoard.d.ts +25 -0
  17. package/components/SplitFlapBoard.js +58 -0
  18. package/components/TransportHeroBoard.d.ts +28 -0
  19. package/components/TransportHeroBoard.js +15 -0
  20. package/components/airport-hero-card.css +42 -0
  21. package/components/dot-matrix-board.css +49 -0
  22. package/components/dot-matrix-font.d.ts +2 -0
  23. package/components/dot-matrix-font.js +61 -0
  24. package/components/sbb-departure-board.css +49 -0
  25. package/components/split-flap-board.css +45 -0
  26. package/components/transport-hero-cards.css +39 -0
  27. package/mount-motion-study.js +3 -2
  28. package/package.json +67 -3
  29. package/use-airport-feed.d.ts +9 -0
  30. package/use-airport-feed.js +72 -0
  31. package/use-browser-location.d.ts +13 -0
  32. package/use-browser-location.js +39 -0
  33. package/use-json-asset.d.ts +9 -0
  34. package/use-json-asset.js +45 -0
  35. package/use-now-clock.d.ts +11 -0
  36. package/use-now-clock.js +45 -0
  37. package/use-transition-value.d.ts +12 -0
  38. 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,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,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,42 @@
1
+ @import './split-flap-board.css';
2
+
3
+ .ms-airport-hero { --airport-accent: #edc96b; container-type: inline-size; color: #f0ecdd; background: linear-gradient(125deg, #292d27, #191d19 65%); border: 1px solid #4b4d40; border-radius: 12px; padding: clamp(16px, 3vw, 30px); box-shadow: 0 20px 60px #0003; font-family: ui-sans-serif, system-ui, sans-serif; min-width: 0; }
4
+ .ms-airport-hero__masthead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }
5
+ .ms-airport-hero__eyebrow { color: var(--airport-accent); text-transform: uppercase; font-size: 10px; letter-spacing: .18em; }
6
+ .ms-airport-hero__eyebrow > span { font-size: 18px; margin-right: 6px; }
7
+ .ms-airport-hero__date { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; letter-spacing: .06em; }
8
+ .ms-airport-hero__identity { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 18px 24px; padding: 18px 0 20px; }
9
+ .ms-airport-hero__code { color: var(--airport-accent); font-size: clamp(54px, 10cqi, 82px); font-weight: 650; line-height: .95; letter-spacing: -.065em; }
10
+ .ms-airport-hero__identity h2 { max-width: 300px; color: #f0ecdd; font: 400 clamp(20px, 3.3cqi, 27px)/1.15 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
11
+ .ms-airport-hero__clock { padding-left: 24px; border-left: 1px solid #797c6155; text-align: right; }
12
+ .ms-airport-hero__clock-label { display: block; color: #bebfac; font-size: 10px; line-height: 1.5; letter-spacing: .12em; text-transform: uppercase; }
13
+ .ms-airport-hero__clock-value { display: block; margin-top: 4px; color: #f0ecdd; font: 400 42px/1.1 ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: -.06em; white-space: nowrap; }
14
+ .ms-airport-hero__board-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0 20px; border-bottom: 1px solid #4b4d40; margin-bottom: 14px; }
15
+ .ms-airport-hero__directions { display: flex; gap: 22px; }
16
+ .ms-airport-hero__directions button { min-height: 44px; padding: 10px 0 12px; margin-bottom: -1px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: #b8baaa; font: 500 13px/1.4 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
17
+ .ms-airport-hero__directions button[aria-pressed='true'] { color: var(--airport-accent); border-bottom-color: var(--airport-accent); }
18
+ .ms-airport-hero__directions button:hover { color: #f0ecdd; }
19
+ .ms-airport-hero__window { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; font-variant-numeric: tabular-nums; margin: 8px 0; }
20
+ .ms-airport-hero button:focus-visible { outline: 2px solid var(--airport-accent); outline-offset: 4px; }
21
+ .ms-airport-hero .ms-flap { font-size: 17px; }
22
+ .ms-airport-hero .ms-split-flap-board__table th { color: #c4c3b0; font-size: 11px; letter-spacing: .12em; padding-bottom: 5px; }
23
+ .ms-airport-hero__message { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 30px 0; margin: 0; font-size: 14px; color: #c5c7b6; }
24
+ .ms-airport-hero__message button { background: #30352a; border: 1px solid #666b50; border-radius: 4px; color: var(--airport-accent); padding: 6px 12px; font: inherit; cursor: pointer; }
25
+ .ms-airport-hero__note { color: #b8baaa; font-size: 11px; line-height: 1.6; margin-top: 14px; }
26
+ @container (max-width: 600px) {
27
+ .ms-airport-hero__identity { grid-template-columns: auto minmax(0, 1fr); gap: 16px; }
28
+ .ms-airport-hero__clock { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 0; border-left: 0; border-top: 1px solid #797c6155; text-align: left; }
29
+ .ms-airport-hero__clock-value { margin-top: 0; font-size: 32px; }
30
+ }
31
+ @container (max-width: 440px) {
32
+ .ms-airport-hero__board-header { gap: 4px; }
33
+ .ms-airport-hero__directions { width: 100%; gap: 20px; }
34
+ .ms-airport-hero__window { margin: 6px 0 10px; }
35
+ }
36
+ .ms-airport-board__modes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; color: #b8baaa; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; }
37
+ .ms-airport-board__modes button, .ms-airport-board__unavailable button { min-height: 44px; padding: 8px 16px; border: 1px solid #4b4d40; border-radius: 6px; color: #f0ecdd; background: #1d211c; font: inherit; cursor: pointer; }
38
+ .ms-airport-board__modes button[aria-pressed='true'] { border-color: #edc96b; color: #edc96b; }
39
+ .ms-airport-board__modes button:disabled { opacity: .5; cursor: default; }
40
+ .ms-airport-board__modes button:focus-visible, .ms-airport-board__unavailable button:focus-visible { outline: 2px solid #edc96b; outline-offset: 3px; }
41
+ .ms-airport-board__unavailable { padding: 28px; border: 1px solid #4b4d40; border-radius: 12px; color: #f0ecdd; background: #1d211c; font: 14px/1.6 ui-sans-serif, system-ui, sans-serif; }
42
+ .ms-airport-board__detail { color: #b8baaa; font: 13px/1.6 ui-sans-serif, system-ui, sans-serif; overflow-wrap: anywhere; }
@@ -0,0 +1,49 @@
1
+ .ms-dot-matrix-board {
2
+ --matrix-ink: #ffb340;
3
+ --matrix-unlit: #3c2b1260;
4
+ box-sizing: border-box;
5
+ display: flex;
6
+ flex-direction: column;
7
+ width: 100%;
8
+ height: 320px;
9
+ min-width: 0;
10
+ min-height: 0;
11
+ padding: 16px;
12
+ color: var(--matrix-ink);
13
+ background: linear-gradient(115deg, #222523, #111412 60%);
14
+ border: 1px solid #3c403b;
15
+ border-radius: 5px;
16
+ box-shadow: inset 0 1px #ffffff12, 0 8px 24px #0003;
17
+ }
18
+ .ms-dot-matrix-board * { box-sizing: border-box; }
19
+ .ms-dot-matrix-board__head { display: flex; flex: 0 0 28px; color: #c7c9c1; font: 500 12px/20px ui-sans-serif, system-ui, sans-serif; }
20
+ .ms-dot-matrix-board__head > span { padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .ms-dot-matrix-board__screen { flex: 1; position: relative; min-height: 0; min-width: 0; background: #080b09; box-shadow: inset 0 2px 8px #000; overflow: hidden; }
22
+ .ms-dot-matrix-board table { table-layout: fixed; width: 100%; height: 100%; border-collapse: collapse; text-align: left; }
23
+ .ms-dot-matrix-board td { padding: 0 8px; border-bottom: 1px solid #b4ad7d12; overflow: hidden; }
24
+ .ms-dot-matrix-board tr:last-child td { border-bottom: 0; }
25
+ .ms-matrix-text { display: block; position: relative; width: 100%; }
26
+ .ms-matrix-text svg { display: block; width: 100%; height: var(--matrix-text-height); filter: drop-shadow(0 0 2px #ff9e292e); }
27
+ .ms-matrix-text__unlit { color: var(--matrix-unlit); }
28
+ .ms-matrix-text pattern circle { fill: var(--matrix-unlit); }
29
+ .ms-dot-matrix-board [data-tone='accent'] td:last-child { color: #ffd895; }
30
+ .ms-dot-matrix-board [data-tone='warning'] td:last-child { color: #ff8060; }
31
+ .ms-dot-matrix-board [data-selected] { background: #ffb34016; box-shadow: inset 2px 0 var(--matrix-ink); }
32
+ .ms-dot-matrix-board button { display: block; width: 100%; padding: 0; border: 0; border-radius: 0; background: none; color: inherit; cursor: pointer; font: inherit; }
33
+ .ms-dot-matrix-board button:hover { color: #ffe2ac; }
34
+ .ms-dot-matrix-board button:focus-visible { outline: 2px solid var(--matrix-ink); outline-offset: 3px; }
35
+ .ms-dot-matrix-board__message { position: absolute; inset: 0; display: grid; place-content: center; padding: 16px; overflow: auto; text-align: center; color: var(--matrix-ink); font: 15px/1.6 ui-monospace, monospace; background: #080b09e8; }
36
+ .ms-dot-matrix-board__footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex: 0 0 38px; min-width: 0; padding: 8px; }
37
+ .ms-dot-matrix-board__footer > .ms-matrix-text { width: 60%; min-width: 0; }
38
+ .ms-dot-matrix-board__footer svg { height: 18px; }
39
+ .ms-dot-matrix-board__clock { width: 34%; max-width: 160px; margin-left: auto; min-width: 0; }
40
+ .ms-dot-matrix-board__clock svg { height: 22px; }
41
+ .ms-dot-matrix-board__detail td { padding-left: 20px; }
42
+ .ms-dot-matrix-board[data-variant='uk-rail'] { --matrix-ink: #f6cd46; --matrix-unlit: #51400c60; border-radius: 2px; border: 1px solid #333431; background: #10120f; box-shadow: inset 0 0 0 5px #191b18, 0 12px 32px #0004; padding: 20px; }
43
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__head { flex-basis: 42px; align-items: center; color: var(--matrix-ink); background: #0a0c08; border-bottom: 5px solid #10120f; }
44
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__head svg { height: 18px; }
45
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__heading svg { height: 24px; }
46
+ .ms-dot-matrix-board[data-variant='uk-rail'] td { border-color: #10120f; border-bottom-width: 3px; background: #c7a53208; }
47
+ .ms-dot-matrix-board[data-variant='uk-rail'] [data-tone] td:last-child { color: var(--matrix-ink); }
48
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__footer { color: var(--matrix-ink); background: #0a0c08; border-top: 5px solid #10120f; }
49
+ .ms-matrix-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@@ -0,0 +1,2 @@
1
+ /** Unsupported scripts retain their original text through the SVG text fallback. */
2
+ export declare function matrixPath(value: string): string | undefined;
@@ -0,0 +1,61 @@
1
+ // Original 5 × 7 bitmap alphabet. Each integer is one row, most significant bit first.
2
+ const glyphs = {
3
+ a: [0, 0, 14, 1, 15, 17, 15], b: [16, 16, 30, 17, 17, 17, 30],
4
+ c: [0, 0, 14, 16, 16, 17, 14], d: [1, 1, 15, 17, 17, 17, 15],
5
+ e: [0, 0, 14, 17, 31, 16, 14], f: [6, 9, 8, 28, 8, 8, 8],
6
+ g: [0, 15, 17, 17, 15, 1, 14], h: [16, 16, 30, 17, 17, 17, 17],
7
+ i: [4, 0, 12, 4, 4, 4, 14], j: [2, 0, 6, 2, 2, 18, 12],
8
+ k: [16, 16, 18, 20, 24, 20, 18], l: [12, 4, 4, 4, 4, 4, 14],
9
+ m: [0, 0, 26, 21, 21, 21, 21], n: [0, 0, 30, 17, 17, 17, 17],
10
+ o: [0, 0, 14, 17, 17, 17, 14], p: [0, 0, 30, 17, 30, 16, 16],
11
+ q: [0, 0, 15, 17, 15, 1, 1], r: [0, 0, 22, 25, 16, 16, 16],
12
+ s: [0, 0, 15, 16, 14, 1, 30], t: [8, 8, 28, 8, 8, 9, 6],
13
+ u: [0, 0, 17, 17, 17, 19, 13], v: [0, 0, 17, 17, 17, 10, 4],
14
+ w: [0, 0, 17, 17, 21, 21, 10], x: [0, 0, 17, 10, 4, 10, 17],
15
+ y: [0, 0, 17, 17, 15, 1, 14], z: [0, 0, 31, 2, 4, 8, 31],
16
+ '·': [0, 0, 0, 4, 0, 0, 0],
17
+ ' ': [0, 0, 0, 0, 0, 0, 0],
18
+ A: [14, 17, 17, 31, 17, 17, 17], B: [30, 17, 17, 30, 17, 17, 30],
19
+ C: [14, 17, 16, 16, 16, 17, 14], D: [30, 17, 17, 17, 17, 17, 30],
20
+ E: [31, 16, 16, 30, 16, 16, 31], F: [31, 16, 16, 30, 16, 16, 16],
21
+ G: [14, 17, 16, 23, 17, 17, 15], H: [17, 17, 17, 31, 17, 17, 17],
22
+ I: [14, 4, 4, 4, 4, 4, 14], J: [7, 2, 2, 2, 2, 18, 12],
23
+ K: [17, 18, 20, 24, 20, 18, 17], L: [16, 16, 16, 16, 16, 16, 31],
24
+ M: [17, 27, 21, 21, 17, 17, 17], N: [17, 25, 21, 19, 17, 17, 17],
25
+ O: [14, 17, 17, 17, 17, 17, 14], P: [30, 17, 17, 30, 16, 16, 16],
26
+ Q: [14, 17, 17, 17, 21, 18, 13], R: [30, 17, 17, 30, 20, 18, 17],
27
+ S: [15, 16, 16, 14, 1, 1, 30], T: [31, 4, 4, 4, 4, 4, 4],
28
+ U: [17, 17, 17, 17, 17, 17, 14], V: [17, 17, 17, 17, 17, 10, 4],
29
+ W: [17, 17, 17, 21, 21, 21, 10], X: [17, 17, 10, 4, 10, 17, 17],
30
+ Y: [17, 17, 10, 4, 4, 4, 4], Z: [31, 1, 2, 4, 8, 16, 31],
31
+ '0': [14, 17, 19, 21, 25, 17, 14], '1': [4, 12, 4, 4, 4, 4, 14],
32
+ '2': [14, 17, 1, 2, 4, 8, 31], '3': [30, 1, 1, 14, 1, 1, 30],
33
+ '4': [2, 6, 10, 18, 31, 2, 2], '5': [31, 16, 16, 30, 1, 1, 30],
34
+ '6': [14, 16, 16, 30, 17, 17, 14], '7': [31, 1, 2, 4, 8, 8, 8],
35
+ '8': [14, 17, 17, 14, 17, 17, 14], '9': [14, 17, 17, 15, 1, 1, 14],
36
+ '-': [0, 0, 0, 31, 0, 0, 0], '—': [0, 0, 0, 31, 0, 0, 0],
37
+ '.': [0, 0, 0, 0, 0, 12, 12], '…': [0, 0, 0, 0, 0, 0, 21],
38
+ ':': [0, 12, 12, 0, 12, 12, 0], '/': [1, 1, 2, 4, 8, 16, 16],
39
+ '+': [0, 4, 4, 31, 4, 4, 0], '&': [12, 18, 20, 8, 21, 18, 13],
40
+ "'": [4, 4, 8, 0, 0, 0, 0], '’': [4, 4, 8, 0, 0, 0, 0],
41
+ '(': [2, 4, 8, 8, 8, 4, 2], ')': [8, 4, 2, 2, 2, 4, 8],
42
+ '!': [4, 4, 4, 4, 4, 0, 4], '?': [14, 17, 1, 2, 4, 0, 4],
43
+ };
44
+ /** Unsupported scripts retain their original text through the SVG text fallback. */
45
+ export function matrixPath(value) {
46
+ let path = '';
47
+ for (const [index, character] of Array.from(value).entries()) {
48
+ const glyph = glyphs[character];
49
+ if (!glyph)
50
+ return undefined;
51
+ glyph.forEach((bits, y) => {
52
+ for (let x = 0; x < 5; x++) {
53
+ if (bits & (1 << (4 - x))) {
54
+ const cx = index * 6 + x + .5;
55
+ path += `M${cx - .36},${y + .5}a.36,.36 0 1,0 .72,0a.36,.36 0 1,0 -.72,0`;
56
+ }
57
+ }
58
+ });
59
+ }
60
+ return path;
61
+ }
@@ -0,0 +1,49 @@
1
+ .ms-sbb-board { --sbb-blue: #122b65; display: flex; flex-direction: column; min-width: 0; min-height: 0; width: 100%; background: var(--sbb-blue); color: #fff; border: 1px solid #5873a1; border-radius: 2px; font-family: Arial, Helvetica, sans-serif; container-type: inline-size; }
2
+ .ms-sbb-board__title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 16px 20px 12px; flex-shrink: 0; }
3
+ .ms-sbb-board__title strong { font-size: clamp(20px, 4cqi, 30px); font-weight: 500; }
4
+ .ms-sbb-board__clock { font-size: 18px; font-variant-numeric: tabular-nums; white-space: nowrap; }
5
+ .ms-sbb-board__table { display: flex; flex-direction: column; flex: 1; min-height: 0; }
6
+ .ms-sbb-board__columns, .ms-sbb-board__row { display: grid; grid-template-columns: minmax(58px, .9fr) minmax(62px, .9fr) minmax(0, 4fr) minmax(48px, .6fr); column-gap: 14px; padding: 0 20px; }
7
+ .ms-sbb-board__columns { padding-bottom: 8px; font-size: 11px; color: #ccd8ef; border-bottom: 1px solid #7790b8; }
8
+ .ms-sbb-board__columns > :last-child { text-align: right; }
9
+ .ms-sbb-board__columns > div { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10
+ .ms-sbb-board__body { flex: 1; min-height: 0; position: relative; }
11
+ .ms-sbb-board__rows { display: grid; grid-template-rows: repeat(var(--sbb-row-count), minmax(0, 1fr)); height: 100%; }
12
+ .ms-sbb-board__row { border-bottom: 1px solid #637fa655; align-items: center; font-size: var(--sbb-font-size); line-height: 1.15; min-height: 0; }
13
+ .ms-sbb-board__row:nth-child(even) { background: #ffffff05; }
14
+ .ms-sbb-board__row > [role='cell'] { min-width: 0; max-height: 100%; overflow: hidden; }
15
+ .ms-sbb-board__row > [role='cell']:last-child { text-align: right; }
16
+ .ms-sbb-board__service { display: inline-block; max-width: 100%; padding: .12em .25em; font-size: .78em; font-weight: 700; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }
17
+ .ms-sbb-board__service[data-category='intercity'], .ms-sbb-board__service[data-category='international'] { background: #fff; color: #c90016; font-style: italic; }
18
+ .ms-sbb-board__service[data-category='regional'] { border: 1px solid #fff; }
19
+ .ms-sbb-board__service[data-category='suburban'] { background: #fff; color: #122b65; }
20
+ .ms-sbb-board__time { display: block; font-weight: 600; font-variant-numeric: tabular-nums; }
21
+ .ms-sbb-board__destination, .ms-sbb-board__details, .ms-sbb-board__expected, .ms-sbb-board__sector, .ms-sbb-board__platform { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22
+ .ms-sbb-board__destination { font-weight: 600; }
23
+ .ms-sbb-board__details { margin-top: .2em; color: #d6e0f4; font-size: var(--sbb-detail-size); font-weight: 400; }
24
+ .ms-sbb-board__expected, .ms-sbb-board__sector { margin-top: .2em; font-size: var(--sbb-detail-size); font-weight: 400; }
25
+ .ms-sbb-board__row[data-tone='warning'] .ms-sbb-board__expected { color: #ffdc69; font-weight: 700; }
26
+ .ms-sbb-board__platform { font-size: 1.2em; font-weight: 600; }
27
+ .ms-sbb-board button { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; padding: 0; color: inherit; background: none; font: inherit; cursor: pointer; }
28
+ .ms-sbb-board button:hover .ms-sbb-board__destination { text-decoration: underline; }
29
+ .ms-sbb-board button:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
30
+ .ms-sbb-board__row[data-selected] { background: #ffffff20; box-shadow: inset 3px 0 #fff; }
31
+ .ms-sbb-board__message { position: absolute; inset: 0; display: grid; place-content: center; padding: 16px; overflow: auto; background: var(--sbb-blue); font-size: 14px; line-height: 1.5; text-align: center; }
32
+ .ms-sbb-board__footer { padding: 10px 20px; font-size: 11px; color: #d6e0f4; flex-shrink: 0; }
33
+ @container (max-width: 480px) {
34
+ .ms-sbb-board__columns, .ms-sbb-board__row { grid-template-columns: 44px 46px minmax(0, 1fr) 36px; column-gap: 8px; padding-left: 10px; padding-right: 10px; }
35
+ .ms-sbb-board__title { padding: 12px 10px; }
36
+ .ms-sbb-board__clock { font-size: 14px; }
37
+ .ms-sbb-board__row { font-size: min(var(--sbb-font-size), 15px); }
38
+ }
39
+ @container (max-width: 360px) {
40
+ .ms-sbb-board__columns, .ms-sbb-board__row { grid-template-columns: 46px minmax(0, 1fr) 32px; grid-template-rows: minmax(0, 1.5fr) minmax(0, 1fr); column-gap: 8px; }
41
+ .ms-sbb-board__columns > :nth-child(1), .ms-sbb-board__row > :nth-child(1) { grid-column: 1; grid-row: 2; align-self: start; }
42
+ .ms-sbb-board__columns > :nth-child(2), .ms-sbb-board__row > :nth-child(2) { grid-column: 1; grid-row: 1; align-self: end; }
43
+ .ms-sbb-board__columns > :nth-child(3), .ms-sbb-board__row > :nth-child(3) { grid-column: 2; grid-row: 1 / 3; }
44
+ .ms-sbb-board__columns > :nth-child(4), .ms-sbb-board__row > :nth-child(4) { grid-column: 3; grid-row: 1 / 3; }
45
+ .ms-sbb-board__columns { align-items: center; font-size: 10px; line-height: 1.2; }
46
+ .ms-sbb-board__service { font-size: .7em; }
47
+ .ms-sbb-board__expected { font-size: min(var(--sbb-detail-size), 10px); margin-top: 0; }
48
+ .ms-sbb-board__title { flex-wrap: wrap; gap: 4px 12px; }
49
+ }