@motionstudies/web 0.1.0-alpha.6 → 0.1.0-alpha.8

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 CHANGED
@@ -5,7 +5,7 @@ Shared packages for the Motion Studies transport instrument. The source workspac
5
5
  - `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
6
6
  - `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
7
7
  - `@motionstudies/web`: picker, button tooltips, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
8
- - `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
8
+ - `@motionstudies/data`: Node-only GTFS readers, ADS-B heatmap compilation, air endpoint enrichment, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
9
9
 
10
10
  ```tsx
11
11
  import { MobilePicker } from '@motionstudies/web/components/MobilePicker'
@@ -76,6 +76,86 @@ Pass `labels` for edition translations, `loading`, or a localized `error` and `o
76
76
 
77
77
  For a custom rail or transport board, import `SplitFlapBoard` from `@motionstudies/web/components/SplitFlapBoard` and `@motionstudies/web/split-flap-board.css`. Supply `columns` (`key`, `label`, `characters`) and `rows` (`id`, `cells`, optional `tone`). Cell text longer than its flap count is visually ellipsized, with the full value retained for assistive technology and hover. `onSelectRow`, `selectedRowId` and `selectionColumn` optionally make one cell per row selectable.
78
78
 
79
+ For bus stops and local transport, `DotMatrixBoard` accepts the same rows, selection callbacks, loading state and empty/loading messages. It uses an amber 5 × 7 LED alphabet and its own scoped stylesheet; the flip-board stylesheet is not required. Consumers can switch components without remapping their data.
80
+
81
+ ```tsx
82
+ import { DotMatrixBoard } from '@motionstudies/web/components/DotMatrixBoard'
83
+ import '@motionstudies/web/dot-matrix-board.css'
84
+
85
+ <DotMatrixBoard
86
+ label="Bus departures"
87
+ columns={[
88
+ { key: 'route', label: 'Route', characters: 4, minCharacters: 4 },
89
+ { key: 'destination', label: 'Destination', characters: 24 },
90
+ { key: 'time', label: 'Due', characters: 6, minCharacters: 6, align: 'right' },
91
+ ]}
92
+ rows={[{ id: 'bus-71', cells: { route: '71', destination: 'City Centre', time: '2 min' } }]}
93
+ lineCount="auto"
94
+ style={{ height: 360 }}
95
+ />
96
+ ```
97
+
98
+ `lineCount` defaults to six display slots and accepts 1–30 (values outside that range are clamped; non-finite values use six). Fixed counts keep all slots and scale their contents to the available height, so dense boards need taller containers to remain readable. `"auto"` observes the actual container and fits 1–30 rows at a target `minRowHeight` of 34px. The default board height is 320px; use `style`, `className`, or `height: '100%'` inside a parent with a defined height. Unused slots stay blank; rows beyond the visible slots are omitted, without pagination or changes to consumer selection.
99
+
100
+ For dot-matrix columns, `characters` is a width weight, while `minCharacters` reserves space before the remaining width is distributed. At very narrow widths all columns scale down. Long cell values are visually ellipsized, with their full original text available to assistive technology and on hover. Characters outside the bitmap alphabet (including accented names and non-Latin scripts) use SVG text as a visual fallback. `--matrix-ink` and `--matrix-unlit` customize the LEDs. Updates are immediate, with no flashing or scrolling animation. The **Bus boards** lab specimen exercises both presentations, height/width resizing, row counts, selection, long names, loading and empty states using synthetic timetable data.
101
+
102
+ `variant="uk-rail"` gives the matrix a square black enclosure, mixed-case amber lettering, matrix column headings and horizontal display bands. Optional `heading` and `headingColumnSpan` replace the visual labels across the leading columns while preserving the individual accessible column headers. `footerLabel` and `clockLabel` add a matrix footer; the consumer owns clock formatting and updates. No live clock or pagination is inferred. `DotMatrixRow.note` adds a detail line and an accessible description on the selection button. Details consume one display slot and stay with their departure: if only one slot remains, the next departure with a detail waits until there is room for both. A one-line board still shows its first departure, retaining its full note for assistive technology.
103
+
104
+ ## Rail, bus and airport hero cards
105
+
106
+ Hero cards have separate transport-specific APIs and visual identities, with shared display components underneath:
107
+
108
+ | Card | Identity | Departure fields | Default display |
109
+ | --- | --- | --- | --- |
110
+ | `RailStationHeroCard` | Station name, optional code and locality | Scheduled time, destination, platform, expected time/status, via/service note | UK rail matrix |
111
+ | `BusStopHeroCard` | Stop name, optional stop code and locality | Route, destination, due estimate, optional via | Bus dot matrix |
112
+ | `AirportHeroCard` | IATA code, airport name and city | Flight, time, destination/origin, gate, remarks, direction tabs | Split flap |
113
+
114
+ Import the new cards from `@motionstudies/web/components/RailStationHeroCard` or `@motionstudies/web/components/BusStopHeroCard`, plus `@motionstudies/web/transport-hero-cards.css` (which includes both board styles). Airport imports and study-window behavior remain as documented above.
115
+
116
+ ```tsx
117
+ <RailStationHeroCard
118
+ station={{ name: 'Bristol Temple Meads', code: 'BRI', locality: 'Bristol' }}
119
+ departures={[{
120
+ id: 'train-1', time: '17:15', destination: 'Portsmouth Harbour',
121
+ platform: '9', expected: '17:22', via: 'Eastleigh',
122
+ }]}
123
+ lineCount="auto"
124
+ boardHeight={400}
125
+ clockLabel="16:49:26"
126
+ footerLabel="Study timetable"
127
+ note="Synthetic timetable. Example times, not a live service."
128
+ />
129
+
130
+ <BusStopHeroCard
131
+ stop={{ name: 'Anchor Road', code: 'A1', locality: 'Bristol' }}
132
+ departures={[{ id: 'bus-1', route: '71', destination: 'City Centre', due: '2 min' }]}
133
+ lineCount={6}
134
+ note="Synthetic timetable. Example estimates, not a live service."
135
+ />
136
+ ```
137
+
138
+ Rail and bus consumers supply already ordered and formatted departures, including their own filtering, timezones and freshness. Missing times, platforms and statuses stay unknown rather than becoming “On time”. Both cards accept `presentation` (`'uk-rail'`, `'dot-matrix'`, `'split-flap'`), `lineCount`, `boardHeight` in pixels, `minRowHeight`, `loading`, `error`, `onRetry`, localized `labels`, and controlled `onSelectDeparture`/`selectedDepartureId`. A required `note` explains the source. The matrix fits its container; the split-flap alternative contains scrolling and places any service details in an Information column. Fixed line counts represent physical matrix lines; in split-flap mode they limit departure rows. The **Transport heroes** lab compares all three cards, switches the new cards' presentations, and exercises details, row fitting, updates, long names and failure states.
139
+
140
+ The rail card additionally accepts `presentation="sbb"`: a blue-and-white typographic departure board with service badges, scheduled time, destination/via information and prominent track numbers. This layout follows the information hierarchy in [SBB's general display guide](https://www.sbb.ch/en/travel-information/stations/services-station/station-customer-information/general-display-board.html). It uses ordinary text, including accented and non-Latin names. `RailDeparture.service` supplies a train label such as `IC 1`; optional `serviceCategory` (`'intercity'`, `'international'`, `'regional'`, `'suburban'`) selects the badge treatment. `platformSector` supplies a separate sector label when known. Expected times or disruption messages appear below the scheduled time; absence of a message does not manufacture an “On time” assertion.
141
+
142
+ For this layout, `lineCount` counts departures with their inline detail, and `"auto"` fits rows using a default target height of 64px. Dense fixed counts reduce type size; long values remain in the accessible text and hover titles. Labels stay consumer-owned, including the added `service` column label. The lab's **SBB departure board** option selects a synthetic Zürich HB example with German, French, Italian and English labels. Both new board styles are included in `transport-hero-cards.css`.
143
+
144
+ At compact widths the SBB layout stacks the service badge under the time, preserving destination space and the separate track column. Rail and bus hero padding follows the card width rather than the viewport. The lab includes a 240–980px width slider and Compact/Mobile/Panel/Wide presets, plus a 180–640px board-height control. The size regression suite covers seven card widths, four board heights, fixed and automatic line counts, long destinations, selection during updates, mobile viewports and loading/error states in Chromium and WebKit. Prefer `lineCount="auto"` for small panels; high fixed line counts deliberately trade text size for density.
145
+
146
+ ```tsx
147
+ <RailStationHeroCard
148
+ presentation="sbb"
149
+ station={{ name: 'Zürich HB' }}
150
+ labels={{ station: 'Bahnhof', departures: 'Abfahrt', service: 'Zug', time: 'Zeit', destination: 'Nach', platform: 'Gleis' }}
151
+ departures={[{ id: 'example-1', service: 'IC 1', serviceCategory: 'intercity',
152
+ time: '09:02', destination: 'Genève-Aéroport', via: 'Bern · Lausanne', platform: '32', platformSector: 'ABCD' }]}
153
+ lineCount="auto"
154
+ boardHeight={430}
155
+ note="Synthetic timetable · Example data."
156
+ />
157
+ ```
158
+
79
159
  The shared board also accepts `loading`, a localized `loadingMessage`, and `loadingRows` (default five). While loading, its decorative rows cycle through staggered letters and digits; they are hidden from assistive technology and cannot be selected. A single status message announces loading. When data arrives, characters flip through a short sequence and settle into their actual values; later changes animate only the changed characters. These CSS animations have no JavaScript timers and stop looping when loading ends or the board is removed. Reduced-motion users get static blank loading flaps and immediate final text. `AirportHeroCard` uses this shared loading treatment automatically. Use **Reload board** in the Airports lab to preview the complete loading-to-ready transition.
80
160
 
81
161
  Empty messages also appear on the flaps, in the widest column (the destination/origin column in airport cards), with the other columns blank. Longer localized messages wrap across display rows instead of being truncated. They settle with the same animation as flight details, and one hidden status announces the complete message to assistive technology. This also applies when the study clock moves into a window with no movements.
@@ -83,3 +163,19 @@ Empty messages also appear on the flaps, in the widest column (the destination/o
83
163
  Rail and other transport consumers can share the same time filtering through `movementBoardWindow(study, horizon)` and `movementsForBoard(entries, window, maxRows)` from `@motionstudies/core/domain/movement-board`. Format the returned numeric times when mapping them into `SplitFlapBoard` cells. The lab's rail board follows the same study clock and horizon as its airport card.
84
164
 
85
165
  `@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
