@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.
- package/README.md +171 -2
- package/components/AirportBoard.d.ts +27 -0
- package/components/AirportBoard.js +28 -0
- package/components/AirportHeroCard.d.ts +58 -0
- package/components/AirportHeroCard.js +31 -0
- package/components/BusStopHeroCard.d.ts +37 -0
- package/components/BusStopHeroCard.js +14 -0
- package/components/ButtonTooltips.d.ts +5 -0
- package/components/ButtonTooltips.js +146 -0
- package/components/DotMatrixBoard.d.ts +31 -0
- package/components/DotMatrixBoard.js +84 -0
- package/components/RailStationHeroCard.d.ts +46 -0
- package/components/RailStationHeroCard.js +20 -0
- package/components/SbbDepartureBoard.d.ts +26 -0
- package/components/SbbDepartureBoard.js +29 -0
- package/components/SplitFlapBoard.d.ts +25 -0
- package/components/SplitFlapBoard.js +58 -0
- package/components/TransportHeroBoard.d.ts +28 -0
- package/components/TransportHeroBoard.js +15 -0
- package/components/airport-hero-card.css +42 -0
- package/components/dot-matrix-board.css +49 -0
- package/components/dot-matrix-font.d.ts +2 -0
- package/components/dot-matrix-font.js +61 -0
- package/components/sbb-departure-board.css +49 -0
- package/components/split-flap-board.css +45 -0
- package/components/transport-hero-cards.css +39 -0
- package/mount-motion-study.js +3 -2
- package/package.json +67 -3
- package/use-airport-feed.d.ts +9 -0
- package/use-airport-feed.js +72 -0
- package/use-browser-location.d.ts +13 -0
- package/use-browser-location.js +39 -0
- package/use-json-asset.d.ts +9 -0
- package/use-json-asset.js +45 -0
- package/use-now-clock.d.ts +11 -0
- package/use-now-clock.js +45 -0
- package/use-transition-value.d.ts +12 -0
- package/use-transition-value.js +46 -0
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Shared packages for the Motion Studies transport instrument. The source workspac
|
|
|
4
4
|
|
|
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
|
-
- `@motionstudies/web`: picker, 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.
|
|
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, 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'
|
|
@@ -24,3 +24,172 @@ Keep the resolver stable across renders. Manifest paths and their chunk paths ar
|
|
|
24
24
|
Build release candidates with `npm run build:packages`. Distribution manifests and compiled ESM/declarations are written to `.package-dist/`; workspace manifests continue to point at source for fast local iteration. `npm run check:packed` packs and installs those distributions into a separate consumer, builds the lab and validates the public exports. No source aliases or workspace links are used in that consumer.
|
|
25
25
|
|
|
26
26
|
Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
|
|
27
|
+
|
|
28
|
+
## Now and browser location
|
|
29
|
+
|
|
30
|
+
`useNowClock` from `@motionstudies/web/use-now-clock` follows the wall clock on each animation frame, so returning from a suspended tab catches up immediately. Call `start()` from a Now button, set the consumer's playback rate to 1, and render its `time` while `active`. With `NationalNetworkScene`, pass `isPlaying={false}` while this external clock owns time. Call `stop()` before pausing, seeking or changing speed, and retain the last clock time for ordinary playback. Clear moving vehicle selections on entry to keep the camera still; panning and zooming remain available.
|
|
31
|
+
|
|
32
|
+
Supply a resolver `(instant: Date) => number | null` that maps the instant into the edition's service-time coordinates. The edition owns timezone, service date, daylight-saving rules and source coverage. Now is appropriate when the data gives a meaningful sense of this place at this time; exact live vehicle positions are not required. An edition may explicitly map the current local clock onto a suitable representative weekday or seasonal timetable, with a quiet label such as “Typical weekday · realtime pace”. Historical or modelled sources need that same meaningful relationship to the present; merely having timestamps is insufficient. Return null outside the chosen data's coverage: the hook stops, retains the last valid time and exposes `unavailable`. Do not silently relabel a recording as live or wrap a partial study window. Timetable and observed-data labels remain the consumer's responsibility; 1× playback does not imply a live vehicle feed.
|
|
33
|
+
|
|
34
|
+
`useBrowserLocation` from `@motionstudies/web/use-browser-location` requests a single position only when `locate()` is called. It exposes `location`, `status` and `clear()`; it neither stores nor sends coordinates. Render localized messages for denied, timeout and unavailable states. Clearing or unmounting discards late responses. A secure browser context and user permission are required.
|
|
35
|
+
|
|
36
|
+
Pass an in-coverage position as `NationalNetworkScene`'s `userLocation` for a steady glowing dot. Use the existing `focus-location` camera command once after locating, with `[longitude, latitude]` and a suitable `distanceScale`. Validate coverage in the consumer and preserve the view when the user is outside it. Show the reported `accuracy` in accessible text; the dot is an approximate position. The marker hides in diagram layouts. The lab's **Now** specimen uses a clearly labelled synthetic UTC timetable and covers clock, permission and out-of-coverage behavior. Public editions still require independent adoption and releases.
|
|
37
|
+
|
|
38
|
+
## Selection labels
|
|
39
|
+
|
|
40
|
+
`NationalNetworkScene` gives the selected station first label priority, followed by the selected route's terminal stops (including branches), then intermediate stops. Selected services use their own endpoints. Priority precedes retained labels and ordinary rank/tier admission; clearing selection restores edition ranking. This is built in for every consumer, including geographic and diagram layouts. Supply complete enabled infrastructure as `referenceSnapshot` to preserve endpoints through timetable gaps; its stop indexes need not match the active snapshot. See the [edition behaviour contract](https://github.com/emmettl/motionstudies/blob/main/docs/EDITIONS.md#selection-and-station-labels).
|
|
41
|
+
|
|
42
|
+
## Button help
|
|
43
|
+
|
|
44
|
+
`mountMotionStudy` installs one shared tooltip surface. Independent consumers such as the lab can render `ButtonTooltips` from `@motionstudies/web/components/ButtonTooltips` once instead. Put concise, action-oriented help in each button’s `data-tooltip`; icon buttons fall back to their `aria-label`. An empty `data-tooltip` opts out. Avoid native `title` attributes on these buttons, which can also appear during touch interaction.
|
|
45
|
+
|
|
46
|
+
Help appears after a short mouse hover or on keyboard focus when the primary pointer is fine and supports hover. Touch input suppresses it, including on hybrid devices. Escape, activation, scrolling and blur dismiss it. The tooltip stays inside the viewport, can itself be hovered, and temporarily extends `aria-describedby` without replacing existing descriptions. Copy and translations stay in the edition; rendering and input handling stay in this package. The Controls specimen and packed-consumer tests exercise this contract.
|
|
47
|
+
|
|
48
|
+
## Airport heroes and split-flap boards
|
|
49
|
+
|
|
50
|
+
`AirportHeroCard` provides an airport identity header and switchable departure/arrival boards. `SplitFlapBoard` is the underlying transport-neutral widget, also suitable for rail stations. Both use scoped package styles, semantic tables, full accessible cell values, keyboard-operable selection, contained horizontal scrolling and reduced-motion support. Only changed characters remount for the flap animation.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { AirportHeroCard } from '@motionstudies/web/components/AirportHeroCard'
|
|
54
|
+
import '@motionstudies/web/airport-hero-card.css'
|
|
55
|
+
|
|
56
|
+
<AirportHeroCard
|
|
57
|
+
key={airport.id}
|
|
58
|
+
airport={airport}
|
|
59
|
+
departures={departures}
|
|
60
|
+
arrivals={arrivals}
|
|
61
|
+
study={{ time, windowStart: metadata.windowStart, windowEnd: metadata.windowEnd }}
|
|
62
|
+
dateLabel={metadata.serviceDate}
|
|
63
|
+
note="Observed study · inferred directions; times are observations."
|
|
64
|
+
onSelectFlight={selectAirTrack}
|
|
65
|
+
selectedFlightId={selectedAirTrackId}
|
|
66
|
+
/>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Entries have a stable `id`, a `service` label, and optional numeric `time`, `place`, `stand`, `status` and `tone` (`neutral`, `accent` or `warning`). Movement times and `study.time`, `windowStart`, and `windowEnd` must use the same study-relative seconds and service date. Do not parse display strings or normalize numeric times at midnight: an event after 24:00 retains its value above 86,400. `formatTime` optionally controls display formatting; the default uses the study's `formatServiceTime` helper. The header clock is derived directly from `study.time`, with no independent wall clock.
|
|
70
|
+
|
|
71
|
+
The card sorts movements chronologically and shows up to eight rows per direction inside the intersection of the study bounds and a rolling window: ten minutes behind the playback clock and sixty minutes ahead, with inclusive endpoints. Override this with `horizon={{ lookBehindSeconds: 600, lookAheadSeconds: 3600 }}` and `maxRows`. Playback, backward seeking, changed study bounds and updated movement times all recalculate the rows. An out-of-study or invalid clock shows no movements; rows with missing or non-finite times are excluded because they cannot be placed in the window. Other unknown fields render as a dash. Labels include `studyTime`, `boardWindow` and `outsideWindow` for localization. Filtering does not infer operational statuses or clear the consumer's map selection when a row leaves the window.
|
|
72
|
+
|
|
73
|
+
Consumers still own time coordinates, source interpretation and data loading. Supply movements for the displayed horizon, not just aircraft active at the current second, and use the selected study's bounds rather than an individual progressive chunk's bounds. Do not turn an approach-envelope association into a confirmed departure/arrival: unclassified tracks should remain outside these direction lists. Current `AirTrack` data does not supply scheduled times, routes or gates; leave those fields absent, use observation times only when clearly labelled, and explain any inference in the required `note`.
|
|
74
|
+
|
|
75
|
+
Pass `labels` for edition translations, `loading`, or a localized `error` and `onRetry` for data states. The selected direction is local to each card; key the card by airport ID to reset it on selection changes. The Airports lab specimen exercises synthetic timetables, playback, scrubbing, study-window changes, incomplete observations, French labels, long destinations, updates and recovery. Edition adoption happens through their independently pinned package releases; adding this export does not update deployed studies.
|
|
76
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
160
|
+
|
|
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.
|
|
162
|
+
|
|
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.
|
|
164
|
+
|
|
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).
|
|
182
|
+
|
|
183
|
+
## Shared edition controllers and performance
|
|
184
|
+
|
|
185
|
+
`positionForTrain` now indexes chronological stop times with binary search and retains sequential behavior for unordered observations. Stop arrays are immutable: replace the array when a timetable changes. Arrival/departure boundaries, dwell, cancellation and backward seeking retain the existing contract.
|
|
186
|
+
|
|
187
|
+
`countableVehicleTrains(network, stations, selection)` and `createActiveTimetableVehicleCounter(trains, options)` from `@motionstudies/core/domain/vehicle-counts` separate station/route/category membership from clock updates. Build the selector and counter with `useMemo` when data or selection changes, then call the counter at the displayed time. It includes both interval endpoints and excludes cancellations and inverted intervals. Missing stations yield no matches. By default it counts timetable intervals even if a journey lacks enough stops to position; `{ requirePositionable: true }` excludes journeys with fewer than two stops. This distinction is explicit so an edition can retain its established metric.
|
|
188
|
+
|
|
189
|
+
The renderer now shares active GPU upload ranges, paused frame reuse, label and trail frame budgets, cached text comparators and batched hub lines. Custom layers can import the low-level helpers from `@motionstudies/three/render-performance`. Recreate frame trackers when their geometry/data/selection inputs change; `batchHubLines` takes ownership of two-vertex source line resources. Edition-specific worker transfer, picking and cartographic adapters remain consumer-owned.
|
|
190
|
+
|
|
191
|
+
`useJsonAsset<T>(url, enabled, parse?, optional?)` from `@motionstudies/web/use-json-asset` loads a single asset lazily and exposes `data`, `loading`, `error`, `unavailable`, and `retry`. Keep the parser stable and perform edition-specific schema/source compatibility checks there. Successful data remains cached while disabled; consumers decide whether to display it. Changing the URL or parser immediately discards prior-source state, and disabling/unmounting cancels requests. `retry()` discards cached state and requests again when enabled. Optional HTTP 404 responses are unavailable; other failures are errors. No source fallback or freshness policy is inferred.
|
|
192
|
+
|
|
193
|
+
`useTransitionValue(target, { durationMs, easing, steps })` from `@motionstudies/web/use-transition-value` animates a numeric value, returning `value` and `transitioning`. It reverses from the current frame, cancels on teardown, and settles immediately when reduced motion becomes active. `smoothTransition` is the default easing; `cosineTransition` and stepped progress support existing edition rhythms. Keep custom easing functions stable. Camera actions and lazy layout loading stay in the edition.
|
|
194
|
+
|
|
195
|
+
Edition chunk scripts can call `runNetworkChunkCli()` from `@motionstudies/data/network-chunk-cli`. It accepts the existing `--input`, `--manifest`, `--opening`, `--chunk-hours`, `--opening-start`, `--opening-end`, and `--focus` arguments. Source acquisition, provenance, output paths and command invocation remain edition-owned.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type MovementBoardStudy, type MovementBoardHorizon } from '@motionstudies/core/domain/movement-board';
|
|
3
|
+
import { type SplitFlapRow } from './SplitFlapBoard.tsx';
|
|
4
|
+
export interface AirportBoardEntry {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** Movement time in the same seconds as the study clock. Untimed rows are omitted. */
|
|
7
|
+
readonly time?: number;
|
|
8
|
+
readonly service: string;
|
|
9
|
+
readonly place?: string;
|
|
10
|
+
readonly stand?: string;
|
|
11
|
+
readonly status?: string;
|
|
12
|
+
readonly tone?: SplitFlapRow['tone'];
|
|
13
|
+
}
|
|
14
|
+
declare const defaultLabels: {
|
|
15
|
+
airport: string;
|
|
16
|
+
departures: string;
|
|
17
|
+
arrivals: string;
|
|
18
|
+
time: string;
|
|
19
|
+
service: string;
|
|
20
|
+
destination: string;
|
|
21
|
+
origin: string;
|
|
22
|
+
stand: string;
|
|
23
|
+
status: string;
|
|
24
|
+
emptyDepartures: string;
|
|
25
|
+
emptyArrivals: string;
|
|
26
|
+
loading: string;
|
|
27
|
+
retry: string;
|
|
28
|
+
studyTime: string;
|
|
29
|
+
boardWindow: string;
|
|
30
|
+
outsideWindow: string;
|
|
31
|
+
};
|
|
32
|
+
export interface AirportHeroCardProps {
|
|
33
|
+
readonly airport: {
|
|
34
|
+
readonly iata: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly city: string;
|
|
37
|
+
};
|
|
38
|
+
readonly departures: readonly AirportBoardEntry[];
|
|
39
|
+
readonly arrivals: readonly AirportBoardEntry[];
|
|
40
|
+
/** Explain the source, study date and whether directions/times are inferred. */
|
|
41
|
+
readonly note: ReactNode;
|
|
42
|
+
readonly study: MovementBoardStudy;
|
|
43
|
+
readonly horizon?: MovementBoardHorizon;
|
|
44
|
+
readonly maxRows?: number;
|
|
45
|
+
readonly dateLabel?: string;
|
|
46
|
+
readonly formatTime?: (seconds: number) => string;
|
|
47
|
+
readonly labels?: Partial<typeof defaultLabels>;
|
|
48
|
+
readonly loading?: boolean;
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
readonly onRetry?: () => void;
|
|
51
|
+
readonly onSelectFlight?: (id: string) => void;
|
|
52
|
+
readonly selectedFlightId?: string;
|
|
53
|
+
readonly initialDirection?: 'departures' | 'arrivals';
|
|
54
|
+
readonly className?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
57
|
+
export declare function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows, dateLabel, formatTime, labels, loading, error, onRetry, onSelectFlight, selectedFlightId, initialDirection, className }: AirportHeroCardProps): import("react").JSX.Element;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from 'react';
|
|
3
|
+
import { formatServiceTime } from '@motionstudies/core/domain/network';
|
|
4
|
+
import { movementBoardWindow, movementsForBoard } from '@motionstudies/core/domain/movement-board';
|
|
5
|
+
import { SplitFlapBoard } from './SplitFlapBoard.js';
|
|
6
|
+
const defaultLabels = {
|
|
7
|
+
airport: 'Airport', departures: 'Departures', arrivals: 'Arrivals',
|
|
8
|
+
time: 'Time', service: 'Flight', destination: 'To', origin: 'From', stand: 'Gate', status: 'Remarks',
|
|
9
|
+
emptyDepartures: 'No departures in this window.', emptyArrivals: 'No arrivals in this window.',
|
|
10
|
+
loading: 'Loading airport movements…', retry: 'Retry',
|
|
11
|
+
studyTime: 'Study time', boardWindow: 'Board window', outsideWindow: 'Outside study window',
|
|
12
|
+
};
|
|
13
|
+
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
14
|
+
export function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows = 8, dateLabel, formatTime = formatServiceTime, labels, loading = false, error, onRetry, onSelectFlight, selectedFlightId, initialDirection = 'departures', className = '' }) {
|
|
15
|
+
const [direction, setDirection] = useState(initialDirection);
|
|
16
|
+
const titleId = useId();
|
|
17
|
+
const boardId = useId();
|
|
18
|
+
const copy = { ...defaultLabels, ...labels };
|
|
19
|
+
const window = movementBoardWindow(study, horizon);
|
|
20
|
+
const entries = movementsForBoard(direction === 'departures' ? departures : arrivals, window, maxRows);
|
|
21
|
+
const rows = entries.map((entry) => ({ id: entry.id, tone: entry.tone,
|
|
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] }), 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
|
+
: _jsx(SplitFlapBoard, { label: `${airport.iata} ${copy[direction]}`, columns: [
|
|
25
|
+
{ key: 'time', label: copy.time, characters: 5 },
|
|
26
|
+
{ key: 'service', label: copy.service, characters: 7 },
|
|
27
|
+
{ key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters: 18 },
|
|
28
|
+
{ key: 'stand', label: copy.stand, characters: 3 },
|
|
29
|
+
{ key: 'status', label: copy.status, characters: 11 },
|
|
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
|
+
}
|
|
@@ -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,5 @@
|
|
|
1
|
+
/** One delegated tooltip surface per application. Copy belongs to each edition.
|
|
2
|
+
* data-tooltip supplies help; aria-label is the fallback for icon controls.
|
|
3
|
+
* An empty data-tooltip opts a self-explanatory button out.
|
|
4
|
+
*/
|
|
5
|
+
export declare function ButtonTooltips(): null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
let nextTooltipId = 0;
|
|
3
|
+
/** One delegated tooltip surface per application. Copy belongs to each edition.
|
|
4
|
+
* data-tooltip supplies help; aria-label is the fallback for icon controls.
|
|
5
|
+
* An empty data-tooltip opts a self-explanatory button out.
|
|
6
|
+
*/
|
|
7
|
+
export function ButtonTooltips() {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const capability = window.matchMedia('(hover: hover) and (pointer: fine)');
|
|
10
|
+
const tooltip = document.createElement('div');
|
|
11
|
+
tooltip.id = `motion-study-tooltip-${++nextTooltipId}`;
|
|
12
|
+
tooltip.setAttribute('role', 'tooltip');
|
|
13
|
+
Object.assign(tooltip.style, {
|
|
14
|
+
position: 'fixed', zIndex: '2147483647', boxSizing: 'border-box',
|
|
15
|
+
maxWidth: 'min(300px, calc(100vw - 16px))', padding: '9px 12px',
|
|
16
|
+
border: '1px solid #73818d', borderRadius: '6px', background: '#101820',
|
|
17
|
+
color: '#f5f7fa', font: '400 13px/1.45 system-ui, sans-serif',
|
|
18
|
+
letterSpacing: 'normal', textTransform: 'none', textAlign: 'start',
|
|
19
|
+
whiteSpace: 'normal', overflowWrap: 'anywhere',
|
|
20
|
+
boxShadow: '0 4px 18px #0006', pointerEvents: 'auto',
|
|
21
|
+
});
|
|
22
|
+
let anchor = null;
|
|
23
|
+
let showTimer = 0;
|
|
24
|
+
let hideTimer = 0;
|
|
25
|
+
let touch = false;
|
|
26
|
+
const copy = (button) => (button.getAttribute('data-tooltip') ?? button.getAttribute('aria-label') ?? '').trim();
|
|
27
|
+
const buttonAt = (target) => target instanceof Element ? target.closest('button') : null;
|
|
28
|
+
const hide = () => {
|
|
29
|
+
window.clearTimeout(showTimer);
|
|
30
|
+
window.clearTimeout(hideTimer);
|
|
31
|
+
if (anchor) {
|
|
32
|
+
const descriptions = (anchor.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== tooltip.id);
|
|
33
|
+
if (descriptions.length)
|
|
34
|
+
anchor.setAttribute('aria-describedby', descriptions.join(' '));
|
|
35
|
+
else
|
|
36
|
+
anchor.removeAttribute('aria-describedby');
|
|
37
|
+
}
|
|
38
|
+
anchor = null;
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
tooltip.remove();
|
|
41
|
+
};
|
|
42
|
+
const refresh = () => {
|
|
43
|
+
if (!anchor)
|
|
44
|
+
return;
|
|
45
|
+
if (!anchor.isConnected || !copy(anchor) || !anchor.getClientRects().length) {
|
|
46
|
+
hide();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const text = copy(anchor);
|
|
50
|
+
if (tooltip.textContent !== text)
|
|
51
|
+
tooltip.textContent = text;
|
|
52
|
+
const lang = anchor.closest('[lang]')?.getAttribute('lang') ?? document.documentElement.lang;
|
|
53
|
+
if (tooltip.lang !== lang)
|
|
54
|
+
tooltip.lang = lang;
|
|
55
|
+
const rect = anchor.getBoundingClientRect();
|
|
56
|
+
const width = tooltip.offsetWidth;
|
|
57
|
+
const height = tooltip.offsetHeight;
|
|
58
|
+
const left = Math.max(8, Math.min(rect.left + (rect.width - width) / 2, window.innerWidth - width - 8));
|
|
59
|
+
const below = rect.bottom + 8;
|
|
60
|
+
const top = below + height <= window.innerHeight - 8 ? below : Math.max(8, rect.top - height - 8);
|
|
61
|
+
tooltip.style.left = `${left}px`;
|
|
62
|
+
tooltip.style.top = `${top}px`;
|
|
63
|
+
};
|
|
64
|
+
const show = (button, delay) => {
|
|
65
|
+
if (!capability.matches || touch || !copy(button))
|
|
66
|
+
return;
|
|
67
|
+
if (anchor === button) {
|
|
68
|
+
window.clearTimeout(hideTimer);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
hide();
|
|
72
|
+
showTimer = window.setTimeout(() => {
|
|
73
|
+
if (!button.isConnected || !capability.matches || touch)
|
|
74
|
+
return;
|
|
75
|
+
anchor = button;
|
|
76
|
+
document.body.append(tooltip);
|
|
77
|
+
const descriptions = (button.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
|
|
78
|
+
button.setAttribute('aria-describedby', [...descriptions, tooltip.id].join(' '));
|
|
79
|
+
refresh();
|
|
80
|
+
observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-tooltip', 'aria-label', 'hidden', 'lang'] });
|
|
81
|
+
}, delay);
|
|
82
|
+
};
|
|
83
|
+
const over = (event) => {
|
|
84
|
+
if (event.pointerType !== 'mouse')
|
|
85
|
+
return;
|
|
86
|
+
touch = false;
|
|
87
|
+
if (tooltip.contains(event.target)) {
|
|
88
|
+
window.clearTimeout(hideTimer);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const button = buttonAt(event.target);
|
|
92
|
+
if (button && !button.contains(event.relatedTarget))
|
|
93
|
+
show(button, 400);
|
|
94
|
+
};
|
|
95
|
+
const out = (event) => {
|
|
96
|
+
const button = buttonAt(event.target);
|
|
97
|
+
if (button?.contains(event.relatedTarget) || tooltip.contains(event.relatedTarget))
|
|
98
|
+
return;
|
|
99
|
+
window.clearTimeout(showTimer);
|
|
100
|
+
if (anchor || tooltip.contains(event.target)) {
|
|
101
|
+
window.clearTimeout(hideTimer);
|
|
102
|
+
hideTimer = window.setTimeout(hide, 160);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const focus = (event) => {
|
|
106
|
+
const button = buttonAt(event.target);
|
|
107
|
+
if (button)
|
|
108
|
+
show(button, 0);
|
|
109
|
+
};
|
|
110
|
+
const down = (event) => { touch = event.pointerType !== 'mouse'; hide(); };
|
|
111
|
+
const key = (event) => {
|
|
112
|
+
if (event.key === 'Escape')
|
|
113
|
+
hide();
|
|
114
|
+
else if (event.key === 'Tab')
|
|
115
|
+
touch = false;
|
|
116
|
+
};
|
|
117
|
+
const observer = new MutationObserver(refresh);
|
|
118
|
+
document.addEventListener('pointerover', over, true);
|
|
119
|
+
document.addEventListener('pointerout', out, true);
|
|
120
|
+
document.addEventListener('pointerdown', down, true);
|
|
121
|
+
document.addEventListener('focusin', focus);
|
|
122
|
+
document.addEventListener('focusout', hide);
|
|
123
|
+
document.addEventListener('keydown', key, true);
|
|
124
|
+
document.addEventListener('click', hide, true);
|
|
125
|
+
document.addEventListener('scroll', hide, true);
|
|
126
|
+
window.addEventListener('resize', hide);
|
|
127
|
+
window.addEventListener('blur', hide);
|
|
128
|
+
capability.addEventListener('change', hide);
|
|
129
|
+
return () => {
|
|
130
|
+
hide();
|
|
131
|
+
observer.disconnect();
|
|
132
|
+
document.removeEventListener('pointerover', over, true);
|
|
133
|
+
document.removeEventListener('pointerout', out, true);
|
|
134
|
+
document.removeEventListener('pointerdown', down, true);
|
|
135
|
+
document.removeEventListener('focusin', focus);
|
|
136
|
+
document.removeEventListener('focusout', hide);
|
|
137
|
+
document.removeEventListener('keydown', key, true);
|
|
138
|
+
document.removeEventListener('click', hide, true);
|
|
139
|
+
document.removeEventListener('scroll', hide, true);
|
|
140
|
+
window.removeEventListener('resize', hide);
|
|
141
|
+
window.removeEventListener('blur', hide);
|
|
142
|
+
capability.removeEventListener('change', hide);
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
@@ -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;
|