@motionstudies/core 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (50) hide show
  1. package/README.md +247 -2
  2. package/air-continents.d.ts +63 -0
  3. package/air-continents.js +43 -0
  4. package/air-enrichment.d.ts +63 -0
  5. package/air-enrichment.js +76 -0
  6. package/air-search.d.ts +3 -0
  7. package/domain/aggregate-road.d.ts +48 -0
  8. package/domain/aggregate-road.js +22 -0
  9. package/domain/air-day.d.ts +3 -1
  10. package/domain/air.d.ts +15 -0
  11. package/domain/airport.d.ts +16 -0
  12. package/domain/airport.js +23 -0
  13. package/domain/daylight.d.ts +32 -0
  14. package/domain/daylight.js +32 -0
  15. package/domain/field-paths.d.ts +35 -0
  16. package/domain/field-paths.js +43 -0
  17. package/domain/gridded-series.d.ts +30 -0
  18. package/domain/gridded-series.js +61 -0
  19. package/domain/live-airport.d.ts +42 -0
  20. package/domain/live-airport.js +32 -0
  21. package/domain/movement-board.d.ts +21 -0
  22. package/domain/movement-board.js +23 -0
  23. package/domain/network-patterns.d.ts +19 -0
  24. package/domain/network-patterns.js +73 -0
  25. package/domain/network.js +37 -1
  26. package/domain/power-day.d.ts +84 -0
  27. package/domain/power-day.js +104 -0
  28. package/domain/published-day.d.ts +133 -0
  29. package/domain/published-day.js +162 -0
  30. package/domain/route-events.d.ts +70 -0
  31. package/domain/route-events.js +38 -0
  32. package/domain/route-field.d.ts +53 -0
  33. package/domain/route-field.js +47 -0
  34. package/domain/station-calls.d.ts +48 -0
  35. package/domain/station-calls.js +73 -0
  36. package/domain/station-departures.d.ts +15 -0
  37. package/domain/station-departures.js +25 -0
  38. package/domain/supported-field.d.ts +78 -0
  39. package/domain/supported-field.js +95 -0
  40. package/domain/vehicle-calls.d.ts +11 -0
  41. package/domain/vehicle-calls.js +14 -0
  42. package/domain/vehicle-counts.d.ts +13 -0
  43. package/domain/vehicle-counts.js +33 -0
  44. package/map-labels.d.ts +24 -0
  45. package/map-labels.js +48 -0
  46. package/package.json +108 -1
  47. package/render-frame.d.ts +8 -0
  48. package/render-frame.js +13 -0
  49. package/timeline.d.ts +31 -0
  50. package/timeline.js +68 -0
