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

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,30 @@
1
+ /** A regular latitude/longitude grid of one or more variables at listed instants, such as cloud
2
+ * cover, rainfall or a model wind component. Values keep the source's own units; null marks a
3
+ * missing sample. Readings interpolate bilinearly in space and linearly in time, and a missing
4
+ * sample with any weight makes the whole reading unavailable rather than bridging the gap.
5
+ */
6
+ export interface GriddedSeriesGrid {
7
+ readonly west: number;
8
+ readonly south: number;
9
+ readonly step: number;
10
+ readonly width: number;
11
+ readonly height: number;
12
+ }
13
+ export interface GriddedSeries<K extends string = string> {
14
+ /** ISO instants in ascending order. */
15
+ readonly times: readonly string[];
16
+ readonly grid: GriddedSeriesGrid;
17
+ /** One frame per instant; rows run south to north. */
18
+ readonly frames: readonly Readonly<Partial<Record<K, readonly (number | null)[]>>>[];
19
+ }
20
+ export interface TimeBracket {
21
+ readonly a: number;
22
+ readonly b: number;
23
+ readonly fraction: number;
24
+ }
25
+ /** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
26
+ export declare function bracketTime(times: readonly string[], time: string | number): TimeBracket | null;
27
+ /** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
28
+ export declare function sampleGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, latitude: number, longitude: number, time: string | number): number | null;
29
+ /** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
30
+ export declare function meanGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, time: string | number, range?: readonly [number, number]): number | null;
@@ -0,0 +1,61 @@
1
+ /** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
2
+ export function bracketTime(times, time) {
3
+ const value = typeof time === 'number' ? time : Date.parse(time);
4
+ if (!times.length || !Number.isFinite(value) || value < Date.parse(times[0]) || value > Date.parse(times[times.length - 1]))
5
+ return null;
6
+ for (let i = 0; i < times.length; i++) {
7
+ const stamp = Date.parse(times[i]);
8
+ if (value === stamp)
9
+ return { a: i, b: i, fraction: 0 };
10
+ if (stamp > value)
11
+ return { a: i - 1, b: i, fraction: (value - Date.parse(times[i - 1])) / (stamp - Date.parse(times[i - 1])) };
12
+ }
13
+ return null;
14
+ }
15
+ const valid = (sample, range) => typeof sample === 'number' && Number.isFinite(sample) && (!range || (sample >= range[0] && sample <= range[1]));
16
+ /** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
17
+ export function sampleGriddedSeries(series, key, latitude, longitude, time) {
18
+ const bracket = bracketTime(series.times, time), { west, south, step, width, height } = series.grid, x = (longitude - west) / step, y = (latitude - south) / step;
19
+ if (!bracket || !(x >= 0 && y >= 0 && x <= width - 1 && y <= height - 1))
20
+ return null;
21
+ const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
22
+ let value = 0;
23
+ for (const [t, wt] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
24
+ for (const [ix, wx] of [[x0, 1 - fx], [Math.min(x0 + 1, width - 1), fx]])
25
+ for (const [iy, wy] of [[y0, 1 - fy], [Math.min(y0 + 1, height - 1), fy]]) {
26
+ const weight = wt * wx * wy;
27
+ if (weight === 0)
28
+ continue;
29
+ const sample = series.frames[t]?.[key]?.[iy * width + ix];
30
+ if (!valid(sample))
31
+ return null;
32
+ value += sample * weight;
33
+ }
34
+ }
35
+ return value;
36
+ }
37
+ /** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
38
+ export function meanGriddedSeries(series, key, time, range) {
39
+ const bracket = bracketTime(series.times, time);
40
+ if (!bracket)
41
+ return null;
42
+ const { south, step, width, height } = series.grid;
43
+ let sum = 0, weight = 0;
44
+ for (let y = 0; y < height; y++) {
45
+ const area = Math.cos((south + y * step) * Math.PI / 180);
46
+ for (let x = 0; x < width; x++) {
47
+ let value = 0;
48
+ for (const [frame, w] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
49
+ if (w === 0)
50
+ continue;
51
+ const sample = series.frames[frame]?.[key]?.[y * width + x];
52
+ if (!valid(sample, range))
53
+ return null;
54
+ value += sample * w;
55
+ }
56
+ sum += value * area;
57
+ weight += area;
58
+ }
59
+ }
60
+ return weight ? sum / weight : null;
61
+ }
@@ -0,0 +1,42 @@
1
+ /** Live boards use Unix seconds throughout, independent of an edition's recorded service day. */
2
+ export interface LiveAirportMovement {
3
+ readonly id: string;
4
+ readonly service: string;
5
+ readonly scheduledTime?: number;
6
+ /** Provider revision: may be estimated or actual; do not relabel as confirmed. */
7
+ readonly revisedTime?: number;
8
+ readonly place?: string;
9
+ readonly gate?: string;
10
+ readonly status?: string;
11
+ readonly tone?: 'neutral' | 'accent' | 'warning';
12
+ }
13
+ export interface LiveAirportSnapshot {
14
+ readonly airport: {
15
+ readonly iata: string;
16
+ readonly name: string;
17
+ readonly city: string;
18
+ readonly timeZone: string;
19
+ };
20
+ readonly fetchedAt: number;
21
+ readonly freshUntil: number;
22
+ readonly expiresAt: number;
23
+ readonly windowStart: number;
24
+ readonly windowEnd: number;
25
+ readonly departures: readonly LiveAirportMovement[];
26
+ readonly arrivals: readonly LiveAirportMovement[];
27
+ }
28
+ export type AirportFeedReason = 'disabled' | 'not-configured' | 'subscription' | 'budget' | 'provider' | 'rate-limit';
29
+ export interface AirportFeedResponse {
30
+ readonly version: 1;
31
+ readonly status: 'fresh' | 'stale' | 'unavailable' | 'disabled';
32
+ readonly reason?: AirportFeedReason;
33
+ readonly retryAfterSeconds: number;
34
+ readonly snapshot?: LiveAirportSnapshot;
35
+ }
36
+ export interface AirportFeedCapabilities {
37
+ readonly version: 1;
38
+ readonly enabled: boolean;
39
+ readonly airports: readonly string[];
40
+ }
41
+ /** Validate the network boundary before publishing data to a card. */
42
+ export declare function isAirportFeedResponse(value: unknown): value is AirportFeedResponse;
@@ -0,0 +1,32 @@
1
+ const record = (value) => !!value && typeof value === 'object';
2
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value);
3
+ function movement(value) {
4
+ return record(value) && typeof value.id === 'string' && typeof value.service === 'string'
5
+ && ['scheduledTime', 'revisedTime'].every((key) => value[key] === undefined || finite(value[key]))
6
+ && ['place', 'gate', 'status'].every((key) => value[key] === undefined || typeof value[key] === 'string')
7
+ && (value.tone === undefined || ['neutral', 'accent', 'warning'].includes(String(value.tone)));
8
+ }
9
+ /** Validate the network boundary before publishing data to a card. */
10
+ export function isAirportFeedResponse(value) {
11
+ if (!record(value) || value.version !== 1 || !['fresh', 'stale', 'unavailable', 'disabled'].includes(String(value.status))
12
+ || !finite(value.retryAfterSeconds) || value.retryAfterSeconds < 0)
13
+ return false;
14
+ if (value.status === 'disabled' || value.status === 'unavailable')
15
+ return value.snapshot === undefined;
16
+ const s = value.snapshot;
17
+ try {
18
+ if (!record(s) || !record(s.airport) || typeof s.airport.timeZone !== 'string')
19
+ return false;
20
+ new Intl.DateTimeFormat('en', { timeZone: s.airport.timeZone }).format(0);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ return record(s) && record(s.airport)
26
+ && ['iata', 'name', 'city', 'timeZone'].every((key) => typeof s.airport[key] === 'string')
27
+ && ['fetchedAt', 'freshUntil', 'expiresAt', 'windowStart', 'windowEnd'].every((key) => finite(s[key]))
28
+ && Number(s.fetchedAt) <= Number(s.freshUntil) && Number(s.freshUntil) <= Number(s.expiresAt)
29
+ && Number(s.windowStart) < Number(s.windowEnd)
30
+ && Array.isArray(s.departures) && s.departures.every(movement)
31
+ && Array.isArray(s.arrivals) && s.arrivals.every(movement);
32
+ }
@@ -0,0 +1,21 @@
1
+ /** All values use the study's time coordinate, in seconds (including times beyond midnight). */
2
+ export interface MovementBoardStudy {
3
+ readonly time: number;
4
+ readonly windowStart: number;
5
+ readonly windowEnd: number;
6
+ }
7
+ export interface MovementBoardHorizon {
8
+ readonly lookBehindSeconds?: number;
9
+ readonly lookAheadSeconds?: number;
10
+ }
11
+ export interface MovementBoardWindow {
12
+ readonly start: number;
13
+ readonly end: number;
14
+ }
15
+ /** Keep recent movements briefly, then show the next hour, clipped to the selected study. */
16
+ export declare function movementBoardWindow(study: MovementBoardStudy, { lookBehindSeconds, lookAheadSeconds }?: MovementBoardHorizon): MovementBoardWindow | undefined;
17
+ /** Unknown times cannot be placed in a time-windowed board. Never mutate the caller's rows. */
18
+ export declare function movementsForBoard<T extends {
19
+ readonly id: string;
20
+ readonly time?: number;
21
+ }>(movements: readonly T[], window: MovementBoardWindow | undefined, maxRows?: number): readonly T[];
@@ -0,0 +1,23 @@
1
+ /** Keep recent movements briefly, then show the next hour, clipped to the selected study. */
2
+ export function movementBoardWindow(study, { lookBehindSeconds = 600, lookAheadSeconds = 3600 } = {}) {
3
+ const { time, windowStart, windowEnd } = study;
4
+ if (![time, windowStart, windowEnd, lookBehindSeconds, lookAheadSeconds].every(Number.isFinite)
5
+ || windowStart > windowEnd || time < windowStart || time > windowEnd
6
+ || lookBehindSeconds < 0 || lookAheadSeconds < 0)
7
+ return undefined;
8
+ return {
9
+ start: Math.max(windowStart, time - lookBehindSeconds),
10
+ end: Math.min(windowEnd, time + lookAheadSeconds),
11
+ };
12
+ }
13
+ /** Unknown times cannot be placed in a time-windowed board. Never mutate the caller's rows. */
14
+ export function movementsForBoard(movements, window, maxRows = 8) {
15
+ if (!window || ![window.start, window.end, maxRows].every(Number.isFinite)
16
+ || window.start > window.end || maxRows < 1)
17
+ return [];
18
+ return movements
19
+ .filter((entry) => entry.time !== undefined && Number.isFinite(entry.time)
20
+ && entry.time >= window.start && entry.time <= window.end)
21
+ .sort((first, second) => first.time - second.time || (first.id < second.id ? -1 : first.id > second.id ? 1 : 0))
22
+ .slice(0, Math.floor(maxRows));
23
+ }
@@ -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
+ }
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,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
+ }