@motionstudies/core 0.1.0-alpha.12 → 0.1.0-alpha.14

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
@@ -25,6 +25,14 @@ Build release candidates with `npm run build:packages`. Distribution manifests a
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
27
 
28
+ ## Timetable patterns, station calls and observation windows
29
+
30
+ `@motionstudies/core/domain/network-patterns` provides a transport-neutral repeated-schedule codec. `@motionstudies/web/use-pattern-network-day` lazily decodes current/adjacent chunks after verifying byte length and SHA-256; it exposes retry and drops old-source state when the manifest URL changes. Supply stable options and a stable asset resolver. Format/category options support existing edition wire contracts.
31
+
32
+ `@motionstudies/core/domain/station-calls` supplies station calls, independent source status, namespaced source combination and upcoming-call filtering. Source IDs, repeated visits and acquired windows survive combination. Consumers supply interchange IDs and passenger restrictions; matching display names do not collapse services.
33
+
34
+ `@motionstudies/data/observation-windows` compiles bounded acquired records into hashed windows with explicit time semantics, conflict policy and acquisition-gap reporting. It preserves supplied record IDs, values and source references. It uses elapsed UTC seconds, including 23/25-hour study days, and performs no acquisition or inference. See [contracts, limits and parity evidence](../docs/ALLCHANGE-FOUNDATIONS.md).
35
+
28
36
  ## Now and browser location
29
37
 
30
38
  `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.
@@ -217,3 +225,7 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
217
225
  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.
218
226
 
219
227
  For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motionstudies/web/components/NetworkVehicleHeroCard` accepts `snapshot`, `train`, playback `time`, a required source `note` and the vehicle card's presentation/state/label options. Import `vehicle-hero-card.css`. It resolves the selected train ID against the current snapshot, retaining destination identity but showing no stale calls when that ID is absent. Calls stay in journey order, use stable journey/call IDs and follow backward seeking. During dwell the heading becomes `atStopLabel` (default “At stop”) and the time is the departure; otherwise it is the upcoming arrival. Times are formatted with `formatServiceTime` by default, without normalizing after midnight or adding delay twice. Use `showTimes={false}` for frequency-modelled journeys, and explain the model in `note`. The core `vehicleCalls` helper is also exported from `@motionstudies/core/domain/vehicle-calls` for custom adapters. Selection callback IDs refer to calls, not station indexes.
