@motionstudies/core 0.1.0-alpha.7 → 0.1.0-alpha.9

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
@@ -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.
@@ -99,3 +179,17 @@ See [service architecture and operations](../docs/LIVE-AIRPORTS.md). The Worker
99
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.
100
180
 
101
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.
package/domain/network.js CHANGED
@@ -69,7 +69,7 @@ export function buildStationIndex(snapshot) {
69
69
  routes: [...record.routes.values()].sort((first, second) => first.name.localeCompare(second.name, 'de-CH')),
70
70
  }));
71
71
  }
72
- export function positionForTrain(train, time) {
72
+ function linearPositionForTrain(train, time) {
73
73
  if (train.realtime?.status === 'cancelled' ||
74
74
  time < train.start ||
75
75
  time > train.end ||
@@ -98,6 +98,42 @@ export function positionForTrain(train, time) {
98
98
  const last = train.stops.at(-1);
99
99
  return { fromStop: last[0], toStop: last[0], progress: 0 };
100
100
  }
101
+ // Timetables are immutable. A replacement stop array (including realtime edits)
102
+ // gets its own validation; old schedules can be garbage collected.
103
+ const chronologicalSchedules = new WeakMap();
104
+ export function positionForTrain(train, time) {
105
+ if (train.realtime?.status === 'cancelled' || time < train.start || time > train.end || train.stops.length < 2)
106
+ return;
107
+ const stops = train.stops;
108
+ let chronological = chronologicalSchedules.get(stops);
109
+ if (chronological === undefined) {
110
+ chronological = stops.every((stop, index) => Number.isFinite(stop[1]) && Number.isFinite(stop[2]) &&
111
+ stop[2] >= stop[1] && (index === 0 || stop[1] >= stops[index - 1][2]));
112
+ chronologicalSchedules.set(stops, chronological);
113
+ }
114
+ // Preserve sequential semantics for unordered observations and non-finite clocks.
115
+ if (!chronological || !Number.isFinite(time))
116
+ return linearPositionForTrain(train, time);
117
+ let lower = 0;
118
+ let upper = stops.length;
119
+ while (lower < upper) {
120
+ const middle = (lower + upper) >>> 1;
121
+ if (stops[middle][2] < time)
122
+ lower = middle + 1;
123
+ else
124
+ upper = middle;
125
+ }
126
+ const next = stops[Math.min(lower, stops.length - 1)];
127
+ if (lower === 0 || lower === stops.length || time > next[1]) {
128
+ return { fromStop: next[0], toStop: next[0], progress: 0 };
129
+ }
130
+ const previous = stops[lower - 1];
131
+ return {
132
+ fromStop: previous[0], toStop: next[0],
133
+ progress: Math.min(1, Math.max(0, (time - previous[2]) / Math.max(1, next[1] - previous[2]))),
134
+ segmentIndex: lower - 1,
135
+ };
136
+ }
101
137
  export function formatServiceTime(totalSeconds) {
102
138
  const normalized = ((Math.round(totalSeconds) % 86400) + 86400) % 86400;
103
139
  const hours = Math.floor(normalized / 3600);
@@ -0,0 +1,15 @@
1
+ import type { NetworkSnapshot, ServiceCategory } from './network.ts';
2
+ export interface StationDeparture {
3
+ readonly id: string;
4
+ readonly trainId: string;
5
+ /** Time from the supplied snapshot: realtime-adjusted snapshots contain adjusted times. */
6
+ readonly time: number;
7
+ readonly service: string;
8
+ readonly category: ServiceCategory;
9
+ readonly destination: string;
10
+ readonly platform?: string;
11
+ readonly via: readonly string[];
12
+ readonly status: 'scheduled' | 'adjusted' | 'cancelled';
13
+ }
14
+ /** Resolve the station against this snapshot, retaining repeat calls but excluding terminators. */
15
+ export declare function stationDepartures(snapshot: NetworkSnapshot, stationName: string): readonly StationDeparture[];
@@ -0,0 +1,25 @@
1
+ /** Resolve the station against this snapshot, retaining repeat calls but excluding terminators. */
2
+ export function stationDepartures(snapshot, stationName) {
3
+ const stopIndexes = new Set();
4
+ snapshot.stops.forEach((stop, index) => { if (stop[2] === stationName)
5
+ stopIndexes.add(index); });
6
+ if (!stopIndexes.size)
7
+ return [];
8
+ const departures = [];
9
+ for (const train of snapshot.trains) {
10
+ train.stops.forEach(([stopIndex, , departure], callIndex) => {
11
+ if (!stopIndexes.has(stopIndex) || !Number.isFinite(departure) || callIndex === train.stops.length - 1)
12
+ return;
13
+ const onward = train.stops.slice(callIndex + 1).map(([index]) => snapshot.stops[index]?.[2]).filter((name) => Boolean(name));
14
+ if (!onward.length)
15
+ return;
16
+ const destination = train.headsign.trim() || onward.at(-1);
17
+ departures.push({ id: `${train.id}:${callIndex}`, trainId: train.id, time: departure,
18
+ service: train.route || train.shortName, category: train.category, destination,
19
+ platform: snapshot.stops[stopIndex]?.[3]?.trim() || undefined,
20
+ via: [...new Set(onward.slice(0, -1).filter((name) => name !== stationName && name !== destination))].slice(0, 3),
21
+ status: train.realtime?.status ?? 'scheduled' });
22
+ });
23
+ }
24
+ return departures.sort((a, b) => a.time - b.time || a.id.localeCompare(b.id));
25
+ }
@@ -0,0 +1,13 @@
1
+ import type { NetworkSnapshot, NetworkTrain, ServiceCategory, StationIndexEntry } from './network.ts';
2
+ export declare function countableVehicleTrains(network: NetworkSnapshot | undefined, stations: readonly StationIndexEntry[], selection: {
3
+ category?: ServiceCategory;
4
+ station?: Pick<StationIndexEntry, 'name'>;
5
+ route?: {
6
+ name: string;
7
+ category: ServiceCategory;
8
+ };
9
+ }): readonly NetworkTrain[];
10
+ /** Build once per immutable selection; clock updates need only two binary searches. */
11
+ export declare function createActiveTimetableVehicleCounter(trains: readonly NetworkTrain[], options?: {
12
+ readonly requirePositionable?: boolean;
13
+ }): (time: number) => number;
@@ -0,0 +1,33 @@
1
+ export function countableVehicleTrains(network, stations, selection) {
2
+ const stationTrainIds = selection.station
3
+ ? new Set(stations.find(station => station.name === selection.station?.name)?.trainIds ?? [])
4
+ : undefined;
5
+ return network?.trains.filter(train => (!selection.category || train.category === selection.category) &&
6
+ (!stationTrainIds || stationTrainIds.has(train.id)) &&
7
+ (!selection.route || (train.route === selection.route.name && train.category === selection.route.category))) ?? [];
8
+ }
9
+ /** Build once per immutable selection; clock updates need only two binary searches. */
10
+ export function createActiveTimetableVehicleCounter(trains, options = {}) {
11
+ const starts = [], ends = [];
12
+ for (const train of trains) {
13
+ if (train.realtime?.status === 'cancelled' || (options.requirePositionable && train.stops.length < 2) || !(train.start <= train.end))
14
+ continue;
15
+ starts.push(train.start);
16
+ ends.push(train.end);
17
+ }
18
+ starts.sort((a, b) => a - b);
19
+ ends.sort((a, b) => a - b);
20
+ const before = (values, time, inclusive) => {
21
+ let low = 0, high = values.length;
22
+ while (low < high) {
23
+ const middle = (low + high) >>> 1;
24
+ if (values[middle] < time || inclusive && values[middle] === time)
25
+ low = middle + 1;
26
+ else
27
+ high = middle;
28
+ }
29
+ return low;
30
+ };
31
+ // Include both departure and arrival instants, including zero-length trips.
32
+ return time => Number.isNaN(time) ? 0 : before(starts, time, true) - before(ends, time, false);
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/core",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -149,6 +149,16 @@
149
149
  "types": "./domain/live-airport.d.ts",
150
150
  "import": "./domain/live-airport.js",
151
151
  "default": "./domain/live-airport.js"
152
+ },
153
+ "./domain/station-departures": {
154
+ "types": "./domain/station-departures.d.ts",
155
+ "import": "./domain/station-departures.js",
156
+ "default": "./domain/station-departures.js"
157
+ },
158
+ "./domain/vehicle-counts": {
159
+ "types": "./domain/vehicle-counts.d.ts",
160
+ "import": "./domain/vehicle-counts.js",
161
+ "default": "./domain/vehicle-counts.js"
152
162
  }
153
163
  },
154
164
  "files": [