166
+
167
+ ## Optional live airport feed
168
+
169
+ `AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
170
+
171
+ Live timestamps are Unix seconds and use the airport's timezone for display, independently of recorded service time. The wrapper does not pass live flight IDs to the recorded scene's selection callback. The recorded card remains mounted while hidden; the edition still owns playback and can pause its study when appropriate. Only Now mode fetches flight boards; checking capabilities does not query the paid provider. Stale results carry their retrieval time and disappear when expired. Source data never falls back to synthetic or recorded flights under a live label.
172
+
173
+ See [service architecture and operations](../docs/LIVE-AIRPORTS.md). The Worker is deployed separately; npm publication and edition adoption remain explicit release steps.
174
+
175
+ ## Shared recorded air compilation
176
+
177
+ `@motionstudies/data/adsb-heatmap` consolidates the offline heatmap pipeline previously copied between editions. `ingestAdsbHeatmaps` reads cached gzip slices, decodes observations, filters transport-scale tracks, splits flights, and writes either an opening snapshot or an indexed day with overlapping chunks. Source hashes, chunk hashes and ODbL attribution accompany the output. It makes no network requests.
178
+
179
+ The same `decodeAdsbHeatmap` now powers `enrichAirEndpoints`; endpoint inference retains full coordinate precision, while playback compilation retains the existing five-decimal coordinates. `transportAirTracks` and `chunkAirSnapshot` are also available for consumers that assemble their own pipeline. All functions have public TypeScript declarations and work in the packed Node package.
180
+
181
+ Editions supply geographic bounds, service date, explicit UTC offset, optional timezone, input files and output paths. Flight IDs and chunk overlap retain the existing contracts. The default splits known callsign changes and gaps over 30 minutes. Set `splitTracks: false` only when reproducing a legacy opening snapshot with one ID per aircraft. See [adoption and compatibility](../docs/AIR-DATA.md).
@@ -0,0 +1,27 @@
1
+ import { type AirportHeroCardProps } from './AirportHeroCard.tsx';
2
+ declare const defaults: {
3
+ study: string;
4
+ now: string;
5
+ checking: string;
6
+ unavailable: string;
7
+ returnToStudy: string;
8
+ loading: string;
9
+ updated: string;
10
+ stale: string;
11
+ localTime: string;
12
+ scheduled: string;
13
+ revised: string;
14
+ note: string;
15
+ };
16
+ export interface AirportBoardProps {
17
+ readonly studyCard: AirportHeroCardProps;
18
+ readonly live: {
19
+ readonly baseUrl: string;
20
+ readonly edition: string;
21
+ readonly airport: string;
22
+ };
23
+ readonly labels?: Partial<typeof defaults>;
24
+ }
25
+ /** Optional live enhancement. Keeps the study clock and recorded flight selection separate. */
26
+ export declare function AirportBoard({ studyCard, live, labels }: AirportBoardProps): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,28 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { AirportHeroCard } from './AirportHeroCard.js';
4
+ import { useAirportFeed } from '../use-airport-feed.js';
5
+ import { useNowClock } from '../use-now-clock.js';
6
+ const defaults = {
7
+ study: 'Study', now: 'Now', checking: 'Checking live availability…', unavailable: 'Live updates unavailable.',
8
+ returnToStudy: 'Return to study', loading: 'Loading current airport board…', updated: 'Retrieved', stale: 'Updates delayed',
9
+ localTime: 'Local time', scheduled: 'Scheduled', revised: 'Revised', note: 'AeroDataBox · Scheduled or revised times as supplied. Revisions may be estimated or actual. All times local. Coverage may be incomplete.',
10
+ };
11
+ /** Optional live enhancement. Keeps the study clock and recorded flight selection separate. */
12
+ export function AirportBoard({ studyCard, live, labels }) {
13
+ const copy = { ...defaults, ...labels };
14
+ const clock = useNowClock((instant) => Math.floor(instant.getTime() / 1000));
15
+ const feed = useAirportFeed(live.baseUrl, live.edition, live.airport, clock.active);
16
+ const [selected, setSelected] = useState();
17
+ const response = feed.response, snapshot = response?.snapshot;
18
+ const now = clock.time ?? snapshot?.fetchedAt ?? 0;
19
+ const usable = snapshot && snapshot.expiresAt > now && ['fresh', 'stale'].includes(response.status);
20
+ const stale = usable && (response?.status === 'stale' || now >= snapshot.freshUntil);
21
+ const timeZone = snapshot?.airport.timeZone ?? 'UTC';
22
+ const formatter = new Intl.DateTimeFormat(undefined, { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
23
+ const formatTime = (time) => formatter.format(new Date(time * 1000));
24
+ const dateLabel = new Intl.DateTimeFormat(undefined, { timeZone, day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(now * 1000));
25
+ function stop() { clock.stop(); setSelected(undefined); }
26
+ const selectedFlight = usable ? [...snapshot.departures, ...snapshot.arrivals].find((flight) => flight.id === selected) : undefined;
27
+ return _jsxs("div", { className: "ms-airport-board", children: [_jsxs("div", { className: "ms-airport-board__modes", role: "group", "aria-label": `${copy.study} / ${copy.now}`, children: [_jsx("button", { type: "button", "aria-pressed": !clock.active, onClick: stop, children: copy.study }), _jsx("button", { type: "button", "aria-pressed": clock.active, disabled: !clock.active && !feed.available, onClick: clock.start, children: copy.now }), !clock.active && _jsx("span", { children: feed.checking ? copy.checking : !feed.available ? copy.unavailable : null })] }), _jsx("div", { hidden: clock.active, children: _jsx(AirportHeroCard, { ...studyCard }) }), clock.active && _jsx(_Fragment, { children: usable ? _jsxs(_Fragment, { children: [_jsx(AirportHeroCard, { airport: snapshot.airport, study: { time: now, windowStart: snapshot.windowStart, windowEnd: snapshot.windowEnd }, departures: snapshot.departures.map((f) => ({ ...f, time: f.revisedTime ?? f.scheduledTime, stand: f.gate })), arrivals: snapshot.arrivals.map((f) => ({ ...f, time: f.revisedTime ?? f.scheduledTime, stand: f.gate })), formatTime: formatTime, dateLabel: dateLabel, labels: { ...studyCard.labels, studyTime: copy.localTime }, maxRows: studyCard.maxRows, horizon: studyCard.horizon, selectedFlightId: selected, onSelectFlight: setSelected, note: _jsxs(_Fragment, { children: [_jsxs("span", { role: "status", children: [stale ? `${copy.stale} · ` : '', copy.updated, " ", formatTime(snapshot.fetchedAt), " \u00B7 "] }), copy.note] }) }, snapshot.airport.iata), selectedFlight && _jsxs("p", { className: "ms-airport-board__detail", children: [selectedFlight.service, " \u00B7 ", selectedFlight.place ?? '—', " \u00B7 ", selectedFlight.status ?? '—', selectedFlight.scheduledTime !== undefined && _jsxs(_Fragment, { children: [" \u00B7 ", copy.scheduled, " ", formatTime(selectedFlight.scheduledTime)] }), selectedFlight.revisedTime !== undefined && _jsxs(_Fragment, { children: [" \u00B7 ", copy.revised, " ", formatTime(selectedFlight.revisedTime)] })] })] }) : _jsxs("div", { className: "ms-airport-board__unavailable", role: "status", children: [_jsx("p", { children: feed.loading || feed.checking ? copy.loading : copy.unavailable }), _jsx("button", { type: "button", onClick: stop, children: copy.returnToStudy })] }) })] });
28
+ }
@@ -1,4 +1,4 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId, useState } from 'react';
3
3
  import { formatServiceTime } from '@motionstudies/core/domain/network';
4
4
  import { movementBoardWindow, movementsForBoard } from '@motionstudies/core/domain/movement-board';
@@ -20,12 +20,12 @@ export function AirportHeroCard({ airport, departures, arrivals, note, study, ho
20
20
  const entries = movementsForBoard(direction === 'departures' ? departures : arrivals, window, maxRows);
21
21
  const rows = entries.map((entry) => ({ id: entry.id, tone: entry.tone,
22
22
  cells: { time: formatTime(entry.time), service: entry.service, place: entry.place, stand: entry.stand, status: entry.status } }));
23
- return _jsxs("section", { className: `ms-airport-hero ${className}`, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-airport-hero__masthead", children: [_jsxs("span", { className: "ms-airport-hero__eyebrow", children: [_jsx("span", { "aria-hidden": "true", children: "\u2197" }), " ", copy.airport, " / ", airport.city] }), _jsxs("span", { className: "ms-airport-hero__clock", children: [dateLabel && _jsxs(_Fragment, { children: [dateLabel, " \u00B7 "] }), copy.studyTime, " ", Number.isFinite(study.time) ? formatTime(study.time) : '—'] })] }), _jsxs("div", { className: "ms-airport-hero__identity", children: [_jsx("strong", { className: "ms-airport-hero__code", children: airport.iata }), _jsx("h2", { id: titleId, children: airport.name }), _jsx("span", { className: "ms-airport-hero__runway", "aria-hidden": "true" })] }), _jsx("div", { className: "ms-airport-hero__directions", role: "group", "aria-label": `${copy.departures} / ${copy.arrivals}`, children: ['departures', 'arrivals'].map((value) => _jsxs("button", { type: "button", "aria-pressed": direction === value, "aria-controls": boardId, onClick: () => setDirection(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'departures' ? '↗' : '↘' }), " ", copy[value]] }, value)) }), _jsx("p", { className: "ms-airport-hero__window", children: window ? `${copy.boardWindow} ${formatTime(window.start)}–${formatTime(window.end)}` : copy.outsideWindow }), _jsx("div", { id: boardId, children: error ? _jsxs("div", { className: "ms-airport-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
23
+ return _jsxs("section", { className: `ms-airport-hero ${className}`, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-airport-hero__masthead", children: [_jsxs("span", { className: "ms-airport-hero__eyebrow", children: [_jsx("span", { "aria-hidden": "true", children: "\u2197" }), " ", copy.airport, " / ", airport.city] }), dateLabel && _jsx("span", { className: "ms-airport-hero__date", children: dateLabel })] }), _jsxs("div", { className: "ms-airport-hero__identity", children: [_jsx("strong", { className: "ms-airport-hero__code", children: airport.iata }), _jsx("h2", { id: titleId, children: airport.name }), _jsxs("div", { className: "ms-airport-hero__clock", children: [_jsx("span", { className: "ms-airport-hero__clock-label", children: copy.studyTime }), ' ', _jsx("strong", { className: "ms-airport-hero__clock-value", children: Number.isFinite(study.time) ? formatTime(study.time) : '—' })] })] }), _jsxs("div", { className: "ms-airport-hero__board-header", children: [_jsx("div", { className: "ms-airport-hero__directions", role: "group", "aria-label": `${copy.departures} / ${copy.arrivals}`, children: ['departures', 'arrivals'].map((value) => _jsxs("button", { type: "button", "aria-pressed": direction === value, "aria-controls": boardId, onClick: () => setDirection(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'departures' ? '↗' : '↘' }), " ", copy[value]] }, value)) }), _jsx("p", { className: "ms-airport-hero__window", children: window ? `${copy.boardWindow} ${formatTime(window.start)}–${formatTime(window.end)}` : copy.outsideWindow })] }), _jsx("div", { id: boardId, children: error ? _jsxs("div", { className: "ms-airport-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
24
24
  : _jsx(SplitFlapBoard, { label: `${airport.iata} ${copy[direction]}`, columns: [
25
25
  { key: 'time', label: copy.time, characters: 5 },
26
26
  { key: 'service', label: copy.service, characters: 7 },
27
- { key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters: 16 },
27
+ { key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters: 18 },
28
28
  { key: 'stand', label: copy.stand, characters: 3 },
29
- { key: 'status', label: copy.status, characters: 10 },
29
+ { key: 'status', label: copy.status, characters: 11 },
30
30
  ], rows: rows, loading: loading, loadingMessage: copy.loading, loadingRows: rows.length || Math.min(maxRows, 5), emptyMessage: direction === 'departures' ? copy.emptyDepartures : copy.emptyArrivals, selectionColumn: "service", onSelectRow: onSelectFlight, selectedRowId: selectedFlightId }) }), _jsx("div", { className: "ms-airport-hero__note", children: note })] });
31
31
  }
@@ -0,0 +1,37 @@
1
+ import type { DotMatrixRow } from './DotMatrixBoard.tsx';
2
+ import { type TransportHeroOptions } from './TransportHeroBoard.tsx';
3
+ export interface BusDeparture {
4
+ readonly id: string;
5
+ readonly route: string;
6
+ readonly destination: string;
7
+ /** Consumer-formatted estimate or scheduled time; missing values stay unknown. */
8
+ readonly due?: string;
9
+ readonly via?: string;
10
+ readonly tone?: DotMatrixRow['tone'];
11
+ }
12
+ declare const defaultLabels: {
13
+ departures: string;
14
+ route: string;
15
+ destination: string;
16
+ due: string;
17
+ stop: string;
18
+ via: string;
19
+ loading: string;
20
+ empty: string;
21
+ retry: string;
22
+ detail: string;
23
+ };
24
+ export interface BusStopHeroCardProps extends TransportHeroOptions {
25
+ readonly stop: {
26
+ readonly name: string;
27
+ readonly code?: string;
28
+ readonly locality?: string;
29
+ };
30
+ readonly departures: readonly BusDeparture[];
31
+ readonly labels?: Partial<typeof defaultLabels>;
32
+ readonly onSelectDeparture?: (id: string) => void;
33
+ readonly selectedDepartureId?: string;
34
+ }
35
+ /** Local stop identity and route-first arrival estimates. The consumer owns ordering. */
36
+ export declare function BusStopHeroCard({ stop, departures, labels, presentation, lineCount, boardHeight, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className }: BusStopHeroCardProps): import("react").JSX.Element;
37
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { TransportHeroBoard } from './TransportHeroBoard.js';
4
+ const defaultLabels = { departures: 'Bus departures from', route: 'Route', destination: 'Destination', due: 'Due', stop: 'Stop',
5
+ via: 'via', loading: 'Loading bus departures…', empty: 'No buses expected.', retry: 'Retry', detail: 'Information' };
6
+ /** Local stop identity and route-first arrival estimates. The consumer owns ordering. */
7
+ export function BusStopHeroCard({ stop, departures, labels, presentation = 'dot-matrix', lineCount = 6, boardHeight = 360, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className = '' }) {
8
+ const titleId = useId();
9
+ const copy = { ...defaultLabels, ...labels };
10
+ return _jsxs("section", { className: `ms-bus-stop-hero ${className}`, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-bus-stop-hero__identity", children: [_jsx("span", { className: "ms-bus-stop-hero__symbol", "aria-hidden": "true", children: _jsxs("svg", { viewBox: "0 0 32 32", children: [_jsx("rect", { x: "7", y: "4", width: "18", height: "23", rx: "4" }), _jsx("path", { d: "M7 17h18M11 8h10M10 27v3m12-3v3" }), _jsx("circle", { cx: "11", cy: "22", r: "1" }), _jsx("circle", { cx: "21", cy: "22", r: "1" })] }) }), _jsxs("div", { children: [_jsx("p", { children: copy.departures }), _jsx("h2", { id: titleId, children: stop.name }), stop.locality && _jsx("p", { children: stop.locality })] }), stop.code && _jsxs("div", { className: "ms-bus-stop-hero__code", children: [_jsx("span", { children: copy.stop }), _jsx("strong", { children: stop.code })] })] }), _jsx(TransportHeroBoard, { label: `${stop.name} bus departures`, presentation: presentation, boardHeight: boardHeight, columns: [{ key: 'route', label: copy.route, characters: 4, minCharacters: 4 },
11
+ { key: 'destination', label: copy.destination, characters: 24, minCharacters: 5 },
12
+ { key: 'due', label: copy.due, characters: 6, minCharacters: 6, align: 'right' }], rows: departures.map((entry) => ({ id: entry.id, tone: entry.tone, note: entry.via ? `${copy.via} ${entry.via}` : undefined,
13
+ cells: { route: entry.route, destination: entry.destination, due: entry.due } })), 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: "route" }), _jsx("div", { className: "ms-transport-hero__note", children: note })] });
14
+ }
@@ -0,0 +1,31 @@
1
+ import { type CSSProperties } from 'react';
2
+ import type { SplitFlapBoardProps, SplitFlapColumn, SplitFlapRow } from './SplitFlapBoard.tsx';
3
+ export interface DotMatrixColumn extends SplitFlapColumn {
4
+ /** Space reserved before distributing the remaining width by `characters`. */
5
+ readonly minCharacters?: number;
6
+ readonly align?: 'left' | 'right';
7
+ }
8
+ export interface DotMatrixRow extends SplitFlapRow {
9
+ /** Optional detail line; also included in the accessible departure description. */
10
+ readonly note?: string;
11
+ }
12
+ export interface DotMatrixBoardProps extends Omit<SplitFlapBoardProps, 'columns' | 'rows' | 'loadingRows'> {
13
+ readonly columns: readonly DotMatrixColumn[];
14
+ readonly rows: readonly DotMatrixRow[];
15
+ readonly variant?: 'bus' | 'uk-rail';
16
+ /** Optional matrix heading spanning the first `headingColumnSpan` columns. */
17
+ readonly heading?: string;
18
+ readonly headingColumnSpan?: number;
19
+ readonly footerLabel?: string;
20
+ /** Consumer-formatted clock, updated by the consumer; never inferred as live. */
21
+ readonly clockLabel?: string;
22
+ /** Fixed display slots, or fit as many rows as possible into the board height. */
23
+ readonly lineCount?: number | 'auto';
24
+ /** Target minimum row height in auto mode, in CSS pixels. Default 34. */
25
+ readonly minRowHeight?: number;
26
+ readonly className?: string;
27
+ /** Set height here, or through CSS, to fit a panel. Default height is 320px. */
28
+ readonly style?: CSSProperties;
29
+ }
30
+ /** The consumer owns row order, arrival estimates and freshness, as with SplitFlapBoard. */
31
+ export declare function DotMatrixBoard({ label, columns, rows, lineCount, minRowHeight, loading, loadingMessage, emptyMessage, onSelectRow, selectedRowId, selectionColumn, className, style, variant, heading, headingColumnSpan, footerLabel, clockLabel }: DotMatrixBoardProps): import("react").JSX.Element;
@@ -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,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
+ }
@@ -1,21 +1,42 @@
1
1
  @import './split-flap-board.css';
2
2
 
3
- .ms-airport-hero { --airport-accent: #edc96b; 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; }
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
5
  .ms-airport-hero__eyebrow { color: var(--airport-accent); text-transform: uppercase; font-size: 10px; letter-spacing: .18em; }
6
6
  .ms-airport-hero__eyebrow > span { font-size: 18px; margin-right: 6px; }
7
- .ms-airport-hero__clock { color: #bebfac; font: 12px/1.5 ui-monospace, monospace; }
8
- .ms-airport-hero__window { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; margin: 0 0 12px; }
9
- .ms-airport-hero__identity { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 24px; padding: 22px 0 26px; }
10
- .ms-airport-hero__code { color: var(--airport-accent); font-size: clamp(54px, 8vw, 88px); font-weight: 650; line-height: .95; letter-spacing: -.065em; }
11
- .ms-airport-hero__identity h2 { flex: 1; min-width: 140px; max-width: 300px; color: #f0ecdd; font: 400 clamp(20px, 3vw, 27px)/1.15 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
12
- .ms-airport-hero__runway { margin-left: auto; width: 54px; height: 62px; border-inline: 1px solid #797c6166; transform: skewY(-20deg); background: repeating-linear-gradient(to bottom, #b5b79266 0 8px, transparent 8px 16px) center / 2px 100% no-repeat; }
13
- .ms-airport-hero__directions { display: flex; gap: 22px; border-bottom: 1px solid #4b4d40; margin-bottom: 16px; }
14
- .ms-airport-hero__directions button { 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; }
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; }
15
17
  .ms-airport-hero__directions button[aria-pressed='true'] { color: var(--airport-accent); border-bottom-color: var(--airport-accent); }
16
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; }
17
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; }
18
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; }
19
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; }
20
25
  .ms-airport-hero__note { color: #b8baaa; font-size: 11px; line-height: 1.6; margin-top: 14px; }
21
- @media (max-width: 480px) { .ms-airport-hero__runway { display: none; } .ms-airport-hero__directions { gap: 18px; } }
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
+ }
@@ -0,0 +1,39 @@
1
+ @import './dot-matrix-board.css';
2
+ @import './split-flap-board.css';
3
+ @import './sbb-departure-board.css';
4
+
5
+ .ms-bus-stop-hero, .ms-rail-station-hero { box-sizing: border-box; min-width: 0; container-type: inline-size; padding: clamp(12px, 4%, 24px); font-family: ui-sans-serif, system-ui, sans-serif; }
6
+ .ms-bus-stop-hero *, .ms-rail-station-hero * { box-sizing: border-box; }
7
+ .ms-bus-stop-hero { color: #e8ebe2; background: #2b3532; border: 1px solid #56645d; border-radius: 6px; }
8
+ .ms-bus-stop-hero__identity { display: flex; align-items: center; gap: 18px; padding: 4px 0 24px; }
9
+ .ms-bus-stop-hero__identity > div { min-width: 0; }
10
+ .ms-bus-stop-hero__identity p { margin: 0; color: #b9c7be; font-size: 12px; line-height: 1.6; }
11
+ .ms-bus-stop-hero__identity h2 { margin: 4px 0; font-size: clamp(24px, 5cqi, 40px); font-weight: 500; line-height: 1.1; letter-spacing: -.03em; overflow-wrap: anywhere; }
12
+ .ms-bus-stop-hero__symbol { display: grid; place-items: center; flex-shrink: 0; width: 48px; height: 58px; background: #dfe7d9; color: #2b3532; border-radius: 2px; }
13
+ .ms-bus-stop-hero__symbol svg { width: 32px; fill: none; stroke: currentColor; stroke-width: 2; }
14
+ .ms-bus-stop-hero__code { margin-left: auto; text-align: right; flex-shrink: 0; }
15
+ .ms-bus-stop-hero__code span { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
16
+ .ms-bus-stop-hero__code strong { font-size: 28px; line-height: 1.2; font-weight: 500; }
17
+ .ms-rail-station-hero { color: #f3f0e3; background: linear-gradient(120deg, #1d272c, #11191d); border: 1px solid #425057; border-top: 4px solid #d0b874; border-radius: 3px; }
18
+ .ms-rail-station-hero__masthead { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; color: #c9c8b9; font-size: 10px; text-transform: uppercase; letter-spacing: .14em; }
19
+ .ms-rail-station-hero__masthead strong { padding: 4px 7px; border: 1px solid #5a605b; font: 500 12px/1.2 ui-monospace, monospace; }
20
+ .ms-rail-station-hero__identity { display: flex; align-items: center; gap: 18px; padding: 20px 0 24px; }
21
+ .ms-rail-station-hero__identity > span { flex-shrink: 0; width: 42px; color: #e4d7b2; }
22
+ .ms-rail-station-hero__identity svg { width: 100%; fill: none; stroke: currentColor; stroke-width: 3; }
23
+ .ms-rail-station-hero__identity h2 { margin: 0; font-size: clamp(25px, 5cqi, 42px); line-height: 1.1; letter-spacing: -.035em; font-weight: 500; overflow-wrap: anywhere; }
24
+ .ms-rail-station-hero[data-presentation='sbb'] { background: #0c1730; border-color: #405375; border-top: 4px solid #eb001b; }
25
+ .ms-rail-station-hero[data-presentation='sbb'] .ms-rail-station-hero__identity > span { width: 44px; height: 40px; padding: 5px; color: #fff; background: #d70019; }
26
+ .ms-rail-station-hero[data-presentation='sbb'] .ms-rail-station-hero__masthead { color: #b7c6e0; }
27
+ .ms-transport-hero__note { margin-top: 16px; color: #b8c0b9; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
28
+ .ms-transport-hero__error { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; min-height: 140px; padding: 24px; background: #111713; color: #ece8d6; }
29
+ .ms-transport-hero__error button { min-height: 40px; padding: 8px 16px; font: inherit; background: #29352c; color: inherit; border: 1px solid #627465; border-radius: 3px; cursor: pointer; }
30
+ .ms-transport-hero__error button:focus-visible { outline: 2px solid #f6cd46; outline-offset: 3px; }
31
+ .ms-transport-hero__flap { overflow: auto; min-width: 0; }
32
+ .ms-transport-hero__flap-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 12px 0; font: 12px/1.5 ui-monospace, monospace; color: #d8d6c1; }
33
+ @container (max-width: 440px) {
34
+ .ms-bus-stop-hero__identity, .ms-rail-station-hero__identity { gap: 12px; }
35
+ .ms-bus-stop-hero__symbol { width: 36px; height: 46px; }
36
+ .ms-rail-station-hero__identity > span { width: 32px; }
37
+ .ms-bus-stop-hero__code strong { font-size: 20px; }
38
+ .ms-rail-station-hero .ms-dot-matrix-board { padding: 12px 8px; }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -99,7 +99,34 @@
99
99
  "types": "./components/ButtonTooltips.d.ts",
100
100
  "import": "./components/ButtonTooltips.js",
101
101
  "default": "./components/ButtonTooltips.js"
102
- }
102
+ },
103
+ "./use-airport-feed": {
104
+ "types": "./use-airport-feed.d.ts",
105
+ "import": "./use-airport-feed.js",
106
+ "default": "./use-airport-feed.js"
107
+ },
108
+ "./components/AirportBoard": {
109
+ "types": "./components/AirportBoard.d.ts",
110
+ "import": "./components/AirportBoard.js",
111
+ "default": "./components/AirportBoard.js"
112
+ },
113
+ "./components/DotMatrixBoard": {
114
+ "types": "./components/DotMatrixBoard.d.ts",
115
+ "import": "./components/DotMatrixBoard.js",
116
+ "default": "./components/DotMatrixBoard.js"
117
+ },
118
+ "./dot-matrix-board.css": "./components/dot-matrix-board.css",
119
+ "./components/BusStopHeroCard": {
120
+ "types": "./components/BusStopHeroCard.d.ts",
121
+ "import": "./components/BusStopHeroCard.js",
122
+ "default": "./components/BusStopHeroCard.js"
123
+ },
124
+ "./components/RailStationHeroCard": {
125
+ "types": "./components/RailStationHeroCard.d.ts",
126
+ "import": "./components/RailStationHeroCard.js",
127
+ "default": "./components/RailStationHeroCard.js"
128
+ },
129
+ "./transport-hero-cards.css": "./components/transport-hero-cards.css"
103
130
  },
104
131
  "files": [
105
132
  "**/*.js",
@@ -114,7 +141,7 @@
114
141
  "**/*.css"
115
142
  ],
116
143
  "dependencies": {
117
- "@motionstudies/core": "0.1.0-alpha.6"
144
+ "@motionstudies/core": "0.1.0-alpha.8"
118
145
  },
119
146
  "peerDependencies": {
120
147
  "react": "^19.2.8",
@@ -0,0 +1,9 @@
1
+ import { type AirportFeedResponse } from '@motionstudies/core/domain/live-airport';
2
+ export interface AirportFeedState {
3
+ readonly available: boolean;
4
+ readonly checking: boolean;
5
+ readonly loading: boolean;
6
+ readonly response?: AirportFeedResponse;
7
+ }
8
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
9
+ export declare function useAirportFeed(baseUrl: string, edition: string, airport: string, enabled: boolean): AirportFeedState;
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { isAirportFeedResponse } from '@motionstudies/core/domain/live-airport';
3
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
4
+ export function useAirportFeed(baseUrl, edition, airport, enabled) {
5
+ const source = `${baseUrl.replace(/\/$/, '')}/api/airports/v1/${encodeURIComponent(edition)}`;
6
+ const identity = `${source}/${airport}/${enabled}`;
7
+ const empty = { available: false, checking: true, loading: false };
8
+ const [stored, setStored] = useState({ ...empty, identity });
9
+ const state = stored.identity === identity ? stored : empty;
10
+ useEffect(() => {
11
+ const controller = new AbortController();
12
+ let timer, failures = 0;
13
+ const update = (patch) => {
14
+ if (!controller.signal.aborted)
15
+ setStored((old) => ({ ...(old.identity === identity ? old : empty), ...patch, identity }));
16
+ };
17
+ update(empty);
18
+ const tick = async () => {
19
+ let delay = 60_000;
20
+ if (document.hidden) {
21
+ timer = window.setTimeout(() => void tick(), delay);
22
+ return;
23
+ }
24
+ try {
25
+ const options = { cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]) };
26
+ const capabilitiesResponse = await fetch(`${source}/capabilities`, options);
27
+ if (!capabilitiesResponse.ok)
28
+ throw new Error('Capabilities unavailable');
29
+ const capabilities = await capabilitiesResponse.json();
30
+ if (capabilities.version !== 1 || typeof capabilities.enabled !== 'boolean' || !Array.isArray(capabilities.airports))
31
+ throw new Error('Invalid capabilities');
32
+ const available = capabilities.enabled && capabilities.airports.includes(airport);
33
+ update({ available, checking: false });
34
+ if (!available) {
35
+ update({ loading: false, response: { version: 1, status: 'disabled', reason: 'disabled', retryAfterSeconds: 60 } });
36
+ }
37
+ else if (enabled) {
38
+ update({ loading: true });
39
+ const r = await fetch(`${source}/${encodeURIComponent(airport)}/board`, options);
40
+ const response = await r.json();
41
+ if (!isAirportFeedResponse(response))
42
+ throw new Error('Invalid board');
43
+ if (response.snapshot && (response.snapshot.airport.iata !== airport || response.snapshot.fetchedAt > Date.now() / 1000 + 60))
44
+ throw new Error('Mismatched board');
45
+ update({ response, loading: false, available: response.status !== 'disabled' });
46
+ // Check capabilities every minute even when the upstream circuit is paused.
47
+ }
48
+ failures = 0;
49
+ }
50
+ catch {
51
+ failures += 1;
52
+ delay = Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
53
+ if (!controller.signal.aborted)
54
+ setStored((old) => {
55
+ const snapshot = old.identity === identity ? old.response?.snapshot : undefined;
56
+ return { identity, available: old.identity === identity && old.available, checking: false, loading: false,
57
+ response: { version: 1, status: snapshot && snapshot.expiresAt > Date.now() / 1000 ? 'stale' : 'unavailable', reason: 'provider', retryAfterSeconds: delay / 1000,
58
+ ...(snapshot && snapshot.expiresAt > Date.now() / 1000 ? { snapshot } : {}) } };
59
+ });
60
+ }
61
+ finally {
62
+ if (!controller.signal.aborted)
63
+ timer = window.setTimeout(() => void tick(), delay);
64
+ }
65
+ };
66
+ void tick();
67
+ return () => { controller.abort(); window.clearTimeout(timer); };
68
+ // empty is deliberately reconstructed only when the source effect restarts.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [identity, source, airport, enabled]);
71
+ return state;
72
+ }