@motionstudies/core 0.1.0-alpha.11 → 0.1.0-alpha.13
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 +36 -0
- package/domain/aggregate-road.d.ts +48 -0
- package/domain/aggregate-road.js +22 -0
- package/domain/network-patterns.d.ts +19 -0
- package/domain/network-patterns.js +73 -0
- package/domain/station-calls.d.ts +48 -0
- package/domain/station-calls.js +73 -0
- package/domain/vehicle-calls.d.ts +11 -0
- package/domain/vehicle-calls.js +14 -0
- package/package.json +21 -1
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.
|
|
@@ -164,6 +172,28 @@ Rail and other transport consumers can share the same time filtering through `mo
|
|
|
164
172
|
|
|
165
173
|
`@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
174
|
|
|
175
|
+
## Vehicle hero cards
|
|
176
|
+
|
|
177
|
+
`VehicleHeroCard` shows one vehicle's destination, next stop and remaining calling points. Import it from `@motionstudies/web/components/VehicleHeroCard` with `@motionstudies/web/vehicle-hero-card.css`. The **Vehicle heroes** lab compares `uk-bus` (amber onboard display), `uk-rail` (dark rail display), `yellow-bus` (yellow next-stop panel) and `sbb` (blue next-stop panel with a red service badge). These are presentation studies, not official operator components.
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<VehicleHeroCard
|
|
181
|
+
presentation="sbb"
|
|
182
|
+
vehicle={{ service: 'IC 1', operator: 'SBB CFF FFS', destination: 'Genève-Aéroport' }}
|
|
183
|
+
stops={[
|
|
184
|
+
{ id: 'bern', name: 'Bern', time: '10:28', platform: '6' },
|
|
185
|
+
{ id: 'fribourg', name: 'Fribourg/Freiburg', time: '10:56' },
|
|
186
|
+
]}
|
|
187
|
+
note="Synthetic journey · Example calls and times."
|
|
188
|
+
onSelectStop={selectStation}
|
|
189
|
+
selectedStopId={selectedStationId}
|
|
190
|
+
/>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Consumers supply remaining `stops` in journey order and update them with playback, seeking or observations; the card has no independent clock. The first supplied call is the next stop. Optional `time`, `platform` and `detail` are consumer-formatted; absent values stay absent. `vehicle.destination` is optional and shows an explicit unavailable label when missing. The final supplied stop is never inferred to be the destination: set `isDestination` only when confirmed, especially when data is progressively chunked. Empty calls mean no upcoming stops are available, without asserting that the vehicle has arrived.
|
|
194
|
+
|
|
195
|
+
The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
|
|
196
|
+
|
|
167
197
|
## Optional live airport feed
|
|
168
198
|
|
|
169
199
|
`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.
|
|
@@ -193,3 +223,9 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
|
|
|
193
223
|
`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
224
|
|
|
195
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.
|
|
226
|
+
|
|
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,48 @@
|
|
|
1
|
+
/** Counts are vehicle passages at a detector or a provider-defined link, not vehicle identities. */
|
|
2
|
+
export type AggregateRoadInterval = {
|
|
3
|
+
readonly kind: 'provider-slot';
|
|
4
|
+
readonly serviceDate: string;
|
|
5
|
+
readonly index: number;
|
|
6
|
+
readonly endingLabel: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'utc';
|
|
9
|
+
readonly from: string;
|
|
10
|
+
readonly to: string;
|
|
11
|
+
};
|
|
12
|
+
export interface AggregateRoadSample {
|
|
13
|
+
readonly interval: AggregateRoadInterval;
|
|
14
|
+
readonly vehicleCount: number | null;
|
|
15
|
+
readonly averageSpeed: {
|
|
16
|
+
readonly value: number;
|
|
17
|
+
readonly unit: 'mph' | 'km/h';
|
|
18
|
+
} | null;
|
|
19
|
+
readonly sourceRecordIds: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface AggregateRoadSeries {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly provider: string;
|
|
24
|
+
readonly dataset: string;
|
|
25
|
+
readonly siteId: string;
|
|
26
|
+
readonly modelVersion?: string;
|
|
27
|
+
readonly samples: readonly AggregateRoadSample[];
|
|
28
|
+
}
|
|
29
|
+
/** Geometry association is independent of measurements; a candidate is not a verified link. */
|
|
30
|
+
export interface AggregateRoadSection {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly seriesId: string;
|
|
33
|
+
readonly path: readonly (readonly [longitude: number, latitude: number])[];
|
|
34
|
+
readonly association: {
|
|
35
|
+
readonly kind: 'illustrative';
|
|
36
|
+
readonly method: string;
|
|
37
|
+
readonly maximumDistanceMetres: number;
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: 'provider-link';
|
|
40
|
+
readonly linkId: string;
|
|
41
|
+
readonly modelVersion: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export declare function aggregateRoadValue(sample: AggregateRoadSample | undefined, metric: 'vehicleCount' | 'averageSpeed'): number | null;
|
|
45
|
+
/** Compile once per immutable series. Conflicting or duplicate slots remain unavailable.
|
|
46
|
+
* No interpolation, nearest-slot fallback or conversion between provider and UTC clocks.
|
|
47
|
+
*/
|
|
48
|
+
export declare function indexAggregateRoadSlots(series: AggregateRoadSeries, serviceDate: string): ReadonlyMap<number, AggregateRoadSample>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function aggregateRoadValue(sample, metric) {
|
|
2
|
+
const value = metric === 'vehicleCount' ? sample?.vehicleCount : sample?.averageSpeed?.value;
|
|
3
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (metric !== 'vehicleCount' || Number.isInteger(value)) ? value : null;
|
|
4
|
+
}
|
|
5
|
+
/** Compile once per immutable series. Conflicting or duplicate slots remain unavailable.
|
|
6
|
+
* No interpolation, nearest-slot fallback or conversion between provider and UTC clocks.
|
|
7
|
+
*/
|
|
8
|
+
export function indexAggregateRoadSlots(series, serviceDate) {
|
|
9
|
+
const result = new Map(), conflicts = new Set();
|
|
10
|
+
for (const sample of series.samples) {
|
|
11
|
+
const interval = sample.interval;
|
|
12
|
+
if (interval.kind !== 'provider-slot' || interval.serviceDate !== serviceDate || !Number.isSafeInteger(interval.index) || interval.index < 0)
|
|
13
|
+
continue;
|
|
14
|
+
if (result.has(interval.index)) {
|
|
15
|
+
result.delete(interval.index);
|
|
16
|
+
conflicts.add(interval.index);
|
|
17
|
+
}
|
|
18
|
+
else if (!conflicts.has(interval.index))
|
|
19
|
+
result.set(interval.index, sample);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NetworkSnapshot, NetworkTrain } from './network.ts';
|
|
2
|
+
export interface VehicleCall {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly time: number;
|
|
6
|
+
readonly platform?: string;
|
|
7
|
+
readonly atStop: boolean;
|
|
8
|
+
readonly isDestination: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Remaining calls from this snapshot's train. Times may already include realtime adjustments. */
|
|
11
|
+
export declare function vehicleCalls(snapshot: NetworkSnapshot, train: NetworkTrain, time: number): readonly VehicleCall[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Remaining calls from this snapshot's train. Times may already include realtime adjustments. */
|
|
2
|
+
export function vehicleCalls(snapshot, train, time) {
|
|
3
|
+
if (!Number.isFinite(time) || train.realtime?.status === 'cancelled')
|
|
4
|
+
return [];
|
|
5
|
+
return train.stops.flatMap(([index, arrival, departure], callIndex) => {
|
|
6
|
+
const stop = snapshot.stops[index];
|
|
7
|
+
if (!stop || !Number.isFinite(arrival) || !Number.isFinite(departure) || departure < arrival || departure < time)
|
|
8
|
+
return [];
|
|
9
|
+
const atStop = arrival <= time && time <= departure;
|
|
10
|
+
return [{ id: `${train.id}:${callIndex}`, name: stop[2], time: atStop ? departure : arrival,
|
|
11
|
+
platform: stop[3]?.trim() || undefined, atStop,
|
|
12
|
+
isDestination: callIndex === train.stops.length - 1 && Boolean(train.headsign.trim()) && stop[2].trim() === train.headsign.trim() }];
|
|
13
|
+
});
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
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",
|
|
@@ -159,6 +169,16 @@
|
|
|
159
169
|
"types": "./domain/vehicle-counts.d.ts",
|
|
160
170
|
"import": "./domain/vehicle-counts.js",
|
|
161
171
|
"default": "./domain/vehicle-counts.js"
|
|
172
|
+
},
|
|
173
|
+
"./domain/aggregate-road": {
|
|
174
|
+
"types": "./domain/aggregate-road.d.ts",
|
|
175
|
+
"import": "./domain/aggregate-road.js",
|
|
176
|
+
"default": "./domain/aggregate-road.js"
|
|
177
|
+
},
|
|
178
|
+
"./domain/vehicle-calls": {
|
|
179
|
+
"types": "./domain/vehicle-calls.d.ts",
|
|
180
|
+
"import": "./domain/vehicle-calls.js",
|
|
181
|
+
"default": "./domain/vehicle-calls.js"
|
|
162
182
|
}
|
|
163
183
|
},
|
|
164
184
|
"files": [
|