@motionstudies/core 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
@@ -0,0 +1,132 @@
1
+ /** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
2
+ * five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
3
+ * per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
4
+ * manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
5
+ * and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
6
+ */
7
+ export declare const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
8
+ export interface PublishedDayManifest {
9
+ readonly date: string;
10
+ readonly compiledAt: string;
11
+ readonly hours: {
12
+ readonly present: readonly string[];
13
+ readonly missing: readonly string[];
14
+ };
15
+ readonly stats: {
16
+ readonly samples: number;
17
+ readonly vehicles: number;
18
+ readonly operators: number;
19
+ readonly slices: number;
20
+ readonly excludedRejected: number;
21
+ readonly excludedOutsideDay: number;
22
+ };
23
+ readonly slices: number;
24
+ readonly files: number;
25
+ readonly bytes: number;
26
+ readonly list: readonly {
27
+ readonly path: string;
28
+ readonly bytes: number;
29
+ }[];
30
+ }
31
+ /** One cell of a slice: centre-less floor coordinates, samples (which sum across cells) and distinct vehicles (which do not). */
32
+ export type SliceCell = readonly [lon: number, lat: number, samples: number, vehicles: number];
33
+ export interface SliceFile {
34
+ readonly slice: string;
35
+ readonly cells: readonly SliceCell[];
36
+ }
37
+ /** Members of each cell as operator index into the day index and vehicle reference. */
38
+ export type Member = readonly [operatorIndex: number, vehicleRef: string];
39
+ export interface MembersFile {
40
+ readonly slice: string;
41
+ readonly cells: Readonly<Record<string, readonly Member[]>>;
42
+ }
43
+ export interface PackPart {
44
+ readonly file: string;
45
+ readonly bytes: number;
46
+ readonly vehicles: number;
47
+ readonly samples: number;
48
+ readonly refs?: readonly string[];
49
+ }
50
+ export interface OperatorEntry {
51
+ readonly ref: string;
52
+ readonly vehicles: number;
53
+ readonly samples: number;
54
+ readonly parts: readonly PackPart[];
55
+ }
56
+ export interface DayIndex {
57
+ readonly date: string;
58
+ readonly operators: readonly OperatorEntry[];
59
+ }
60
+ export type TrackPoint = readonly [epochSeconds: number, lon: number, lat: number];
61
+ export type JourneyRun = readonly [epochSeconds: number, lineRef: string | null, journeyRef: string | null];
62
+ export interface VehicleDay {
63
+ readonly track: readonly TrackPoint[];
64
+ readonly journeys: readonly JourneyRun[];
65
+ }
66
+ export interface Pack {
67
+ readonly date: string;
68
+ readonly operator: string;
69
+ readonly part: number;
70
+ readonly parts: number;
71
+ readonly vehicles: Readonly<Record<string, VehicleDay>>;
72
+ }
73
+ export interface DayTransport {
74
+ /** Text of a file under the day's base, by its relative path. */
75
+ readonly text: (path: string) => Promise<string>;
76
+ /** Bytes of a file under the day's base. */
77
+ readonly bytes: (path: string) => Promise<Uint8Array>;
78
+ /** Gunzip; defaults to the platform's DecompressionStream. */
79
+ readonly inflate?: (bytes: Uint8Array) => Promise<Uint8Array>;
80
+ }
81
+ /** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
82
+ export declare function sliceNameAt(instant: string | number): string;
83
+ /** The ISO start of a named slice on a date. */
84
+ export declare function sliceStart(date: string, name: string): string;
85
+ /** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
86
+ * arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
87
+ * cell edge lands where the compiler put it. */
88
+ export declare function cellOf(lon: number, lat: number): readonly [number, number];
89
+ export declare function cellKey(cellLon: number, cellLat: number): string;
90
+ /** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
91
+ export declare function partFor(index: DayIndex, operatorRef: string, vehicleRef: string): PackPart | null;
92
+ /** Problems with a compiled day's manifest, empty when consistent: all 24 hours of the date accounted for once, one
93
+ * slice and one members file per slice, and the file list agreeing with the stated file and byte totals (the manifest
94
+ * itself is the one unlisted file). A trimmed fixture, whose statistics describe the whole day, fails by design. */
95
+ export declare function checkDayManifest(manifest: PublishedDayManifest): string[];
96
+ /** Problems reconciling a slice with its members, empty when they agree: the sea and the shoal must count the same
97
+ * vehicles. Each cell's distinct-vehicle count equals its distinct members, no member repeats, and neither file names
98
+ * a cell the other lacks. Samples are not checked here; members carry identities, not sample counts. */
99
+ export declare function reconcileSlice(slice: SliceFile, members: MembersFile): string[];
100
+ /** Position along a track at an instant: linear between the samples around it, null outside the track or across a
101
+ * gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
102
+ export declare function positionAt(track: readonly TrackPoint[], epochSeconds: number, maxGapSeconds?: number): readonly [lon: number, lat: number] | null;
103
+ /** The line and journey a vehicle was on at an instant, from its runs. */
104
+ export declare function journeyAt(journeys: readonly JourneyRun[], epochSeconds: number): JourneyRun | null;
105
+ /** Minimal shapes of the platform pieces used, so the module compiles without DOM typings. */
106
+ interface ByteSource {
107
+ arrayBuffer(): Promise<ArrayBuffer>;
108
+ }
109
+ interface ResponseLike extends ByteSource {
110
+ readonly ok: boolean;
111
+ readonly status: number;
112
+ text(): Promise<string>;
113
+ }
114
+ export type FetchLike = (url: string) => Promise<ResponseLike>;
115
+ export declare function inflateGzip(bytes: Uint8Array): Promise<Uint8Array>;
116
+ /** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
117
+ export declare function inflateIfGzipped(bytes: Uint8Array, inflate: (bytes: Uint8Array) => Promise<Uint8Array>): Promise<Uint8Array>;
118
+ export interface PublishedDay {
119
+ readonly manifest: PublishedDayManifest;
120
+ readonly index: DayIndex;
121
+ readonly slice: (name: string) => Promise<SliceFile>;
122
+ readonly members: (name: string) => Promise<MembersFile>;
123
+ readonly pack: (operatorRef: string, vehicleRef: string) => Promise<Pack>;
124
+ readonly vehicle: (operatorRef: string, vehicleRef: string) => Promise<VehicleDay | null>;
125
+ /** Slice names present, from the manifest's file list, in order. */
126
+ readonly sliceNames: readonly string[];
127
+ }
128
+ /** Open a published day: manifest and index up front, everything else on demand and cached per file. */
129
+ export declare function openPublishedDay(transport: DayTransport): Promise<PublishedDay>;
130
+ /** A transport over fetch for a day served under a base URL. */
131
+ export declare function fetchTransport(base: string, fetchImpl?: FetchLike | undefined): DayTransport;
132
+ export {};
@@ -0,0 +1,162 @@
1
+ /** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
2
+ * five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
3
+ * per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
4
+ * manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
5
+ * and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
6
+ */
7
+ export const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
8
+ /** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
9
+ export function sliceNameAt(instant) {
10
+ const t = typeof instant === 'number' ? instant : Date.parse(instant);
11
+ if (!Number.isFinite(t))
12
+ throw new Error('Invalid instant');
13
+ const s = Math.floor(t / 1000 / SLICE_SECONDS) * SLICE_SECONDS, d = new Date(s * 1000);
14
+ return `${String(d.getUTCHours()).padStart(2, '0')}-${String(d.getUTCMinutes()).padStart(2, '0')}`;
15
+ }
16
+ /** The ISO start of a named slice on a date. */
17
+ export function sliceStart(date, name) { return `${date}T${name.replace('-', ':')}:00Z`; }
18
+ /** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
19
+ * arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
20
+ * cell edge lands where the compiler put it. */
21
+ export function cellOf(lon, lat) {
22
+ return [Number((Math.floor(lon / CELL_DEGREES) * CELL_DEGREES).toFixed(1)), Number((Math.floor(lat / CELL_DEGREES) * CELL_DEGREES).toFixed(1))];
23
+ }
24
+ export function cellKey(cellLon, cellLat) { return `${cellLon.toFixed(1)},${cellLat.toFixed(1)}`; }
25
+ /** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
26
+ export function partFor(index, operatorRef, vehicleRef) {
27
+ const op = index.operators.find(o => o.ref === operatorRef);
28
+ if (!op)
29
+ return null;
30
+ if (op.parts.length === 1)
31
+ return op.parts[0];
32
+ return op.parts.find(p => p.refs?.includes(vehicleRef)) ?? null;
33
+ }
34
+ /** Problems with a compiled day's manifest, empty when consistent: all 24 hours of the date accounted for once, one
35
+ * slice and one members file per slice, and the file list agreeing with the stated file and byte totals (the manifest
36
+ * itself is the one unlisted file). A trimmed fixture, whose statistics describe the whole day, fails by design. */
37
+ export function checkDayManifest(manifest) {
38
+ const problems = [], hours = [...manifest.hours.present, ...manifest.hours.missing], distinct = new Set(hours).size;
39
+ if (hours.length !== 24 || distinct !== 24)
40
+ problems.push(`Hours present and missing account for ${distinct} distinct of 24, listed ${hours.length} times`);
41
+ if (hours.some(h => !h.startsWith(`${manifest.date}T`)))
42
+ problems.push('An hour lies outside the manifest date');
43
+ const count = (prefix) => manifest.list.filter(f => f.path.startsWith(prefix)).length;
44
+ if (manifest.stats.slices !== manifest.slices)
45
+ problems.push(`Statistics count ${manifest.stats.slices} slices; the manifest ${manifest.slices}`);
46
+ if (count('slices/') !== manifest.slices)
47
+ problems.push(`${count('slices/')} slice files listed for ${manifest.slices} slices`);
48
+ if (count('members/') !== manifest.slices)
49
+ problems.push(`${count('members/')} members files listed for ${manifest.slices} slices`);
50
+ if (manifest.list.length + 1 !== manifest.files)
51
+ problems.push(`${manifest.list.length} listed files and the manifest disagree with ${manifest.files} files`);
52
+ const bytes = manifest.list.reduce((a, f) => a + f.bytes, 0);
53
+ if (bytes !== manifest.bytes)
54
+ problems.push(`Listed files hold ${bytes} bytes; the manifest states ${manifest.bytes}`);
55
+ return problems;
56
+ }
57
+ /** Problems reconciling a slice with its members, empty when they agree: the sea and the shoal must count the same
58
+ * vehicles. Each cell's distinct-vehicle count equals its distinct members, no member repeats, and neither file names
59
+ * a cell the other lacks. Samples are not checked here; members carry identities, not sample counts. */
60
+ export function reconcileSlice(slice, members) {
61
+ if (slice.slice !== members.slice)
62
+ return [`Slice ${slice.slice} paired with members of ${members.slice}`];
63
+ const problems = [], seen = new Set();
64
+ for (const [lon, lat, , vehicles] of slice.cells) {
65
+ const key = cellKey(lon, lat), refs = members.cells[key];
66
+ seen.add(key);
67
+ if (!refs) {
68
+ problems.push(`Cell ${key} counts ${vehicles} vehicles and has no members`);
69
+ continue;
70
+ }
71
+ const unique = new Set(refs.map(member => JSON.stringify(member))).size;
72
+ if (unique !== refs.length)
73
+ problems.push(`Cell ${key} repeats a member`);
74
+ if (unique !== vehicles)
75
+ problems.push(`Cell ${key} counts ${vehicles} vehicles but lists ${unique} members`);
76
+ }
77
+ for (const key of Object.keys(members.cells))
78
+ if (!seen.has(key))
79
+ problems.push(`Members name cell ${key}, absent from the slice`);
80
+ return problems;
81
+ }
82
+ /** Position along a track at an instant: linear between the samples around it, null outside the track or across a
83
+ * gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
84
+ export function positionAt(track, epochSeconds, maxGapSeconds = 180) {
85
+ if (!track.length || epochSeconds < track[0][0] || epochSeconds > track[track.length - 1][0])
86
+ return null;
87
+ let lo = 0, hi = track.length - 1;
88
+ while (hi - lo > 1) {
89
+ const mid = (lo + hi) >> 1;
90
+ if (track[mid][0] <= epochSeconds)
91
+ lo = mid;
92
+ else
93
+ hi = mid;
94
+ }
95
+ const a = track[lo], b = track[hi];
96
+ if (a[0] === epochSeconds || lo === hi)
97
+ return [a[1], a[2]];
98
+ if (b[0] === epochSeconds)
99
+ return [b[1], b[2]];
100
+ if (b[0] - a[0] > maxGapSeconds)
101
+ return null;
102
+ const f = (epochSeconds - a[0]) / (b[0] - a[0]);
103
+ return [a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
104
+ }
105
+ /** The line and journey a vehicle was on at an instant, from its runs. */
106
+ export function journeyAt(journeys, epochSeconds) {
107
+ let found = null;
108
+ for (const run of journeys) {
109
+ if (run[0] <= epochSeconds)
110
+ found = run;
111
+ else
112
+ break;
113
+ }
114
+ return found;
115
+ }
116
+ const platform = globalThis;
117
+ export async function inflateGzip(bytes) {
118
+ if (!platform.DecompressionStream || !platform.Blob || !platform.Response)
119
+ throw new Error('No DecompressionStream on this platform; supply an inflater');
120
+ const stream = new platform.Blob([bytes]).stream().pipeThrough(new platform.DecompressionStream('gzip'));
121
+ return new Uint8Array(await new platform.Response(stream).arrayBuffer());
122
+ }
123
+ /** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
124
+ export async function inflateIfGzipped(bytes, inflate) {
125
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b ? inflate(bytes) : bytes;
126
+ }
127
+ function decodeText(bytes) {
128
+ if (!platform.TextDecoder)
129
+ throw new Error('No TextDecoder on this platform');
130
+ return new platform.TextDecoder().decode(bytes);
131
+ }
132
+ /** Open a published day: manifest and index up front, everything else on demand and cached per file. */
133
+ export async function openPublishedDay(transport) {
134
+ const manifest = JSON.parse(await transport.text('manifest.json'));
135
+ const index = JSON.parse(await transport.text('index.json'));
136
+ if (manifest.date !== index.date)
137
+ throw new Error('Manifest and index disagree on the date');
138
+ const cache = new Map();
139
+ const cached = (path, load) => { let p = cache.get(path); if (!p) {
140
+ p = load();
141
+ cache.set(path, p);
142
+ } return p; };
143
+ const inflate = transport.inflate ?? inflateGzip;
144
+ const sliceNames = manifest.list.filter(f => f.path.startsWith('slices/')).map(f => f.path.slice(7, -5)).sort();
145
+ const pack = (operatorRef, vehicleRef) => { const part = partFor(index, operatorRef, vehicleRef); if (!part)
146
+ return Promise.reject(new Error(`No pack for ${operatorRef} ${vehicleRef}`)); return cached(part.file, async () => JSON.parse(decodeText(await inflateIfGzipped(await transport.bytes(part.file), inflate)))); };
147
+ return { manifest, index, sliceNames,
148
+ slice: name => cached(`slices/${name}.json`, async () => JSON.parse(await transport.text(`slices/${name}.json`))),
149
+ members: name => cached(`members/${name}.json`, async () => JSON.parse(await transport.text(`members/${name}.json`))),
150
+ pack,
151
+ vehicle: async (operatorRef, vehicleRef) => { if (!partFor(index, operatorRef, vehicleRef))
152
+ return null; return (await pack(operatorRef, vehicleRef)).vehicles[vehicleRef] ?? null; } };
153
+ }
154
+ /** A transport over fetch for a day served under a base URL. */
155
+ export function fetchTransport(base, fetchImpl = platform.fetch) {
156
+ if (!fetchImpl)
157
+ throw new Error('No fetch on this platform; supply one');
158
+ const url = (path) => `${base.replace(/\/$/, '')}/${path}`;
159
+ const get = async (path) => { const r = await fetchImpl(url(path)); if (!r.ok)
160
+ throw new Error(`${path} returned HTTP ${r.status}`); return r; };
161
+ return { text: async (path) => (await get(path)).text(), bytes: async (path) => new Uint8Array(await (await get(path)).arrayBuffer()) };
162
+ }
@@ -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,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,78 @@
1
+ /** A continuous estimate between scattered instruments. Values are reconstructed from weighted
2
+ * neighbouring stations; `support` reports how near the closest contributing evidence is, so a
3
+ * rendering can fade where its evidence weakens. The estimate is a model choice, not an observation
4
+ * at the sampled place, and missing station values stay missing rather than becoming zero.
5
+ */
6
+ export interface FieldStation {
7
+ readonly latitude: number;
8
+ readonly longitude: number;
9
+ }
10
+ /** Kilometre scales of the neighbour kernel and the support fade. */
11
+ export interface FieldSupportOptions {
12
+ /** Gaussian scale of neighbour weights. */
13
+ readonly scaleKm: number;
14
+ /** Weights taper smoothly from `reachStartKm` to zero at `reachKm`; stations beyond `reachKm` do not contribute. */
15
+ readonly reachStartKm: number;
16
+ readonly reachKm: number;
17
+ /** Support is 1 within `supportNearKm` of the nearest contributing station and fades to 0 at `supportFarKm`. */
18
+ readonly supportNearKm: number;
19
+ readonly supportFarKm: number;
20
+ }
21
+ export declare const DEFAULT_FIELD_SUPPORT: FieldSupportOptions;
22
+ export interface FieldNeighbour {
23
+ readonly index: number;
24
+ readonly distanceKm: number;
25
+ readonly weight: number;
26
+ }
27
+ /** One station's contribution at one instant. A null value excludes the station; an incomplete vector excludes it from the vector estimate only. */
28
+ export interface FieldSample {
29
+ readonly value: number | null;
30
+ readonly vector?: readonly [east: number, north: number] | null;
31
+ }
32
+ export interface FieldEstimate {
33
+ readonly value: number | null;
34
+ readonly vector: readonly [east: number, north: number] | null;
35
+ /** Fade from the nearest station that supplied a value. */
36
+ readonly support: number;
37
+ /** Fade from the nearest station that supplied a complete vector; 0 when none did. */
38
+ readonly vectorSupport: number;
39
+ }
40
+ export interface FieldGridBounds {
41
+ readonly west: number;
42
+ readonly east: number;
43
+ readonly south: number;
44
+ readonly north: number;
45
+ readonly step: number;
46
+ }
47
+ export interface FieldGridCell {
48
+ readonly latitude: number;
49
+ readonly longitude: number;
50
+ readonly neighbours: readonly FieldNeighbour[];
51
+ }
52
+ export interface FieldGrid {
53
+ readonly width: number;
54
+ readonly height: number;
55
+ readonly bounds: FieldGridBounds;
56
+ readonly cells: readonly FieldGridCell[];
57
+ }
58
+ /** A bilinear reading of a sampled grid. The vector is null when any contributing corner lacks one. */
59
+ export interface FieldGridSample {
60
+ readonly value: number;
61
+ readonly vector: readonly [east: number, north: number] | null;
62
+ readonly support: number;
63
+ }
64
+ /** Great-circle distance on a spherical Earth. */
65
+ export declare function distanceKm(a: FieldStation, b: FieldStation): number;
66
+ /** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
67
+ export declare function fieldNeighbours(stations: readonly FieldStation[], latitude: number, longitude: number, options?: FieldSupportOptions, exclude?: number): FieldNeighbour[];
68
+ /** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
69
+ export declare function estimateField(neighbours: readonly FieldNeighbour[], sample: (index: number) => FieldSample | null | undefined, options?: FieldSupportOptions): FieldEstimate;
70
+ /** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
71
+ export declare function createFieldGrid(stations: readonly FieldStation[], bounds: FieldGridBounds, options?: FieldSupportOptions): FieldGrid;
72
+ /** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
73
+ * Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
74
+ * unavailable and its support also fades from the nearest station with a vector.
75
+ */
76
+ export declare function sampleFieldGrid(grid: FieldGrid, sample: (index: number, level: number) => FieldSample | null | undefined, levels: number, options?: FieldSupportOptions, movement?: boolean): Float32Array;
77
+ /** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
78
+ export declare function sampleFieldGridAt(data: Float32Array, grid: FieldGrid, latitude: number, longitude: number, level: number): FieldGridSample | null;
@@ -0,0 +1,95 @@
1
+ export const DEFAULT_FIELD_SUPPORT = { scaleKm: 90, reachStartKm: 180, reachKm: 240, supportNearKm: 120, supportFarKm: 240 };
2
+ const rad = Math.PI / 180;
3
+ const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
4
+ const fade = (distance, near, far) => 1 - smooth((distance - near) / Math.max(far - near, 1e-9));
5
+ /** Great-circle distance on a spherical Earth. */
6
+ export function distanceKm(a, b) {
7
+ const p = (b.latitude - a.latitude) * rad, q = (b.longitude - a.longitude) * rad;
8
+ const h = Math.sin(p / 2) ** 2 + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(q / 2) ** 2;
9
+ return 12742.0176 * Math.asin(Math.min(1, Math.sqrt(h)));
10
+ }
11
+ /** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
12
+ export function fieldNeighbours(stations, latitude, longitude, options = DEFAULT_FIELD_SUPPORT, exclude = -1) {
13
+ const place = { latitude, longitude }, neighbours = [];
14
+ stations.forEach((station, index) => {
15
+ const distance = distanceKm(station, place);
16
+ if (index === exclude || distance >= options.reachKm)
17
+ return;
18
+ neighbours.push({ index, distanceKm: distance, weight: Math.exp(-.5 * (distance / options.scaleKm) ** 2) * fade(distance, options.reachStartKm, options.reachKm) });
19
+ });
20
+ return neighbours;
21
+ }
22
+ /** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
23
+ export function estimateField(neighbours, sample, options = DEFAULT_FIELD_SUPPORT) {
24
+ let weight = 0, value = 0, vectorWeight = 0, east = 0, north = 0, nearest = Infinity, nearestVector = Infinity;
25
+ for (const n of neighbours) {
26
+ const s = sample(n.index);
27
+ if (!s || s.value === null || !Number.isFinite(s.value))
28
+ continue;
29
+ weight += n.weight;
30
+ value += n.weight * s.value;
31
+ nearest = Math.min(nearest, n.distanceKm);
32
+ const v = s.vector;
33
+ if (v && Number.isFinite(v[0]) && Number.isFinite(v[1])) {
34
+ vectorWeight += n.weight;
35
+ east += n.weight * v[0];
36
+ north += n.weight * v[1];
37
+ nearestVector = Math.min(nearestVector, n.distanceKm);
38
+ }
39
+ }
40
+ if (weight < 1e-12)
41
+ return { value: null, vector: null, support: 0, vectorSupport: 0 };
42
+ return { value: value / weight, vector: vectorWeight ? [east / vectorWeight, north / vectorWeight] : null,
43
+ support: fade(nearest, options.supportNearKm, options.supportFarKm), vectorSupport: vectorWeight ? fade(nearestVector, options.supportNearKm, options.supportFarKm) : 0 };
44
+ }
45
+ /** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
46
+ export function createFieldGrid(stations, bounds, options = DEFAULT_FIELD_SUPPORT) {
47
+ const width = Math.round((bounds.east - bounds.west) / bounds.step) + 1, height = Math.round((bounds.north - bounds.south) / bounds.step) + 1, cells = [];
48
+ for (let y = 0; y < height; y++)
49
+ for (let x = 0; x < width; x++) {
50
+ const latitude = bounds.south + y * bounds.step, longitude = bounds.west + x * bounds.step;
51
+ cells.push({ latitude, longitude, neighbours: fieldNeighbours(stations, latitude, longitude, options) });
52
+ }
53
+ return { width, height, bounds, cells };
54
+ }
55
+ /** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
56
+ * Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
57
+ * unavailable and its support also fades from the nearest station with a vector.
58
+ */
59
+ export function sampleFieldGrid(grid, sample, levels, options = DEFAULT_FIELD_SUPPORT, movement = false) {
60
+ const data = new Float32Array(grid.cells.length * levels * 4);
61
+ data.fill(NaN);
62
+ for (let level = 0; level < levels; level++)
63
+ for (let i = 0; i < grid.cells.length; i++) {
64
+ const e = estimateField(grid.cells[i].neighbours, (index) => sample(index, level), options), o = (level * grid.cells.length + i) * 4;
65
+ if (e.value === null || (movement && !e.vector))
66
+ continue;
67
+ data[o] = e.value;
68
+ data[o + 3] = movement ? Math.min(e.support, e.vectorSupport) : e.support;
69
+ if (e.vector) {
70
+ data[o + 1] = e.vector[0];
71
+ data[o + 2] = e.vector[1];
72
+ }
73
+ }
74
+ return data;
75
+ }
76
+ /** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
77
+ export function sampleFieldGridAt(data, grid, latitude, longitude, level) {
78
+ const { width, height, bounds } = grid, x = (longitude - bounds.west) / bounds.step, y = (latitude - bounds.south) / bounds.step;
79
+ if (!(x >= 0 && x <= width - 1 && y >= 0 && y <= height - 1) || level < 0 || (level + 1) * width * height * 4 > data.length)
80
+ return null;
81
+ const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, out = [0, 0, 0, 0];
82
+ let vector = true;
83
+ for (const [xx, yy, w] of [[x0, y0, (1 - fx) * (1 - fy)], [Math.min(x0 + 1, width - 1), y0, fx * (1 - fy)], [x0, Math.min(y0 + 1, height - 1), (1 - fx) * fy], [Math.min(x0 + 1, width - 1), Math.min(y0 + 1, height - 1), fx * fy]]) {
84
+ if (w < 1e-10)
85
+ continue;
86
+ const o = (level * width * height + yy * width + xx) * 4;
87
+ if (!Number.isFinite(data[o]) || !Number.isFinite(data[o + 3]))
88
+ return null;
89
+ if (!Number.isFinite(data[o + 1]) || !Number.isFinite(data[o + 2]))
90
+ vector = false;
91
+ for (let c = 0; c < 4; c++)
92
+ out[c] += data[o + c] * w;
93
+ }
94
+ return { value: out[0], vector: vector ? [out[1], out[2]] : null, support: out[3] };
95
+ }
@@ -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[];