@@ -0,0 +1,84 @@
1
+ /** Dated electricity evidence. All times are elapsed UTC seconds from startUtc.
2
+ * Settlement is interval energy (MWh); plans and national aggregates are power (MW).
3
+ * Geography, population selection and placement policy belong to the consuming work.
4
+ */
5
+ export interface PowerSource {
6
+ sha256: string;
7
+ url: string;
8
+ retrievedAt: string;
9
+ publisher: string;
10
+ }
11
+ export interface PowerPlacement {
12
+ method: 'site' | 'connection' | 'curated' | 'aggregate' | 'interconnector' | 'unplaced';
13
+ latitude?: number;
14
+ longitude?: number;
15
+ note?: string | null;
16
+ source?: string | null;
17
+ score?: number;
18
+ matched?: Record<string, unknown>;
19
+ }
20
+ export interface PowerUnit {
21
+ id: string;
22
+ name: string;
23
+ fuelType: string | null;
24
+ capacityMw: number | null;
25
+ settledMwh: (number | null)[];
26
+ runs: (string | null)[];
27
+ /** [start, end, start MW, end MW]; half-open intervals, no extrapolation. */
28
+ plan: [number, number, number, number][];
29
+ placement?: PowerPlacement;
30
+ evidence?: {
31
+ path: string;
32
+ sha256: string;
33
+ bytes: number;
34
+ };
35
+ }
36
+ export interface PowerDay {
37
+ kind: 'power-day';
38
+ schemaVersion: 1;
39
+ date: string;
40
+ timezone: string;
41
+ startUtc: string;
42
+ endUtc: string;
43
+ periodSeconds: 1800;
44
+ quantityUnit: 'MWh';
45
+ powerUnit: 'MW';
46
+ scope: string;
47
+ population: string;
48
+ units: PowerUnit[];
49
+ demandMw: (number | null)[];
50
+ fuelMix: {
51
+ time: number;
52
+ byFuel: Record<string, number | null>;
53
+ }[];
54
+ settlementSources: string[];
55
+ planSources: string[];
56
+ sources: PowerSource[];
57
+ attribution: {
58
+ statement: string;
59
+ licence: string;
60
+ }[];
61
+ }
62
+ /** Validate untrusted JSON at both publication and browser boundaries. Does not fetch. */
63
+ export declare function readPowerDay(value: unknown): PowerDay;
64
+ export declare function powerPeriod(day: PowerDay, seconds: number): number | null;
65
+ /** Return null for a gap or ambiguous overlapping declarations, never infer a plan. */
66
+ export declare function plannedPower(unit: PowerUnit, seconds: number): number | null;
67
+ export declare function unitPower(day: PowerDay, unit: PowerUnit, seconds: number): {
68
+ period: number | null;
69
+ settledMwh: number | null;
70
+ averageMw: number | null;
71
+ plannedMw: number | null;
72
+ run: string | null;
73
+ };
74
+ /** Membership is supplied by the work; report missing contributors alongside partial sums. */
75
+ export declare function sumPower(day: PowerDay, units: readonly PowerUnit[], seconds: number): {
76
+ averageMw: number | null;
77
+ available: number;
78
+ missing: number;
79
+ };
80
+ /** Last national sample, valid for at most five minutes and never outside this day. */
81
+ export declare function fuelMixAt(day: PowerDay, seconds: number): {
82
+ time: number;
83
+ byFuel: Record<string, number | null>;
84
+ } | null;
@@ -0,0 +1,104 @@
1
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
3
+ const nullable = (v) => v === null || finite(v);
4
+ const hash = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
5
+ const nonempty = (v) => typeof v === 'string' && v.length > 0;
6
+ const utc = (v) => typeof v === 'string' && /Z$/.test(v) && Number.isFinite(Date.parse(v));
7
+ function requireValue(ok, message) { if (!ok)
8
+ throw new Error(`Invalid power day: ${message}`); }
9
+ /** Validate untrusted JSON at both publication and browser boundaries. Does not fetch. */
10
+ export function readPowerDay(value) {
11
+ requireValue(object(value), 'expected object');
12
+ const d = value;
13
+ requireValue(d.kind === 'power-day' && d.schemaVersion === 1, 'schema');
14
+ requireValue(nonempty(d.timezone) && typeof d.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d.date), 'date/timezone');
15
+ requireValue(utc(d.startUtc) && utc(d.endUtc), 'UTC bounds');
16
+ const duration = (Date.parse(d.endUtc) - Date.parse(d.startUtc)) / 1000, periods = duration / 1800;
17
+ requireValue([46, 48, 50].includes(periods) && d.periodSeconds === 1800, 'civil-day intervals');
18
+ // A power day is a complete civil day, including both clock-change days.
19
+ const dateAt = (t) => new Intl.DateTimeFormat('en-CA', { timeZone: d.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(t);
20
+ const midnight = (t) => new Intl.DateTimeFormat('en-GB', { timeZone: d.timezone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).format(t) === '00:00:00';
21
+ requireValue(dateAt(Date.parse(d.startUtc)) === d.date && midnight(Date.parse(d.startUtc)) && midnight(Date.parse(d.endUtc)) && dateAt(Date.parse(d.endUtc) - 1) === d.date, 'civil date');
22
+ requireValue(d.quantityUnit === 'MWh' && d.powerUnit === 'MW', 'explicit units');
23
+ requireValue(nonempty(d.scope) && nonempty(d.population), 'scope/population');
24
+ requireValue(Array.isArray(d.sources) && d.sources.length > 0, 'sources');
25
+ const hashes = new Set();
26
+ for (const s of d.sources) {
27
+ requireValue(object(s) && hash(s.sha256) && nonempty(s.publisher) && utc(s.retrievedAt) && typeof s.url === 'string' && /^https:\/\//.test(s.url), 'source record');
28
+ requireValue(!hashes.has(s.sha256), 'duplicate source');
29
+ hashes.add(s.sha256);
30
+ }
31
+ for (const key of ['settlementSources', 'planSources'])
32
+ requireValue(Array.isArray(d[key]) && d[key].length === periods && d[key].every((h) => typeof h === 'string' && hashes.has(h)), `${key} linkage`);
33
+ requireValue(Array.isArray(d.attribution) && d.attribution.length > 0 && d.attribution.every(a => object(a) && nonempty(a.statement) && typeof a.licence === 'string' && /^https:\/\//.test(a.licence)), 'attribution');
34
+ requireValue(Array.isArray(d.demandMw) && d.demandMw.length === periods && d.demandMw.every(nullable), 'demand');
35
+ requireValue(Array.isArray(d.fuelMix), 'fuel mix');
36
+ let previous = -Infinity;
37
+ for (const f of d.fuelMix) {
38
+ requireValue(object(f) && finite(f.time) && f.time >= 0 && f.time < duration && f.time > previous && object(f.byFuel) && Object.values(f.byFuel).every(nullable), 'fuel mix samples');
39
+ previous = f.time;
40
+ }
41
+ requireValue(Array.isArray(d.units), 'units');
42
+ const ids = new Set();
43
+ for (const u of d.units) {
44
+ requireValue(object(u) && nonempty(u.id) && !ids.has(u.id) && nonempty(u.name), 'unit identity');
45
+ ids.add(u.id);
46
+ requireValue((u.fuelType === null || nonempty(u.fuelType)) && nullable(u.capacityMw), 'unit register');
47
+ requireValue(Array.isArray(u.settledMwh) && u.settledMwh.length === periods && u.settledMwh.every(nullable), 'settlement values');
48
+ const settledMwh = u.settledMwh;
49
+ requireValue(Array.isArray(u.runs) && u.runs.length === periods && u.runs.every((r, i) => settledMwh[i] === null ? r === null : ['II', 'SF', 'R1', 'R2', 'R3', 'RF', 'DF'].includes(r)), 'settlement runs');
50
+ requireValue(Array.isArray(u.plan) && u.plan.every(p => Array.isArray(p) && p.length === 4 && p.every(finite) && p[0] >= 0 && p[1] <= duration && p[1] > p[0]), 'declared segments');
51
+ if (u.placement !== undefined) {
52
+ const p = u.placement;
53
+ requireValue(object(p) && ['site', 'connection', 'curated', 'aggregate', 'interconnector', 'unplaced'].includes(String(p.method)), 'placement method');
54
+ const located = ['site', 'connection', 'curated'].includes(String(p.method));
55
+ requireValue(located ? finite(p.latitude) && Math.abs(p.latitude) <= 90 && finite(p.longitude) && Math.abs(p.longitude) <= 180 : p.latitude === undefined && p.longitude === undefined, 'placement coordinates');
56
+ if (p.method === 'curated')
57
+ requireValue(nonempty(p.source), 'curated citation');
58
+ }
59
+ if (u.evidence !== undefined) {
60
+ const e = u.evidence;
61
+ requireValue(object(e) && typeof e.path === 'string' && /^evidence\/[a-f0-9]{64}\.json$/.test(e.path) && hash(e.sha256) && Number.isSafeInteger(e.bytes) && Number(e.bytes) > 0, 'evidence artifact');
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+ export function powerPeriod(day, seconds) {
67
+ return Number.isFinite(seconds) && seconds >= 0 && seconds < day.demandMw.length * day.periodSeconds ? Math.floor(seconds / day.periodSeconds) : null;
68
+ }
69
+ /** Return null for a gap or ambiguous overlapping declarations, never infer a plan. */
70
+ export function plannedPower(unit, seconds) {
71
+ const segments = unit.plan.filter(p => seconds >= p[0] && seconds < p[1]);
72
+ if (segments.length !== 1)
73
+ return null;
74
+ const [start, end, a, b] = segments[0];
75
+ return a + (b - a) * (seconds - start) / (end - start);
76
+ }
77
+ export function unitPower(day, unit, seconds) {
78
+ const period = powerPeriod(day, seconds), mwh = period === null ? null : unit.settledMwh[period];
79
+ return { period, settledMwh: mwh, averageMw: mwh === null ? null : mwh * 3600 / day.periodSeconds, plannedMw: period === null ? null : plannedPower(unit, seconds), run: period === null ? null : unit.runs[period] };
80
+ }
81
+ /** Membership is supplied by the work; report missing contributors alongside partial sums. */
82
+ export function sumPower(day, units, seconds) {
83
+ let averageMw = 0, available = 0;
84
+ for (const unit of units) {
85
+ const value = unitPower(day, unit, seconds).averageMw;
86
+ if (value !== null) {
87
+ averageMw += value;
88
+ available++;
89
+ }
90
+ }
91
+ return { averageMw: available ? averageMw : null, available, missing: units.length - available };
92
+ }
93
+ /** Last national sample, valid for at most five minutes and never outside this day. */
94
+ export function fuelMixAt(day, seconds) {
95
+ if (powerPeriod(day, seconds) === null)
96
+ return null;
97
+ let sample;
98
+ for (let i = day.fuelMix.length - 1; i >= 0; i--)
99
+ if (day.fuelMix[i].time <= seconds) {
100
+ sample = day.fuelMix[i];
101
+ break;
102
+ }
103
+ return sample && seconds - sample.time < 300 ? sample : null;
104
+ }
@@ -0,0 +1,133 @@
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
+ readonly sha256?: string;
30
+ }[];
31
+ }
32
+ /** One cell of a slice: centre-less floor coordinates, samples (which sum across cells) and distinct vehicles (which do not). */
33
+ export type SliceCell = readonly [lon: number, lat: number, samples: number, vehicles: number];
34
+ export interface SliceFile {
35
+ readonly slice: string;
36
+ readonly cells: readonly SliceCell[];
37
+ }
38
+ /** Members of each cell as operator index into the day index and vehicle reference. */
39
+ export type Member = readonly [operatorIndex: number, vehicleRef: string];
40
+ export interface MembersFile {
41
+ readonly slice: string;
42
+ readonly cells: Readonly<Record<string, readonly Member[]>>;
43
+ }
44
+ export interface PackPart {
45
+ readonly file: string;
46
+ readonly bytes: number;
47
+ readonly vehicles: number;
48
+ readonly samples: number;
49
+ readonly refs?: readonly string[];
50
+ }
51
+ export interface OperatorEntry {
52
+ readonly ref: string;
53
+ readonly vehicles: number;
54
+ readonly samples: number;
55
+ readonly parts: readonly PackPart[];
56
+ }
57
+ export interface DayIndex {
58
+ readonly date: string;
59
+ readonly operators: readonly OperatorEntry[];
60
+ }
61
+ export type TrackPoint = readonly [epochSeconds: number, lon: number, lat: number];
62
+ export type JourneyRun = readonly [epochSeconds: number, lineRef: string | null, journeyRef: string | null];
63
+ export interface VehicleDay {
64
+ readonly track: readonly TrackPoint[];
65
+ readonly journeys: readonly JourneyRun[];
66
+ }
67
+ export interface Pack {
68
+ readonly date: string;
69
+ readonly operator: string;
70
+ readonly part: number;
71
+ readonly parts: number;
72
+ readonly vehicles: Readonly<Record<string, VehicleDay>>;
73
+ }
74
+ export interface DayTransport {
75
+ /** Text of a file under the day's base, by its relative path. */
76
+ readonly text: (path: string) => Promise<string>;
77
+ /** Bytes of a file under the day's base. */
78
+ readonly bytes: (path: string) => Promise<Uint8Array>;
79
+ /** Gunzip; defaults to the platform's DecompressionStream. */
80
+ readonly inflate?: (bytes: Uint8Array) => Promise<Uint8Array>;
81
+ }
82
+ /** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
83
+ export declare function sliceNameAt(instant: string | number): string;
84
+ /** The ISO start of a named slice on a date. */
85
+ export declare function sliceStart(date: string, name: string): string;
86
+ /** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
87
+ * arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
88
+ * cell edge lands where the compiler put it. */
89
+ export declare function cellOf(lon: number, lat: number): readonly [number, number];
90
+ export declare function cellKey(cellLon: number, cellLat: number): string;
91
+ /** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
92
+ export declare function partFor(index: DayIndex, operatorRef: string, vehicleRef: string): PackPart | null;
93
+ /** Problems with a compiled day's manifest, empty when consistent: all 24 hours of the date accounted for once, one
94
+ * slice and one members file per slice, and the file list agreeing with the stated file and byte totals (the manifest
95
+ * itself is the one unlisted file). A trimmed fixture, whose statistics describe the whole day, fails by design. */
96
+ export declare function checkDayManifest(manifest: PublishedDayManifest): string[];
97
+ /** Problems reconciling a slice with its members, empty when they agree: the sea and the shoal must count the same
98
+ * vehicles. Each cell's distinct-vehicle count equals its distinct members, no member repeats, and neither file names
99
+ * a cell the other lacks. Samples are not checked here; members carry identities, not sample counts. */
100
+ export declare function reconcileSlice(slice: SliceFile, members: MembersFile): string[];
101
+ /** Position along a track at an instant: linear between the samples around it, null outside the track or across a
102
+ * gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
103
+ export declare function positionAt(track: readonly TrackPoint[], epochSeconds: number, maxGapSeconds?: number): readonly [lon: number, lat: number] | null;
104
+ /** The line and journey a vehicle was on at an instant, from its runs. */
105
+ export declare function journeyAt(journeys: readonly JourneyRun[], epochSeconds: number): JourneyRun | null;
106
+ /** Minimal shapes of the platform pieces used, so the module compiles without DOM typings. */
107
+ interface ByteSource {
108
+ arrayBuffer(): Promise<ArrayBuffer>;
109
+ }
110
+ interface ResponseLike extends ByteSource {
111
+ readonly ok: boolean;
112
+ readonly status: number;
113
+ text(): Promise<string>;
114
+ }
115
+ export type FetchLike = (url: string) => Promise<ResponseLike>;
116
+ export declare function inflateGzip(bytes: Uint8Array): Promise<Uint8Array>;
117
+ /** 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. */
118
+ export declare function inflateIfGzipped(bytes: Uint8Array, inflate: (bytes: Uint8Array) => Promise<Uint8Array>): Promise<Uint8Array>;
119
+ export interface PublishedDay {
120
+ readonly manifest: PublishedDayManifest;
121
+ readonly index: DayIndex;
122
+ readonly slice: (name: string) => Promise<SliceFile>;
123
+ readonly members: (name: string) => Promise<MembersFile>;
124
+ readonly pack: (operatorRef: string, vehicleRef: string) => Promise<Pack>;
125
+ readonly vehicle: (operatorRef: string, vehicleRef: string) => Promise<VehicleDay | null>;
126
+ /** Slice names present, from the manifest's file list, in order. */
127
+ readonly sliceNames: readonly string[];
128
+ }
129
+ /** Open a published day: manifest and index up front, everything else on demand and cached per file. */
130
+ export declare function openPublishedDay(transport: DayTransport): Promise<PublishedDay>;
131
+ /** A transport over fetch for a day served under a base URL. */
132
+ export declare function fetchTransport(base: string, fetchImpl?: FetchLike | undefined): DayTransport;
133
+ 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,70 @@
1
+ /** Off-route runs are stretches of recorded positions more than a stated distance from a journey's own matched
2
+ * pattern path. Events cluster runs by place and time within one service day. A persistent place holds runs on
3
+ * every analysed day across most of the day: a long-running diversion or published geometry that disagrees with
4
+ * the road, which detection alone cannot tell apart. Explanations are joined from a disruption source and
5
+ * remain the publisher's statement, not a verified cause.
6
+ */
7
+ export interface OffRouteRun {
8
+ readonly line: string;
9
+ readonly vehicle: string;
10
+ readonly aimedDeparture: string;
11
+ readonly patternId: string | null;
12
+ readonly start: number;
13
+ readonly end: number;
14
+ readonly maxOffRouteMetres: number;
15
+ readonly between: readonly [from: string, to: string];
16
+ readonly track: readonly (readonly [longitude: number, latitude: number, time: number])[];
17
+ }
18
+ export interface RouteEventExplanation {
19
+ readonly source: 'siri-sx' | 'street-manager' | 'local-knowledge';
20
+ readonly reference: string;
21
+ readonly summary: string;
22
+ readonly planned: boolean | null;
23
+ readonly validity: {
24
+ readonly start: string;
25
+ readonly end: string | null;
26
+ };
27
+ readonly lines: readonly string[];
28
+ }
29
+ export interface RouteEvent {
30
+ readonly id: string;
31
+ readonly serviceDate: string;
32
+ /** Service-day seconds, matching the scene clock. */
33
+ readonly start: number;
34
+ readonly end: number;
35
+ readonly startAt: string;
36
+ readonly endAt: string;
37
+ readonly lines: readonly string[];
38
+ readonly journeys: number;
39
+ readonly runs: readonly OffRouteRun[];
40
+ readonly centroid: readonly [longitude: number, latitude: number];
41
+ readonly maxOffRouteMetres: number;
42
+ readonly recurs: boolean;
43
+ readonly explanations: readonly RouteEventExplanation[];
44
+ }
45
+ export interface PersistentPlace {
46
+ readonly id: string;
47
+ readonly centroid: readonly [longitude: number, latitude: number];
48
+ readonly days: readonly string[];
49
+ readonly lines: readonly string[];
50
+ readonly runs: number;
51
+ readonly explanations: readonly RouteEventExplanation[];
52
+ }
53
+ export interface RouteEventPresenceRule {
54
+ readonly leadSeconds: number;
55
+ readonly fadeSeconds: number;
56
+ }
57
+ export declare const DEFAULT_EVENT_PRESENCE: RouteEventPresenceRule;
58
+ /** 0 before the lead-in, 1 through the event, fading to 0 after it. Lets a scrubbed day show an event appearing and healing. */
59
+ export declare function eventPresenceAt(event: Pick<RouteEvent, 'start' | 'end'>, time: number, rule?: RouteEventPresenceRule): number;
60
+ export declare function eventsPresentAt<T extends Pick<RouteEvent, 'start' | 'end'>>(events: readonly T[], time: number, rule?: RouteEventPresenceRule): readonly {
61
+ readonly event: T;
62
+ readonly presence: number;
63
+ }[];
64
+ /** Width grows with journeys and saturates; single excursions stay thin. */
65
+ export declare function eventEmphasis(event: Pick<RouteEvent, 'journeys'>): {
66
+ readonly width: number;
67
+ readonly labelled: boolean;
68
+ };
69
+ /** Explanations whose validity covers the event's window, by ISO instant; an open end counts as still valid. */
70
+ export declare function explanationsCovering(explanations: readonly RouteEventExplanation[], event: Pick<RouteEvent, 'startAt' | 'endAt'>): readonly RouteEventExplanation[];
@@ -0,0 +1,38 @@
1
+ export const DEFAULT_EVENT_PRESENCE = { leadSeconds: 600, fadeSeconds: 1800 };
2
+ /** 0 before the lead-in, 1 through the event, fading to 0 after it. Lets a scrubbed day show an event appearing and healing. */
3
+ export function eventPresenceAt(event, time, rule = DEFAULT_EVENT_PRESENCE) {
4
+ if (!Number.isFinite(time) || event.end < event.start)
5
+ return 0;
6
+ if (time < event.start) {
7
+ const lead = rule.leadSeconds;
8
+ return lead > 0 && time > event.start - lead ? (time - (event.start - lead)) / lead : 0;
9
+ }
10
+ if (time <= event.end)
11
+ return 1;
12
+ const fade = rule.fadeSeconds;
13
+ return fade > 0 && time < event.end + fade ? 1 - (time - event.end) / fade : 0;
14
+ }
15
+ export function eventsPresentAt(events, time, rule = DEFAULT_EVENT_PRESENCE) {
16
+ const out = [];
17
+ for (const event of events) {
18
+ const presence = eventPresenceAt(event, time, rule);
19
+ if (presence > 0)
20
+ out.push({ event, presence });
21
+ }
22
+ return out.sort((a, b) => b.presence - a.presence || a.event.start - b.event.start);
23
+ }
24
+ /** Width grows with journeys and saturates; single excursions stay thin. */
25
+ export function eventEmphasis(event) {
26
+ const journeys = Math.max(0, Math.floor(event.journeys));
27
+ return journeys < 3 ? { width: 0.6, labelled: false } : { width: 1 + 0.18 * Math.min(journeys, 20), labelled: true };
28
+ }
29
+ /** Explanations whose validity covers the event's window, by ISO instant; an open end counts as still valid. */
30
+ export function explanationsCovering(explanations, event) {
31
+ const start = Date.parse(event.startAt), end = Date.parse(event.endAt);
32
+ if (!Number.isFinite(start) || !Number.isFinite(end))
33
+ return [];
34
+ return explanations.filter(x => {
35
+ const from = Date.parse(x.validity.start), to = x.validity.end === null ? Infinity : Date.parse(x.validity.end);
36
+ return Number.isFinite(from) && !Number.isNaN(to) && from <= end && to >= start;
37
+ });
38
+ }
@@ -0,0 +1,53 @@
1
+ /** A route field is a per-pattern series of bins along a published road path, each holding a ratio of medians
2
+ * over many journeys (peak over off-peak speed, observed over scheduled running time). Values describe the
3
+ * pattern across the stated days and hours, never a single journey, and support counts the sampled intervals
4
+ * that reached the bin. Bins without enough support stay null rather than borrowing a neighbour.
5
+ */
6
+ export type RouteFieldMetric = 'peak-over-off-peak-speed' | 'observed-over-scheduled-running-time';
7
+ export interface RouteFieldBin {
8
+ readonly from: number;
9
+ readonly to: number;
10
+ readonly value: number | null;
11
+ readonly support: number;
12
+ }
13
+ export interface RouteFieldPeriod {
14
+ readonly days: readonly string[];
15
+ readonly hours: readonly (readonly [from: number, to: number])[];
16
+ }
17
+ export interface RouteField {
18
+ readonly id: string;
19
+ readonly line: string;
20
+ readonly patternId: string;
21
+ readonly direction: string;
22
+ readonly metric: RouteFieldMetric;
23
+ readonly binMetres: number;
24
+ readonly minimumSupport: number;
25
+ readonly compared: {
26
+ readonly subject: RouteFieldPeriod;
27
+ readonly baseline: RouteFieldPeriod;
28
+ };
29
+ readonly path: readonly (readonly [longitude: number, latitude: number])[];
30
+ readonly bins: readonly RouteFieldBin[];
31
+ }
32
+ export interface RouteFieldEmphasis {
33
+ readonly width: number;
34
+ readonly glow: number;
35
+ readonly ring: boolean;
36
+ }
37
+ export interface RouteFieldEmphasisRule {
38
+ readonly baseline: number;
39
+ readonly floor: number;
40
+ readonly ring: number;
41
+ readonly glowFrom: number;
42
+ }
43
+ /** Quiet at the baseline, growing as the ratio falls; a bin below `ring` earns a marker. Mock rule of 18 September 2026. */
44
+ export declare const PEAK_SPEED_EMPHASIS: RouteFieldEmphasisRule;
45
+ export declare function routeFieldValue(bin: RouteFieldBin | undefined, minimumSupport: number): number | null;
46
+ /** Null for an unsupported bin; draw it dashed, not as fine. */
47
+ export declare function routeFieldEmphasis(value: number | null, rule?: RouteFieldEmphasisRule): RouteFieldEmphasis | null;
48
+ /** Cumulative metres along a longitude/latitude path on a local equirectangular plane; enough for bins of tens of metres. */
49
+ export declare function pathChainage(path: readonly (readonly [longitude: number, latitude: number])[]): readonly number[];
50
+ /** Point at `metres` along the path; clamped to its ends. Returns null for an empty path. */
51
+ export declare function pointAlongPath(path: readonly (readonly [longitude: number, latitude: number])[], chainage: readonly number[], metres: number): readonly [longitude: number, latitude: number] | null;
52
+ /** Bins whose extent overlaps [from,to) metres, in path order. */
53
+ export declare function routeFieldBinsBetween(field: RouteField, from: number, to: number): readonly RouteFieldBin[];