228
+
229
+ ### Railway preparation
230
+
231
+ `@motionstudies/data/rail-wtt`, `rail-journeys`, `rail-public-calls`, `rail-routing` and `rail-geometry` expose typed offline railway preparation. XLSX reading, dated source-column audits, UID/date joins, public-call anchors and connected OSM paths preserve source evidence. Editions supply regional identities, calendar exceptions and clipping policy. Node 24+ and `unzip` are required; no Python runtime is shipped. See `docs/SHARED-RAIL-READERS.md` in the source repository for calendar limits and parity evidence.
@@ -0,0 +1,19 @@
1
+ import type { NetworkDayChunk, NetworkTrain, ServiceCategory } from './network.ts';
2
+ export declare const NETWORK_PATTERN_FORMAT = "network-patterns-v1";
3
+ export type NetworkPattern = Omit<NetworkTrain, 'id' | 'start'>;
4
+ export interface PatternNetworkChunk {
5
+ readonly format: string;
6
+ readonly windowStart: number;
7
+ readonly windowEnd: number;
8
+ readonly patterns: readonly NetworkPattern[];
9
+ readonly journeys: readonly (readonly [id: string, start: number, pattern: number])[];
10
+ }
11
+ export interface PatternChunkOptions {
12
+ /** A consumer may retain a previously published wire-format tag. */
13
+ readonly format?: string;
14
+ readonly categories?: readonly ServiceCategory[];
15
+ }
16
+ /** Deduplicate relative schedules on disk; identities and absolute calls survive decoding. */
17
+ export declare function encodeNetworkPatterns(chunk: NetworkDayChunk, options?: PatternChunkOptions): PatternNetworkChunk;
18
+ export declare function validateNetworkPatterns(value: unknown, options?: PatternChunkOptions): asserts value is PatternNetworkChunk;
19
+ export declare function decodeNetworkPatterns(value: unknown, options?: PatternChunkOptions): NetworkDayChunk;
@@ -0,0 +1,73 @@
1
+ export const NETWORK_PATTERN_FORMAT = 'network-patterns-v1';
2
+ /** Deduplicate relative schedules on disk; identities and absolute calls survive decoding. */
3
+ export function encodeNetworkPatterns(chunk, options = {}) {
4
+ const patterns = [], indexes = new Map();
5
+ const journeys = chunk.trains.map(({ id, start, ...train }) => {
6
+ const pattern = { ...train, end: train.end - start,
7
+ stops: train.stops.map(([stop, arrival, departure]) => [stop, arrival - start, departure - start]),
8
+ };
9
+ const key = JSON.stringify(pattern);
10
+ let index = indexes.get(key);
11
+ if (index === undefined) {
12
+ index = patterns.length;
13
+ indexes.set(key, index);
14
+ patterns.push(pattern);
15
+ }
16
+ return [id, start, index];
17
+ });
18
+ const result = { format: options.format ?? NETWORK_PATTERN_FORMAT, windowStart: chunk.windowStart,
19
+ windowEnd: chunk.windowEnd, patterns, journeys };
20
+ validateNetworkPatterns(result, options);
21
+ return result;
22
+ }
23
+ export function validateNetworkPatterns(value, options = {}) {
24
+ const chunk = value;
25
+ if (!chunk || chunk.format !== (options.format ?? NETWORK_PATTERN_FORMAT) ||
26
+ !Number.isFinite(chunk.windowStart) || !Number.isFinite(chunk.windowEnd) || chunk.windowEnd <= chunk.windowStart ||
27
+ !Array.isArray(chunk.patterns) || !Array.isArray(chunk.journeys))
28
+ throw new Error('Invalid network pattern chunk');
29
+ for (const pattern of chunk.patterns) {
30
+ if (!pattern || !Number.isFinite(pattern.end) || pattern.end < 0 ||
31
+ typeof pattern.route !== 'string' || typeof pattern.headsign !== 'string' || typeof pattern.shortName !== 'string' ||
32
+ typeof pattern.category !== 'string' || (options.categories && !options.categories.includes(pattern.category)) ||
33
+ !Array.isArray(pattern.stops) || pattern.stops.length < 2)
34
+ throw new Error('Invalid network pattern');
35
+ let previous = -Infinity;
36
+ for (const stop of pattern.stops) {
37
+ if (!Array.isArray(stop) || stop.length !== 3)
38
+ throw new Error('Invalid pattern stop');
39
+ const [index, arrival, departure] = stop;
40
+ if (!Number.isSafeInteger(index) || index < 0 || !Number.isFinite(arrival) || !Number.isFinite(departure) ||
41
+ departure < arrival || arrival < previous)
42
+ throw new Error('Invalid pattern stop timing');
43
+ previous = departure;
44
+ }
45
+ if (pattern.end < previous)
46
+ throw new Error('Pattern ends before its final call');
47
+ if (pattern.pathSegments && (!Array.isArray(pattern.pathSegments) || pattern.pathSegments.length !== pattern.stops.length - 1 ||
48
+ pattern.pathSegments.some((index) => index !== null && (!Number.isSafeInteger(index) || index < 0))))
49
+ throw new Error('Invalid pattern paths');
50
+ }
51
+ const ids = new Set();
52
+ for (const journey of chunk.journeys) {
53
+ if (!Array.isArray(journey) || journey.length !== 3)
54
+ throw new Error('Invalid pattern journey');
55
+ const [id, start, index] = journey;
56
+ if (typeof id !== 'string' || !id || ids.has(id) || !Number.isFinite(start) || !Number.isSafeInteger(index) || index < 0 ||
57
+ !chunk.patterns[index] || !Number.isFinite(start + chunk.patterns[index].end) ||
58
+ chunk.patterns[index].stops.some((stop) => !Number.isFinite(start + stop[1]) || !Number.isFinite(start + stop[2])))
59
+ throw new Error('Invalid pattern journey identity or time');
60
+ ids.add(id);
61
+ }
62
+ }
63
+ export function decodeNetworkPatterns(value, options = {}) {
64
+ validateNetworkPatterns(value, options);
65
+ return { windowStart: value.windowStart, windowEnd: value.windowEnd,
66
+ trains: value.journeys.map(([id, start, index]) => {
67
+ const pattern = value.patterns[index];
68
+ return { ...pattern, id, start, end: start + pattern.end,
69
+ stops: pattern.stops.map(([stop, arrival, departure]) => [stop, start + arrival, start + departure]),
70
+ };
71
+ }),
72
+ };
73
+ }
@@ -0,0 +1,48 @@
1
+ import type { NetworkSnapshot, NetworkTrain } from './network.ts';
2
+ export type StationCallDirection = 'arrival' | 'departure';
3
+ export interface StationCall {
4
+ readonly id: string;
5
+ readonly train: NetworkTrain;
6
+ /** Call occurrence within this train, not a global station index. */
7
+ readonly index: number;
8
+ readonly arrival: number;
9
+ readonly departure: number;
10
+ readonly origin: string;
11
+ readonly destination: string;
12
+ readonly allowsArrival: boolean;
13
+ readonly allowsDeparture: boolean;
14
+ readonly movementAvailable?: boolean;
15
+ readonly source?: string;
16
+ readonly windowStart?: number;
17
+ readonly windowEnd?: number;
18
+ }
19
+ export interface StationCallSelection {
20
+ readonly name: string;
21
+ /** When supplied, source IDs are authoritative; never fall back to a name match. */
22
+ readonly stopIds?: readonly string[];
23
+ }
24
+ export interface StationCallPolicy {
25
+ readonly includeTrain?: (train: NetworkTrain) => boolean;
26
+ readonly allows?: (train: NetworkTrain, callIndex: number, direction: StationCallDirection) => boolean;
27
+ readonly origin?: (train: NetworkTrain, snapshot: NetworkSnapshot) => string | undefined;
28
+ readonly destination?: (train: NetworkTrain, snapshot: NetworkSnapshot) => string | undefined;
29
+ }
30
+ /** Retain repeated visits and source-local indexes; passenger restrictions belong to the adapter. */
31
+ export declare function stationCalls(snapshot: NetworkSnapshot, selection: StationCallSelection, policy?: StationCallPolicy): readonly StationCall[];
32
+ export interface StationCallSource {
33
+ readonly id: string;
34
+ readonly selection: StationCallSelection;
35
+ readonly snapshot?: NetworkSnapshot;
36
+ readonly movements?: NetworkSnapshot;
37
+ readonly windowStart: number;
38
+ readonly windowEnd: number;
39
+ readonly policy?: StationCallPolicy;
40
+ readonly loading?: boolean;
41
+ readonly error?: boolean;
42
+ }
43
+ export type StationSourceStatus = 'loading' | 'error' | 'unavailable' | 'date-mismatch' | 'partial' | 'ready';
44
+ export declare function stationSourceStatus(source: StationCallSource, serviceDate: string, requestedStart: number, requestedEnd: number): StationSourceStatus;
45
+ /** Sources remain independently dated and retryable. Equal names/times never merge services. */
46
+ export declare function combinedStationCalls(serviceDate: string, sources: readonly StationCallSource[]): readonly StationCall[];
47
+ /** Clip to both requested and source windows. Never wrap to an unacquired day. */
48
+ export declare function upcomingStationCalls(calls: readonly StationCall[], direction: StationCallDirection, time: number, windowStart: number, windowEnd: number, route?: string, maxRows?: number): readonly StationCall[];
@@ -0,0 +1,73 @@
1
+ /** Retain repeated visits and source-local indexes; passenger restrictions belong to the adapter. */
2
+ export function stationCalls(snapshot, selection, policy = {}) {
3
+ const stops = new Set(snapshot.stops.flatMap((stop, index) => (selection.stopIds ? Boolean(stop[4] && selection.stopIds.includes(stop[4])) : stop[2] === selection.name) ? [index] : []));
4
+ const calls = [], ids = new Map();
5
+ for (const train of snapshot.trains) {
6
+ if (policy.includeTrain && !policy.includeTrain(train))
7
+ continue;
8
+ const prior = ids.get(train.id);
9
+ if (prior) {
10
+ if (prior === train || JSON.stringify(prior) === JSON.stringify(train))
11
+ continue;
12
+ throw new Error('Ambiguous duplicate journey identity in station source');
13
+ }
14
+ ids.set(train.id, train);
15
+ train.stops.forEach(([stop, arrival, departure], index) => {
16
+ if (!stops.has(stop) || !Number.isFinite(arrival) || !Number.isFinite(departure) || departure < arrival)
17
+ return;
18
+ const allowsArrival = index > 0 && (policy.allows?.(train, index, 'arrival') ?? true);
19
+ const allowsDeparture = index < train.stops.length - 1 && (policy.allows?.(train, index, 'departure') ?? true);
20
+ if (!allowsArrival && !allowsDeparture)
21
+ return;
22
+ calls.push({ id: `${train.id}:call:${index}`, train, index, arrival, departure,
23
+ origin: policy.origin?.(train, snapshot) ?? snapshot.stops[train.stops[0][0]]?.[2] ?? '',
24
+ destination: policy.destination?.(train, snapshot) ?? (train.headsign || snapshot.stops[train.stops.at(-1)[0]]?.[2] || ''),
25
+ allowsArrival, allowsDeparture, });
26
+ });
27
+ }
28
+ return calls;
29
+ }
30
+ export function stationSourceStatus(source, serviceDate, requestedStart, requestedEnd) {
31
+ if (source.error)
32
+ return 'error';
33
+ if (source.loading)
34
+ return 'loading';
35
+ if (!source.snapshot || ![source.windowStart, source.windowEnd, requestedStart, requestedEnd].every(Number.isFinite) ||
36
+ source.windowEnd <= source.windowStart || requestedEnd < requestedStart)
37
+ return 'unavailable';
38
+ if (source.snapshot.metadata.serviceDate !== serviceDate)
39
+ return 'date-mismatch';
40
+ return source.windowStart > requestedStart || source.windowEnd < requestedEnd ? 'partial' : 'ready';
41
+ }
42
+ /** Sources remain independently dated and retryable. Equal names/times never merge services. */
43
+ export function combinedStationCalls(serviceDate, sources) {
44
+ const result = [], ids = new Set();
45
+ for (const source of sources) {
46
+ if (!source.id || ids.has(source.id))
47
+ throw new Error('Station sources require distinct identities');
48
+ ids.add(source.id);
49
+ if (!source.snapshot || source.error || source.loading || source.snapshot.metadata.serviceDate !== serviceDate ||
50
+ !Number.isFinite(source.windowStart) || !Number.isFinite(source.windowEnd) || source.windowEnd <= source.windowStart)
51
+ continue;
52
+ const movementIds = source.movements?.metadata.serviceDate === serviceDate
53
+ ? new Set(source.movements.trains.map(train => train.id)) : undefined;
54
+ for (const call of stationCalls(source.snapshot, source.selection, source.policy)) {
55
+ result.push({ ...call, id: `${encodeURIComponent(source.id)}:${call.id}`, source: source.id,
56
+ movementAvailable: source.movements ? (movementIds?.has(call.train.id) ?? false) : undefined,
57
+ windowStart: source.windowStart, windowEnd: source.windowEnd,
58
+ });
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ /** Clip to both requested and source windows. Never wrap to an unacquired day. */
64
+ export function upcomingStationCalls(calls, direction, time, windowStart, windowEnd, route = '', maxRows = 4) {
65
+ if (![time, windowStart, windowEnd].every(Number.isFinite) || time < windowStart || time >= windowEnd || !Number.isSafeInteger(maxRows) || maxRows < 1)
66
+ return [];
67
+ const end = Math.min(windowEnd, time + 3600);
68
+ return calls.filter(call => (direction === 'arrival' ? call.allowsArrival : call.allowsDeparture)
69
+ && call[direction] >= Math.max(windowStart, call.windowStart ?? windowStart)
70
+ && call[direction] < Math.min(windowEnd, call.windowEnd ?? windowEnd)
71
+ && (!route || call.train.route === route) && call[direction] >= time && call[direction] < end)
72
+ .sort((a, b) => a[direction] - b[direction] || a.id.localeCompare(b.id)).slice(0, maxRows);
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/core",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -105,6 +105,16 @@
105
105
  "import": "./domain/network-day.js",
106
106
  "default": "./domain/network-day.js"
107
107
  },
108
+ "./domain/network-patterns": {
109
+ "types": "./domain/network-patterns.d.ts",
110
+ "import": "./domain/network-patterns.js",
111
+ "default": "./domain/network-patterns.js"
112
+ },
113
+ "./domain/station-calls": {
114
+ "types": "./domain/station-calls.d.ts",
115
+ "import": "./domain/station-calls.js",
116
+ "default": "./domain/station-calls.js"
117
+ },
108
118
  "./domain/network-layers": {
109
119
  "types": "./domain/network-layers.d.ts",
110
120
  "import": "./domain/network-layers.js",