@motionstudies/core 0.1.0-alpha.3 → 0.1.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -2
- package/air-continents.d.ts +63 -0
- package/air-continents.js +43 -0
- package/air-enrichment.d.ts +63 -0
- package/air-enrichment.js +76 -0
- package/air-search.d.ts +3 -0
- package/domain/aggregate-road.d.ts +48 -0
- package/domain/aggregate-road.js +22 -0
- package/domain/air-day.d.ts +3 -1
- package/domain/air.d.ts +15 -0
- package/domain/airport.d.ts +16 -0
- package/domain/airport.js +23 -0
- package/domain/daylight.d.ts +32 -0
- package/domain/daylight.js +32 -0
- package/domain/field-paths.d.ts +35 -0
- package/domain/field-paths.js +43 -0
- package/domain/gridded-series.d.ts +30 -0
- package/domain/gridded-series.js +61 -0
- package/domain/live-airport.d.ts +42 -0
- package/domain/live-airport.js +32 -0
- package/domain/movement-board.d.ts +21 -0
- package/domain/movement-board.js +23 -0
- package/domain/network-patterns.d.ts +19 -0
- package/domain/network-patterns.js +73 -0
- package/domain/network.js +37 -1
- package/domain/power-day.d.ts +84 -0
- package/domain/power-day.js +104 -0
- package/domain/published-day.d.ts +133 -0
- package/domain/published-day.js +162 -0
- package/domain/route-events.d.ts +70 -0
- package/domain/route-events.js +38 -0
- package/domain/route-field.d.ts +53 -0
- package/domain/route-field.js +47 -0
- package/domain/station-calls.d.ts +48 -0
- package/domain/station-calls.js +73 -0
- package/domain/station-departures.d.ts +15 -0
- package/domain/station-departures.js +25 -0
- package/domain/supported-field.d.ts +78 -0
- package/domain/supported-field.js +95 -0
- package/domain/vehicle-calls.d.ts +11 -0
- package/domain/vehicle-calls.js +14 -0
- package/domain/vehicle-counts.d.ts +13 -0
- package/domain/vehicle-counts.js +33 -0
- package/map-labels.d.ts +24 -0
- package/map-labels.js +48 -0
- package/package.json +108 -1
- package/render-frame.d.ts +8 -0
- package/render-frame.js +13 -0
- package/timeline.d.ts +31 -0
- package/timeline.js +68 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function aggregateRoadValue(sample, metric) {
|
|
2
|
+
const value = metric === 'vehicleCount' ? sample?.vehicleCount : sample?.averageSpeed?.value;
|
|
3
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && (metric !== 'vehicleCount' || Number.isInteger(value)) ? value : null;
|
|
4
|
+
}
|
|
5
|
+
/** Compile once per immutable series. Conflicting or duplicate slots remain unavailable.
|
|
6
|
+
* No interpolation, nearest-slot fallback or conversion between provider and UTC clocks.
|
|
7
|
+
*/
|
|
8
|
+
export function indexAggregateRoadSlots(series, serviceDate) {
|
|
9
|
+
const result = new Map(), conflicts = new Set();
|
|
10
|
+
for (const sample of series.samples) {
|
|
11
|
+
const interval = sample.interval;
|
|
12
|
+
if (interval.kind !== 'provider-slot' || interval.serviceDate !== serviceDate || !Number.isSafeInteger(interval.index) || interval.index < 0)
|
|
13
|
+
continue;
|
|
14
|
+
if (result.has(interval.index)) {
|
|
15
|
+
result.delete(interval.index);
|
|
16
|
+
conflicts.add(interval.index);
|
|
17
|
+
}
|
|
18
|
+
else if (!conflicts.has(interval.index))
|
|
19
|
+
result.set(interval.index, sample);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
package/domain/air-day.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AirSnapshot, AirTrack } from './air.ts';
|
|
1
|
+
import type { AirEndpoint, AirSnapshot, AirTrack } from './air.ts';
|
|
2
2
|
export interface AirDayAircraft {
|
|
3
3
|
readonly id: string;
|
|
4
4
|
readonly icaoAddress: string;
|
|
@@ -6,6 +6,8 @@ export interface AirDayAircraft {
|
|
|
6
6
|
readonly start: number;
|
|
7
7
|
readonly end: number;
|
|
8
8
|
readonly airportIds?: readonly string[];
|
|
9
|
+
readonly origin?: AirEndpoint;
|
|
10
|
+
readonly destination?: AirEndpoint;
|
|
9
11
|
readonly chunkIds: readonly string[];
|
|
10
12
|
}
|
|
11
13
|
export interface AirDayChunkDescriptor {
|
package/domain/air.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AirContinent, AirContinentSource } from '../air-continents.ts';
|
|
1
2
|
export type AirSample = readonly [
|
|
2
3
|
time: number,
|
|
3
4
|
longitude: number,
|
|
@@ -5,6 +6,18 @@ export type AirSample = readonly [
|
|
|
5
6
|
altitudeFeet: number,
|
|
6
7
|
groundSpeedKnots: number
|
|
7
8
|
];
|
|
9
|
+
export interface AirEndpoint {
|
|
10
|
+
readonly icao: string;
|
|
11
|
+
readonly iata: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly city: string;
|
|
14
|
+
/** Study-local seconds at the observed approach/departure boundary; not a scheduled time. */
|
|
15
|
+
readonly time: number;
|
|
16
|
+
readonly evidence: 'observed-endpoint';
|
|
17
|
+
/** Airport geography, separate from the inferred route evidence. Absent in older releases. */
|
|
18
|
+
readonly continent?: AirContinent;
|
|
19
|
+
readonly continentSource?: AirContinentSource;
|
|
20
|
+
}
|
|
8
21
|
export interface AirTrack {
|
|
9
22
|
readonly id: string;
|
|
10
23
|
readonly icaoAddress?: string;
|
|
@@ -12,6 +25,8 @@ export interface AirTrack {
|
|
|
12
25
|
readonly start: number;
|
|
13
26
|
readonly end: number;
|
|
14
27
|
readonly airportIds?: readonly string[];
|
|
28
|
+
readonly origin?: AirEndpoint;
|
|
29
|
+
readonly destination?: AirEndpoint;
|
|
15
30
|
readonly samples: readonly AirSample[];
|
|
16
31
|
}
|
|
17
32
|
export interface AirSnapshot {
|
package/domain/airport.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AirTrack } from './air.ts';
|
|
2
|
+
import type { AirSearchTrack } from '../air-search.ts';
|
|
2
3
|
export interface StudyAirport {
|
|
3
4
|
readonly id: string;
|
|
4
5
|
readonly name: string;
|
|
@@ -15,3 +16,18 @@ export declare function airportSearchText(airport: StudyAirport): string;
|
|
|
15
16
|
export declare function searchAirports(airports: readonly StudyAirport[], searchQuery: string, limit?: number): readonly StudyAirport[];
|
|
16
17
|
export declare function airTrackServesAirport(track: AirTrack, airport: StudyAirport): boolean;
|
|
17
18
|
export declare function airportAirTrackIds(tracks: readonly AirTrack[], airport: StudyAirport): ReadonlySet<string>;
|
|
19
|
+
/** Board events come from full-flight endpoint evidence, not truncated playback chunks. */
|
|
20
|
+
export declare function airportBoardMovements(aircraft: readonly AirSearchTrack[], airport: StudyAirport): {
|
|
21
|
+
departures: {
|
|
22
|
+
id: string;
|
|
23
|
+
time: number;
|
|
24
|
+
service: string;
|
|
25
|
+
place?: string;
|
|
26
|
+
}[];
|
|
27
|
+
arrivals: {
|
|
28
|
+
id: string;
|
|
29
|
+
time: number;
|
|
30
|
+
service: string;
|
|
31
|
+
place?: string;
|
|
32
|
+
}[];
|
|
33
|
+
};
|
package/domain/airport.js
CHANGED
|
@@ -39,3 +39,26 @@ export function airportAirTrackIds(tracks, airport) {
|
|
|
39
39
|
.filter((track) => airTrackServesAirport(track, airport))
|
|
40
40
|
.map((track) => track.id));
|
|
41
41
|
}
|
|
42
|
+
/** Board events come from full-flight endpoint evidence, not truncated playback chunks. */
|
|
43
|
+
export function airportBoardMovements(aircraft, airport) {
|
|
44
|
+
const departures = [];
|
|
45
|
+
const arrivals = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const track of aircraft) {
|
|
48
|
+
for (const direction of ['origin', 'destination']) {
|
|
49
|
+
const endpoint = track[direction];
|
|
50
|
+
if (!endpoint || endpoint.icao !== airport.icao || !Number.isFinite(endpoint.time))
|
|
51
|
+
continue;
|
|
52
|
+
const key = `${track.icaoAddress ?? track.id}:${direction}:${endpoint.time}`;
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
const other = direction === 'origin' ? track.destination : track.origin;
|
|
57
|
+
const row = { id: track.id, time: endpoint.time, service: track.callsign,
|
|
58
|
+
place: other ? `${other.city || other.name} ${other.iata || other.icao}` : undefined };
|
|
59
|
+
const rows = direction === 'origin' ? departures : arrivals;
|
|
60
|
+
rows.push(row);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { departures, arrivals };
|
|
64
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Sun position and smooth lighting envelopes for a place and instant. The position follows the
|
|
2
|
+
* low-precision solar algorithm published by Astronomy Answers (as popularised by SunCalc), good
|
|
3
|
+
* to a fraction of a degree: enough to light a scene and place a terminator, not for astronomy.
|
|
4
|
+
* The envelopes are artistic colour responses to sun altitude; they encode no weather or sky observation.
|
|
5
|
+
*/
|
|
6
|
+
export interface SolarPosition {
|
|
7
|
+
/** Degrees above the horizon; negative below. */
|
|
8
|
+
readonly altitude: number;
|
|
9
|
+
/** Degrees clockwise from north. */
|
|
10
|
+
readonly azimuth: number;
|
|
11
|
+
}
|
|
12
|
+
/** Sun-altitude thresholds in degrees. Each envelope rises smoothly between its bounds; warmth also falls again between its last two. */
|
|
13
|
+
export interface DaylightEnvelopes {
|
|
14
|
+
readonly twilight: readonly [from: number, to: number];
|
|
15
|
+
readonly warmth: readonly [from: number, to: number, fallFrom: number, fallTo: number];
|
|
16
|
+
readonly daylight: readonly [from: number, to: number];
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_DAYLIGHT_ENVELOPES: DaylightEnvelopes;
|
|
19
|
+
export interface Daylight extends SolarPosition {
|
|
20
|
+
/** Unit vector towards the sun in a scene frame with +x east, +y up and +z south. */
|
|
21
|
+
readonly direction: readonly [number, number, number];
|
|
22
|
+
/** 0 in full night, 1 once the sun nears the horizon. */
|
|
23
|
+
readonly twilight: number;
|
|
24
|
+
/** Peaks around sunrise and sunset, 0 at night and in full day. */
|
|
25
|
+
readonly warmth: number;
|
|
26
|
+
/** 0 below the horizon, 1 once the sun is well up. */
|
|
27
|
+
readonly daylight: number;
|
|
28
|
+
}
|
|
29
|
+
/** Sun altitude and azimuth for an instant, or null for an invalid instant or place. */
|
|
30
|
+
export declare function solarPosition(time: number | string | Date, latitude: number, longitude: number): SolarPosition | null;
|
|
31
|
+
/** Sun direction and lighting envelopes for a place and instant, or null for an invalid instant or place. */
|
|
32
|
+
export declare function daylightAt(time: number | string | Date, latitude: number, longitude: number, envelopes?: DaylightEnvelopes): Daylight | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const DEFAULT_DAYLIGHT_ENVELOPES = { twilight: [-18, -1], warmth: [-12, -1, 3, 15], daylight: [-1, 12] };
|
|
2
|
+
const rad = Math.PI / 180, dayMs = 86_400_000, J1970 = 2440588, J2000 = 2451545, obliquity = rad * 23.4397;
|
|
3
|
+
const smooth = (a, b, value) => { const t = Math.max(0, Math.min(1, (value - a) / (b - a))); return t * t * (3 - 2 * t); };
|
|
4
|
+
const instant = (time) => typeof time === 'number' ? time : time instanceof Date ? time.getTime() : Date.parse(time);
|
|
5
|
+
/** Sun altitude and azimuth for an instant, or null for an invalid instant or place. */
|
|
6
|
+
export function solarPosition(time, latitude, longitude) {
|
|
7
|
+
const ms = instant(time);
|
|
8
|
+
if (!Number.isFinite(ms) || !Number.isFinite(latitude) || !Number.isFinite(longitude))
|
|
9
|
+
return null;
|
|
10
|
+
const days = ms / dayMs - .5 + J1970 - J2000, phi = rad * latitude;
|
|
11
|
+
const anomaly = rad * (357.5291 + .98560028 * days);
|
|
12
|
+
const eclipticLongitude = anomaly + rad * (1.9148 * Math.sin(anomaly) + .02 * Math.sin(2 * anomaly) + .0003 * Math.sin(3 * anomaly)) + rad * 102.9372 + Math.PI;
|
|
13
|
+
const declination = Math.asin(Math.sin(obliquity) * Math.sin(eclipticLongitude));
|
|
14
|
+
const rightAscension = Math.atan2(Math.sin(eclipticLongitude) * Math.cos(obliquity), Math.cos(eclipticLongitude));
|
|
15
|
+
const hourAngle = rad * (280.16 + 360.9856235 * days) + rad * longitude - rightAscension;
|
|
16
|
+
const altitude = Math.asin(Math.sin(phi) * Math.sin(declination) + Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle));
|
|
17
|
+
const fromSouth = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(declination) * Math.cos(phi));
|
|
18
|
+
return { altitude: altitude / rad, azimuth: ((fromSouth / rad + 180) % 360 + 360) % 360 };
|
|
19
|
+
}
|
|
20
|
+
/** Sun direction and lighting envelopes for a place and instant, or null for an invalid instant or place. */
|
|
21
|
+
export function daylightAt(time, latitude, longitude, envelopes = DEFAULT_DAYLIGHT_ENVELOPES) {
|
|
22
|
+
const position = solarPosition(time, latitude, longitude);
|
|
23
|
+
if (!position)
|
|
24
|
+
return null;
|
|
25
|
+
const altitude = position.altitude * rad, fromSouth = (position.azimuth - 180) * rad;
|
|
26
|
+
const [tw0, tw1] = envelopes.twilight, [w0, w1, w2, w3] = envelopes.warmth, [d0, d1] = envelopes.daylight;
|
|
27
|
+
return { ...position,
|
|
28
|
+
direction: [-Math.sin(fromSouth) * Math.cos(altitude), Math.sin(altitude), Math.cos(fromSouth) * Math.cos(altitude)],
|
|
29
|
+
twilight: smooth(tw0, tw1, position.altitude),
|
|
30
|
+
warmth: smooth(w0, w1, position.altitude) * (1 - smooth(w2, w3, position.altitude)),
|
|
31
|
+
daylight: smooth(d0, d1, position.altitude) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Paths integrated through an estimated velocity field. They illustrate the field; they are not
|
|
2
|
+
* observed trajectories of anything. Integration refuses to start from, pass through or land on
|
|
3
|
+
* unsupported field, so a trail ends at a gap instead of crossing it.
|
|
4
|
+
*/
|
|
5
|
+
export interface FieldPoint {
|
|
6
|
+
readonly latitude: number;
|
|
7
|
+
readonly longitude: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FieldMovement {
|
|
10
|
+
readonly value: number;
|
|
11
|
+
readonly vector: readonly [east: number, north: number];
|
|
12
|
+
readonly support: number;
|
|
13
|
+
}
|
|
14
|
+
/** Reads the field at a place and study time; null or undefined where it is unavailable. */
|
|
15
|
+
export type FieldMovementSample = (point: FieldPoint, time: number) => FieldMovement | null | undefined;
|
|
16
|
+
export interface FieldTrack {
|
|
17
|
+
/** Study time of the first point. */
|
|
18
|
+
readonly start: number;
|
|
19
|
+
readonly level: number;
|
|
20
|
+
/** One point per unit step: latitude, longitude and any further channels to interpolate. */
|
|
21
|
+
readonly points: readonly (readonly number[])[];
|
|
22
|
+
}
|
|
23
|
+
export interface TrailSegment {
|
|
24
|
+
readonly a: readonly number[];
|
|
25
|
+
readonly b: readonly number[];
|
|
26
|
+
readonly tail: number;
|
|
27
|
+
readonly fade: number;
|
|
28
|
+
readonly level: number;
|
|
29
|
+
}
|
|
30
|
+
/** Linear blend of two readings. Either side unavailable makes the blend unavailable except at its exact ends. */
|
|
31
|
+
export declare function mixFieldMovement(a: FieldMovement | null | undefined, b: FieldMovement | null | undefined, fraction: number): FieldMovement | null;
|
|
32
|
+
/** Explicit midpoint step. Vectors are metres per second; `secondsPerStep` converts one unit of study time. */
|
|
33
|
+
export declare function advanceFieldPath(point: FieldPoint, time: number, step: number, sample: FieldMovementSample, secondsPerStep?: number): (FieldPoint & FieldMovement) | null;
|
|
34
|
+
/** The part of a track visible at a study time: the last `trail` steps, with a fade at either end of the track. Scrubbing back restores identical segments. */
|
|
35
|
+
export declare function visibleTrailSegments(track: FieldTrack, index: number, trail?: number): TrailSegment[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const rad = Math.PI / 180, kmPerDegree = 6371.0088 * rad;
|
|
2
|
+
const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
|
|
3
|
+
/** Linear blend of two readings. Either side unavailable makes the blend unavailable except at its exact ends. */
|
|
4
|
+
export function mixFieldMovement(a, b, fraction) {
|
|
5
|
+
if (fraction === 0)
|
|
6
|
+
return a ?? null;
|
|
7
|
+
if (fraction === 1)
|
|
8
|
+
return b ?? null;
|
|
9
|
+
if (!a || !b)
|
|
10
|
+
return null;
|
|
11
|
+
const mix = (x, y) => x * (1 - fraction) + y * fraction;
|
|
12
|
+
return { value: mix(a.value, b.value), vector: [mix(a.vector[0], b.vector[0]), mix(a.vector[1], b.vector[1])], support: mix(a.support, b.support) };
|
|
13
|
+
}
|
|
14
|
+
const displace = (p, v, seconds) => ({
|
|
15
|
+
latitude: p.latitude + v.vector[1] * seconds / 1000 / kmPerDegree,
|
|
16
|
+
longitude: p.longitude + v.vector[0] * seconds / 1000 / (kmPerDegree * Math.cos(p.latitude * rad)),
|
|
17
|
+
});
|
|
18
|
+
/** Explicit midpoint step. Vectors are metres per second; `secondsPerStep` converts one unit of study time. */
|
|
19
|
+
export function advanceFieldPath(point, time, step, sample, secondsPerStep = 300) {
|
|
20
|
+
const a = sample(point, time);
|
|
21
|
+
if (!a || a.support <= 0)
|
|
22
|
+
return null;
|
|
23
|
+
const mid = displace(point, a, step * secondsPerStep / 2), b = sample(mid, time + step / 2);
|
|
24
|
+
if (!b || b.support <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
const next = displace(point, b, step * secondsPerStep), value = sample(next, time + step);
|
|
27
|
+
return value && value.support > 0 ? { ...next, ...value } : null;
|
|
28
|
+
}
|
|
29
|
+
/** The part of a track visible at a study time: the last `trail` steps, with a fade at either end of the track. Scrubbing back restores identical segments. */
|
|
30
|
+
export function visibleTrailSegments(track, index, trail = 15) {
|
|
31
|
+
const end = track.start + track.points.length - 1;
|
|
32
|
+
if (index <= track.start || index >= end)
|
|
33
|
+
return [];
|
|
34
|
+
const fade = smooth((index - track.start) / 2) * smooth((end - index) / 3), segments = [];
|
|
35
|
+
for (let i = Math.max(0, Math.floor(index - trail - track.start)); i < track.points.length - 1; i++) {
|
|
36
|
+
const t = track.start + i, a = Math.max(t, index - trail), b = Math.min(t + 1, index);
|
|
37
|
+
if (b <= a)
|
|
38
|
+
continue;
|
|
39
|
+
const p = track.points[i], q = track.points[i + 1], at = (f) => p.map((v, k) => v * (1 - f) + q[k] * f);
|
|
40
|
+
segments.push({ a: at(a - t), b: at(b - t), tail: Math.max(0, 1 - (index - (a + b) / 2) / trail), fade, level: track.level });
|
|
41
|
+
}
|
|
42
|
+
return segments;
|
|
43
|
+
}
|
|
@@ -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
|
-
|
|
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);
|