@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,47 @@
|
|
|
1
|
+
/** Quiet at the baseline, growing as the ratio falls; a bin below `ring` earns a marker. Mock rule of 18 September 2026. */
|
|
2
|
+
export const PEAK_SPEED_EMPHASIS = { baseline: 1, floor: 0.4, ring: 0.6, glowFrom: 0.15 };
|
|
3
|
+
export function routeFieldValue(bin, minimumSupport) {
|
|
4
|
+
if (!bin || bin.value === null || !Number.isFinite(bin.value) || bin.value < 0)
|
|
5
|
+
return null;
|
|
6
|
+
return Number.isFinite(bin.support) && bin.support >= minimumSupport ? bin.value : null;
|
|
7
|
+
}
|
|
8
|
+
/** Null for an unsupported bin; draw it dashed, not as fine. */
|
|
9
|
+
export function routeFieldEmphasis(value, rule = PEAK_SPEED_EMPHASIS) {
|
|
10
|
+
if (value === null || !Number.isFinite(value))
|
|
11
|
+
return null;
|
|
12
|
+
const span = rule.baseline - rule.floor;
|
|
13
|
+
const slow = span > 0 ? Math.min(1, Math.max(0, (rule.baseline - value) / span)) : 0;
|
|
14
|
+
return { width: 1 + 1.6 * slow, glow: slow > rule.glowFrom ? slow : 0, ring: value < rule.ring };
|
|
15
|
+
}
|
|
16
|
+
const EARTH_METRES_PER_DEGREE = 111_320;
|
|
17
|
+
/** Cumulative metres along a longitude/latitude path on a local equirectangular plane; enough for bins of tens of metres. */
|
|
18
|
+
export function pathChainage(path) {
|
|
19
|
+
const out = [0];
|
|
20
|
+
if (path.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
for (let i = 1; i < path.length; i++) {
|
|
23
|
+
const [lon0, lat0] = path[i - 1], [lon1, lat1] = path[i];
|
|
24
|
+
const scale = Math.cos(((lat0 + lat1) / 2) * Math.PI / 180);
|
|
25
|
+
out.push(out[i - 1] + Math.hypot((lon1 - lon0) * EARTH_METRES_PER_DEGREE * scale, (lat1 - lat0) * EARTH_METRES_PER_DEGREE));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** Point at `metres` along the path; clamped to its ends. Returns null for an empty path. */
|
|
30
|
+
export function pointAlongPath(path, chainage, metres) {
|
|
31
|
+
if (path.length === 0 || chainage.length !== path.length)
|
|
32
|
+
return null;
|
|
33
|
+
if (path.length === 1 || metres <= 0)
|
|
34
|
+
return path[0];
|
|
35
|
+
const end = chainage[chainage.length - 1];
|
|
36
|
+
if (metres >= end)
|
|
37
|
+
return path[path.length - 1];
|
|
38
|
+
let hi = 1;
|
|
39
|
+
while (chainage[hi] < metres)
|
|
40
|
+
hi++;
|
|
41
|
+
const lo = hi - 1, span = chainage[hi] - chainage[lo], f = span > 0 ? (metres - chainage[lo]) / span : 0;
|
|
42
|
+
return [path[lo][0] + (path[hi][0] - path[lo][0]) * f, path[lo][1] + (path[hi][1] - path[lo][1]) * f];
|
|
43
|
+
}
|
|
44
|
+
/** Bins whose extent overlaps [from,to) metres, in path order. */
|
|
45
|
+
export function routeFieldBinsBetween(field, from, to) {
|
|
46
|
+
return field.bins.filter(bin => bin.to > from && bin.from < to);
|
|
47
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NetworkSnapshot, NetworkTrain } from './network.ts';
|
|
2
|
+
export type StationCallDirection = 'arrival' | 'departure';
|
|
3
|
+
export interface StationCall {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly train: NetworkTrain;
|
|
6
|
+
/** Call occurrence within this train, not a global station index. */
|
|
7
|
+
readonly index: number;
|
|
8
|
+
readonly arrival: number;
|
|
9
|
+
readonly departure: number;
|
|
10
|
+
readonly origin: string;
|
|
11
|
+
readonly destination: string;
|
|
12
|
+
readonly allowsArrival: boolean;
|
|
13
|
+
readonly allowsDeparture: boolean;
|
|
14
|
+
readonly movementAvailable?: boolean;
|
|
15
|
+
readonly source?: string;
|
|
16
|
+
readonly windowStart?: number;
|
|
17
|
+
readonly windowEnd?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface StationCallSelection {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** When supplied, source IDs are authoritative; never fall back to a name match. */
|
|
22
|
+
readonly stopIds?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
export interface StationCallPolicy {
|
|
25
|
+
readonly includeTrain?: (train: NetworkTrain) => boolean;
|
|
26
|
+
readonly allows?: (train: NetworkTrain, callIndex: number, direction: StationCallDirection) => boolean;
|
|
27
|
+
readonly origin?: (train: NetworkTrain, snapshot: NetworkSnapshot) => string | undefined;
|
|
28
|
+
readonly destination?: (train: NetworkTrain, snapshot: NetworkSnapshot) => string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Retain repeated visits and source-local indexes; passenger restrictions belong to the adapter. */
|
|
31
|
+
export declare function stationCalls(snapshot: NetworkSnapshot, selection: StationCallSelection, policy?: StationCallPolicy): readonly StationCall[];
|
|
32
|
+
export interface StationCallSource {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly selection: StationCallSelection;
|
|
35
|
+
readonly snapshot?: NetworkSnapshot;
|
|
36
|
+
readonly movements?: NetworkSnapshot;
|
|
37
|
+
readonly windowStart: number;
|
|
38
|
+
readonly windowEnd: number;
|
|
39
|
+
readonly policy?: StationCallPolicy;
|
|
40
|
+
readonly loading?: boolean;
|
|
41
|
+
readonly error?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export type StationSourceStatus = 'loading' | 'error' | 'unavailable' | 'date-mismatch' | 'partial' | 'ready';
|
|
44
|
+
export declare function stationSourceStatus(source: StationCallSource, serviceDate: string, requestedStart: number, requestedEnd: number): StationSourceStatus;
|
|
45
|
+
/** Sources remain independently dated and retryable. Equal names/times never merge services. */
|
|
46
|
+
export declare function combinedStationCalls(serviceDate: string, sources: readonly StationCallSource[]): readonly StationCall[];
|
|
47
|
+
/** Clip to both requested and source windows. Never wrap to an unacquired day. */
|
|
48
|
+
export declare function upcomingStationCalls(calls: readonly StationCall[], direction: StationCallDirection, time: number, windowStart: number, windowEnd: number, route?: string, maxRows?: number): readonly StationCall[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Retain repeated visits and source-local indexes; passenger restrictions belong to the adapter. */
|
|
2
|
+
export function stationCalls(snapshot, selection, policy = {}) {
|
|
3
|
+
const stops = new Set(snapshot.stops.flatMap((stop, index) => (selection.stopIds ? Boolean(stop[4] && selection.stopIds.includes(stop[4])) : stop[2] === selection.name) ? [index] : []));
|
|
4
|
+
const calls = [], ids = new Map();
|
|
5
|
+
for (const train of snapshot.trains) {
|
|
6
|
+
if (policy.includeTrain && !policy.includeTrain(train))
|
|
7
|
+
continue;
|
|
8
|
+
const prior = ids.get(train.id);
|
|
9
|
+
if (prior) {
|
|
10
|
+
if (prior === train || JSON.stringify(prior) === JSON.stringify(train))
|
|
11
|
+
continue;
|
|
12
|
+
throw new Error('Ambiguous duplicate journey identity in station source');
|
|
13
|
+
}
|
|
14
|
+
ids.set(train.id, train);
|
|
15
|
+
train.stops.forEach(([stop, arrival, departure], index) => {
|
|
16
|
+
if (!stops.has(stop) || !Number.isFinite(arrival) || !Number.isFinite(departure) || departure < arrival)
|
|
17
|
+
return;
|
|
18
|
+
const allowsArrival = index > 0 && (policy.allows?.(train, index, 'arrival') ?? true);
|
|
19
|
+
const allowsDeparture = index < train.stops.length - 1 && (policy.allows?.(train, index, 'departure') ?? true);
|
|
20
|
+
if (!allowsArrival && !allowsDeparture)
|
|
21
|
+
return;
|
|
22
|
+
calls.push({ id: `${train.id}:call:${index}`, train, index, arrival, departure,
|
|
23
|
+
origin: policy.origin?.(train, snapshot) ?? snapshot.stops[train.stops[0][0]]?.[2] ?? '',
|
|
24
|
+
destination: policy.destination?.(train, snapshot) ?? (train.headsign || snapshot.stops[train.stops.at(-1)[0]]?.[2] || ''),
|
|
25
|
+
allowsArrival, allowsDeparture, });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return calls;
|
|
29
|
+
}
|
|
30
|
+
export function stationSourceStatus(source, serviceDate, requestedStart, requestedEnd) {
|
|
31
|
+
if (source.error)
|
|
32
|
+
return 'error';
|
|
33
|
+
if (source.loading)
|
|
34
|
+
return 'loading';
|
|
35
|
+
if (!source.snapshot || ![source.windowStart, source.windowEnd, requestedStart, requestedEnd].every(Number.isFinite) ||
|
|
36
|
+
source.windowEnd <= source.windowStart || requestedEnd < requestedStart)
|
|
37
|
+
return 'unavailable';
|
|
38
|
+
if (source.snapshot.metadata.serviceDate !== serviceDate)
|
|
39
|
+
return 'date-mismatch';
|
|
40
|
+
return source.windowStart > requestedStart || source.windowEnd < requestedEnd ? 'partial' : 'ready';
|
|
41
|
+
}
|
|
42
|
+
/** Sources remain independently dated and retryable. Equal names/times never merge services. */
|
|
43
|
+
export function combinedStationCalls(serviceDate, sources) {
|
|
44
|
+
const result = [], ids = new Set();
|
|
45
|
+
for (const source of sources) {
|
|
46
|
+
if (!source.id || ids.has(source.id))
|
|
47
|
+
throw new Error('Station sources require distinct identities');
|
|
48
|
+
ids.add(source.id);
|
|
49
|
+
if (!source.snapshot || source.error || source.loading || source.snapshot.metadata.serviceDate !== serviceDate ||
|
|
50
|
+
!Number.isFinite(source.windowStart) || !Number.isFinite(source.windowEnd) || source.windowEnd <= source.windowStart)
|
|
51
|
+
continue;
|
|
52
|
+
const movementIds = source.movements?.metadata.serviceDate === serviceDate
|
|
53
|
+
? new Set(source.movements.trains.map(train => train.id)) : undefined;
|
|
54
|
+
for (const call of stationCalls(source.snapshot, source.selection, source.policy)) {
|
|
55
|
+
result.push({ ...call, id: `${encodeURIComponent(source.id)}:${call.id}`, source: source.id,
|
|
56
|
+
movementAvailable: source.movements ? (movementIds?.has(call.train.id) ?? false) : undefined,
|
|
57
|
+
windowStart: source.windowStart, windowEnd: source.windowEnd,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/** Clip to both requested and source windows. Never wrap to an unacquired day. */
|
|
64
|
+
export function upcomingStationCalls(calls, direction, time, windowStart, windowEnd, route = '', maxRows = 4) {
|
|
65
|
+
if (![time, windowStart, windowEnd].every(Number.isFinite) || time < windowStart || time >= windowEnd || !Number.isSafeInteger(maxRows) || maxRows < 1)
|
|
66
|
+
return [];
|
|
67
|
+
const end = Math.min(windowEnd, time + 3600);
|
|
68
|
+
return calls.filter(call => (direction === 'arrival' ? call.allowsArrival : call.allowsDeparture)
|
|
69
|
+
&& call[direction] >= Math.max(windowStart, call.windowStart ?? windowStart)
|
|
70
|
+
&& call[direction] < Math.min(windowEnd, call.windowEnd ?? windowEnd)
|
|
71
|
+
&& (!route || call.train.route === route) && call[direction] >= time && call[direction] < end)
|
|
72
|
+
.sort((a, b) => a[direction] - b[direction] || a.id.localeCompare(b.id)).slice(0, maxRows);
|
|
73
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NetworkSnapshot, ServiceCategory } from './network.ts';
|
|
2
|
+
export interface StationDeparture {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly trainId: string;
|
|
5
|
+
/** Time from the supplied snapshot: realtime-adjusted snapshots contain adjusted times. */
|
|
6
|
+
readonly time: number;
|
|
7
|
+
readonly service: string;
|
|
8
|
+
readonly category: ServiceCategory;
|
|
9
|
+
readonly destination: string;
|
|
10
|
+
readonly platform?: string;
|
|
11
|
+
readonly via: readonly string[];
|
|
12
|
+
readonly status: 'scheduled' | 'adjusted' | 'cancelled';
|
|
13
|
+
}
|
|
14
|
+
/** Resolve the station against this snapshot, retaining repeat calls but excluding terminators. */
|
|
15
|
+
export declare function stationDepartures(snapshot: NetworkSnapshot, stationName: string): readonly StationDeparture[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Resolve the station against this snapshot, retaining repeat calls but excluding terminators. */
|
|
2
|
+
export function stationDepartures(snapshot, stationName) {
|
|
3
|
+
const stopIndexes = new Set();
|
|
4
|
+
snapshot.stops.forEach((stop, index) => { if (stop[2] === stationName)
|
|
5
|
+
stopIndexes.add(index); });
|
|
6
|
+
if (!stopIndexes.size)
|
|
7
|
+
return [];
|
|
8
|
+
const departures = [];
|
|
9
|
+
for (const train of snapshot.trains) {
|
|
10
|
+
train.stops.forEach(([stopIndex, , departure], callIndex) => {
|
|
11
|
+
if (!stopIndexes.has(stopIndex) || !Number.isFinite(departure) || callIndex === train.stops.length - 1)
|
|
12
|
+
return;
|
|
13
|
+
const onward = train.stops.slice(callIndex + 1).map(([index]) => snapshot.stops[index]?.[2]).filter((name) => Boolean(name));
|
|
14
|
+
if (!onward.length)
|
|
15
|
+
return;
|
|
16
|
+
const destination = train.headsign.trim() || onward.at(-1);
|
|
17
|
+
departures.push({ id: `${train.id}:${callIndex}`, trainId: train.id, time: departure,
|
|
18
|
+
service: train.route || train.shortName, category: train.category, destination,
|
|
19
|
+
platform: snapshot.stops[stopIndex]?.[3]?.trim() || undefined,
|
|
20
|
+
via: [...new Set(onward.slice(0, -1).filter((name) => name !== stationName && name !== destination))].slice(0, 3),
|
|
21
|
+
status: train.realtime?.status ?? 'scheduled' });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return departures.sort((a, b) => a.time - b.time || a.id.localeCompare(b.id));
|
|
25
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** A continuous estimate between scattered instruments. Values are reconstructed from weighted
|
|
2
|
+
* neighbouring stations; `support` reports how near the closest contributing evidence is, so a
|
|
3
|
+
* rendering can fade where its evidence weakens. The estimate is a model choice, not an observation
|
|
4
|
+
* at the sampled place, and missing station values stay missing rather than becoming zero.
|
|
5
|
+
*/
|
|
6
|
+
export interface FieldStation {
|
|
7
|
+
readonly latitude: number;
|
|
8
|
+
readonly longitude: number;
|
|
9
|
+
}
|
|
10
|
+
/** Kilometre scales of the neighbour kernel and the support fade. */
|
|
11
|
+
export interface FieldSupportOptions {
|
|
12
|
+
/** Gaussian scale of neighbour weights. */
|
|
13
|
+
readonly scaleKm: number;
|
|
14
|
+
/** Weights taper smoothly from `reachStartKm` to zero at `reachKm`; stations beyond `reachKm` do not contribute. */
|
|
15
|
+
readonly reachStartKm: number;
|
|
16
|
+
readonly reachKm: number;
|
|
17
|
+
/** Support is 1 within `supportNearKm` of the nearest contributing station and fades to 0 at `supportFarKm`. */
|
|
18
|
+
readonly supportNearKm: number;
|
|
19
|
+
readonly supportFarKm: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_FIELD_SUPPORT: FieldSupportOptions;
|
|
22
|
+
export interface FieldNeighbour {
|
|
23
|
+
readonly index: number;
|
|
24
|
+
readonly distanceKm: number;
|
|
25
|
+
readonly weight: number;
|
|
26
|
+
}
|
|
27
|
+
/** One station's contribution at one instant. A null value excludes the station; an incomplete vector excludes it from the vector estimate only. */
|
|
28
|
+
export interface FieldSample {
|
|
29
|
+
readonly value: number | null;
|
|
30
|
+
readonly vector?: readonly [east: number, north: number] | null;
|
|
31
|
+
}
|
|
32
|
+
export interface FieldEstimate {
|
|
33
|
+
readonly value: number | null;
|
|
34
|
+
readonly vector: readonly [east: number, north: number] | null;
|
|
35
|
+
/** Fade from the nearest station that supplied a value. */
|
|
36
|
+
readonly support: number;
|
|
37
|
+
/** Fade from the nearest station that supplied a complete vector; 0 when none did. */
|
|
38
|
+
readonly vectorSupport: number;
|
|
39
|
+
}
|
|
40
|
+
export interface FieldGridBounds {
|
|
41
|
+
readonly west: number;
|
|
42
|
+
readonly east: number;
|
|
43
|
+
readonly south: number;
|
|
44
|
+
readonly north: number;
|
|
45
|
+
readonly step: number;
|
|
46
|
+
}
|
|
47
|
+
export interface FieldGridCell {
|
|
48
|
+
readonly latitude: number;
|
|
49
|
+
readonly longitude: number;
|
|
50
|
+
readonly neighbours: readonly FieldNeighbour[];
|
|
51
|
+
}
|
|
52
|
+
export interface FieldGrid {
|
|
53
|
+
readonly width: number;
|
|
54
|
+
readonly height: number;
|
|
55
|
+
readonly bounds: FieldGridBounds;
|
|
56
|
+
readonly cells: readonly FieldGridCell[];
|
|
57
|
+
}
|
|
58
|
+
/** A bilinear reading of a sampled grid. The vector is null when any contributing corner lacks one. */
|
|
59
|
+
export interface FieldGridSample {
|
|
60
|
+
readonly value: number;
|
|
61
|
+
readonly vector: readonly [east: number, north: number] | null;
|
|
62
|
+
readonly support: number;
|
|
63
|
+
}
|
|
64
|
+
/** Great-circle distance on a spherical Earth. */
|
|
65
|
+
export declare function distanceKm(a: FieldStation, b: FieldStation): number;
|
|
66
|
+
/** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
|
|
67
|
+
export declare function fieldNeighbours(stations: readonly FieldStation[], latitude: number, longitude: number, options?: FieldSupportOptions, exclude?: number): FieldNeighbour[];
|
|
68
|
+
/** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
|
|
69
|
+
export declare function estimateField(neighbours: readonly FieldNeighbour[], sample: (index: number) => FieldSample | null | undefined, options?: FieldSupportOptions): FieldEstimate;
|
|
70
|
+
/** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
|
|
71
|
+
export declare function createFieldGrid(stations: readonly FieldStation[], bounds: FieldGridBounds, options?: FieldSupportOptions): FieldGrid;
|
|
72
|
+
/** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
|
|
73
|
+
* Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
|
|
74
|
+
* unavailable and its support also fades from the nearest station with a vector.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sampleFieldGrid(grid: FieldGrid, sample: (index: number, level: number) => FieldSample | null | undefined, levels: number, options?: FieldSupportOptions, movement?: boolean): Float32Array;
|
|
77
|
+
/** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
|
|
78
|
+
export declare function sampleFieldGridAt(data: Float32Array, grid: FieldGrid, latitude: number, longitude: number, level: number): FieldGridSample | null;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const DEFAULT_FIELD_SUPPORT = { scaleKm: 90, reachStartKm: 180, reachKm: 240, supportNearKm: 120, supportFarKm: 240 };
|
|
2
|
+
const rad = Math.PI / 180;
|
|
3
|
+
const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
|
|
4
|
+
const fade = (distance, near, far) => 1 - smooth((distance - near) / Math.max(far - near, 1e-9));
|
|
5
|
+
/** Great-circle distance on a spherical Earth. */
|
|
6
|
+
export function distanceKm(a, b) {
|
|
7
|
+
const p = (b.latitude - a.latitude) * rad, q = (b.longitude - a.longitude) * rad;
|
|
8
|
+
const h = Math.sin(p / 2) ** 2 + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(q / 2) ** 2;
|
|
9
|
+
return 12742.0176 * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
10
|
+
}
|
|
11
|
+
/** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
|
|
12
|
+
export function fieldNeighbours(stations, latitude, longitude, options = DEFAULT_FIELD_SUPPORT, exclude = -1) {
|
|
13
|
+
const place = { latitude, longitude }, neighbours = [];
|
|
14
|
+
stations.forEach((station, index) => {
|
|
15
|
+
const distance = distanceKm(station, place);
|
|
16
|
+
if (index === exclude || distance >= options.reachKm)
|
|
17
|
+
return;
|
|
18
|
+
neighbours.push({ index, distanceKm: distance, weight: Math.exp(-.5 * (distance / options.scaleKm) ** 2) * fade(distance, options.reachStartKm, options.reachKm) });
|
|
19
|
+
});
|
|
20
|
+
return neighbours;
|
|
21
|
+
}
|
|
22
|
+
/** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
|
|
23
|
+
export function estimateField(neighbours, sample, options = DEFAULT_FIELD_SUPPORT) {
|
|
24
|
+
let weight = 0, value = 0, vectorWeight = 0, east = 0, north = 0, nearest = Infinity, nearestVector = Infinity;
|
|
25
|
+
for (const n of neighbours) {
|
|
26
|
+
const s = sample(n.index);
|
|
27
|
+
if (!s || s.value === null || !Number.isFinite(s.value))
|
|
28
|
+
continue;
|
|
29
|
+
weight += n.weight;
|
|
30
|
+
value += n.weight * s.value;
|
|
31
|
+
nearest = Math.min(nearest, n.distanceKm);
|
|
32
|
+
const v = s.vector;
|
|
33
|
+
if (v && Number.isFinite(v[0]) && Number.isFinite(v[1])) {
|
|
34
|
+
vectorWeight += n.weight;
|
|
35
|
+
east += n.weight * v[0];
|
|
36
|
+
north += n.weight * v[1];
|
|
37
|
+
nearestVector = Math.min(nearestVector, n.distanceKm);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (weight < 1e-12)
|
|
41
|
+
return { value: null, vector: null, support: 0, vectorSupport: 0 };
|
|
42
|
+
return { value: value / weight, vector: vectorWeight ? [east / vectorWeight, north / vectorWeight] : null,
|
|
43
|
+
support: fade(nearest, options.supportNearKm, options.supportFarKm), vectorSupport: vectorWeight ? fade(nearestVector, options.supportNearKm, options.supportFarKm) : 0 };
|
|
44
|
+
}
|
|
45
|
+
/** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
|
|
46
|
+
export function createFieldGrid(stations, bounds, options = DEFAULT_FIELD_SUPPORT) {
|
|
47
|
+
const width = Math.round((bounds.east - bounds.west) / bounds.step) + 1, height = Math.round((bounds.north - bounds.south) / bounds.step) + 1, cells = [];
|
|
48
|
+
for (let y = 0; y < height; y++)
|
|
49
|
+
for (let x = 0; x < width; x++) {
|
|
50
|
+
const latitude = bounds.south + y * bounds.step, longitude = bounds.west + x * bounds.step;
|
|
51
|
+
cells.push({ latitude, longitude, neighbours: fieldNeighbours(stations, latitude, longitude, options) });
|
|
52
|
+
}
|
|
53
|
+
return { width, height, bounds, cells };
|
|
54
|
+
}
|
|
55
|
+
/** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
|
|
56
|
+
* Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
|
|
57
|
+
* unavailable and its support also fades from the nearest station with a vector.
|
|
58
|
+
*/
|
|
59
|
+
export function sampleFieldGrid(grid, sample, levels, options = DEFAULT_FIELD_SUPPORT, movement = false) {
|
|
60
|
+
const data = new Float32Array(grid.cells.length * levels * 4);
|
|
61
|
+
data.fill(NaN);
|
|
62
|
+
for (let level = 0; level < levels; level++)
|
|
63
|
+
for (let i = 0; i < grid.cells.length; i++) {
|
|
64
|
+
const e = estimateField(grid.cells[i].neighbours, (index) => sample(index, level), options), o = (level * grid.cells.length + i) * 4;
|
|
65
|
+
if (e.value === null || (movement && !e.vector))
|
|
66
|
+
continue;
|
|
67
|
+
data[o] = e.value;
|
|
68
|
+
data[o + 3] = movement ? Math.min(e.support, e.vectorSupport) : e.support;
|
|
69
|
+
if (e.vector) {
|
|
70
|
+
data[o + 1] = e.vector[0];
|
|
71
|
+
data[o + 2] = e.vector[1];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
/** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
|
|
77
|
+
export function sampleFieldGridAt(data, grid, latitude, longitude, level) {
|
|
78
|
+
const { width, height, bounds } = grid, x = (longitude - bounds.west) / bounds.step, y = (latitude - bounds.south) / bounds.step;
|
|
79
|
+
if (!(x >= 0 && x <= width - 1 && y >= 0 && y <= height - 1) || level < 0 || (level + 1) * width * height * 4 > data.length)
|
|
80
|
+
return null;
|
|
81
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, out = [0, 0, 0, 0];
|
|
82
|
+
let vector = true;
|
|
83
|
+
for (const [xx, yy, w] of [[x0, y0, (1 - fx) * (1 - fy)], [Math.min(x0 + 1, width - 1), y0, fx * (1 - fy)], [x0, Math.min(y0 + 1, height - 1), (1 - fx) * fy], [Math.min(x0 + 1, width - 1), Math.min(y0 + 1, height - 1), fx * fy]]) {
|
|
84
|
+
if (w < 1e-10)
|
|
85
|
+
continue;
|
|
86
|
+
const o = (level * width * height + yy * width + xx) * 4;
|
|
87
|
+
if (!Number.isFinite(data[o]) || !Number.isFinite(data[o + 3]))
|
|
88
|
+
return null;
|
|
89
|
+
if (!Number.isFinite(data[o + 1]) || !Number.isFinite(data[o + 2]))
|
|
90
|
+
vector = false;
|
|
91
|
+
for (let c = 0; c < 4; c++)
|
|
92
|
+
out[c] += data[o + c] * w;
|
|
93
|
+
}
|
|
94
|
+
return { value: out[0], vector: vector ? [out[1], out[2]] : null, support: out[3] };
|
|
95
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NetworkSnapshot, NetworkTrain } from './network.ts';
|
|
2
|
+
export interface VehicleCall {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly time: number;
|
|
6
|
+
readonly platform?: string;
|
|
7
|
+
readonly atStop: boolean;
|
|
8
|
+
readonly isDestination: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Remaining calls from this snapshot's train. Times may already include realtime adjustments. */
|
|
11
|
+
export declare function vehicleCalls(snapshot: NetworkSnapshot, train: NetworkTrain, time: number): readonly VehicleCall[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Remaining calls from this snapshot's train. Times may already include realtime adjustments. */
|
|
2
|
+
export function vehicleCalls(snapshot, train, time) {
|
|
3
|
+
if (!Number.isFinite(time) || train.realtime?.status === 'cancelled')
|
|
4
|
+
return [];
|
|
5
|
+
return train.stops.flatMap(([index, arrival, departure], callIndex) => {
|
|
6
|
+
const stop = snapshot.stops[index];
|
|
7
|
+
if (!stop || !Number.isFinite(arrival) || !Number.isFinite(departure) || departure < arrival || departure < time)
|
|
8
|
+
return [];
|
|
9
|
+
const atStop = arrival <= time && time <= departure;
|
|
10
|
+
return [{ id: `${train.id}:${callIndex}`, name: stop[2], time: atStop ? departure : arrival,
|
|
11
|
+
platform: stop[3]?.trim() || undefined, atStop,
|
|
12
|
+
isDestination: callIndex === train.stops.length - 1 && Boolean(train.headsign.trim()) && stop[2].trim() === train.headsign.trim() }];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NetworkSnapshot, NetworkTrain, ServiceCategory, StationIndexEntry } from './network.ts';
|
|
2
|
+
export declare function countableVehicleTrains(network: NetworkSnapshot | undefined, stations: readonly StationIndexEntry[], selection: {
|
|
3
|
+
category?: ServiceCategory;
|
|
4
|
+
station?: Pick<StationIndexEntry, 'name'>;
|
|
5
|
+
route?: {
|
|
6
|
+
name: string;
|
|
7
|
+
category: ServiceCategory;
|
|
8
|
+
};
|
|
9
|
+
}): readonly NetworkTrain[];
|
|
10
|
+
/** Build once per immutable selection; clock updates need only two binary searches. */
|
|
11
|
+
export declare function createActiveTimetableVehicleCounter(trains: readonly NetworkTrain[], options?: {
|
|
12
|
+
readonly requirePositionable?: boolean;
|
|
13
|
+
}): (time: number) => number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function countableVehicleTrains(network, stations, selection) {
|
|
2
|
+
const stationTrainIds = selection.station
|
|
3
|
+
? new Set(stations.find(station => station.name === selection.station?.name)?.trainIds ?? [])
|
|
4
|
+
: undefined;
|
|
5
|
+
return network?.trains.filter(train => (!selection.category || train.category === selection.category) &&
|
|
6
|
+
(!stationTrainIds || stationTrainIds.has(train.id)) &&
|
|
7
|
+
(!selection.route || (train.route === selection.route.name && train.category === selection.route.category))) ?? [];
|
|
8
|
+
}
|
|
9
|
+
/** Build once per immutable selection; clock updates need only two binary searches. */
|
|
10
|
+
export function createActiveTimetableVehicleCounter(trains, options = {}) {
|
|
11
|
+
const starts = [], ends = [];
|
|
12
|
+
for (const train of trains) {
|
|
13
|
+
if (train.realtime?.status === 'cancelled' || (options.requirePositionable && train.stops.length < 2) || !(train.start <= train.end))
|
|
14
|
+
continue;
|
|
15
|
+
starts.push(train.start);
|
|
16
|
+
ends.push(train.end);
|
|
17
|
+
}
|
|
18
|
+
starts.sort((a, b) => a - b);
|
|
19
|
+
ends.sort((a, b) => a - b);
|
|
20
|
+
const before = (values, time, inclusive) => {
|
|
21
|
+
let low = 0, high = values.length;
|
|
22
|
+
while (low < high) {
|
|
23
|
+
const middle = (low + high) >>> 1;
|
|
24
|
+
if (values[middle] < time || inclusive && values[middle] === time)
|
|
25
|
+
low = middle + 1;
|
|
26
|
+
else
|
|
27
|
+
high = middle;
|
|
28
|
+
}
|
|
29
|
+
return low;
|
|
30
|
+
};
|
|
31
|
+
// Include both departure and arrival instants, including zero-length trips.
|
|
32
|
+
return time => Number.isNaN(time) ? 0 : before(starts, time, true) - before(ends, time, false);
|
|
33
|
+
}
|
package/map-labels.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Renderer-independent hierarchy shared by station and airport map labels. */
|
|
2
|
+
export interface MapLabelPriority {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly rank: number;
|
|
5
|
+
readonly priority: number;
|
|
6
|
+
readonly retained: boolean;
|
|
7
|
+
readonly distance: number;
|
|
8
|
+
}
|
|
9
|
+
export interface MapLabelBox {
|
|
10
|
+
readonly left: number;
|
|
11
|
+
readonly right: number;
|
|
12
|
+
readonly top: number;
|
|
13
|
+
readonly bottom: number;
|
|
14
|
+
}
|
|
15
|
+
export interface MapLabelCandidate extends MapLabelPriority {
|
|
16
|
+
readonly box: MapLabelBox;
|
|
17
|
+
}
|
|
18
|
+
export declare function compareMapLabelCandidates(first: MapLabelPriority, second: MapLabelPriority): number;
|
|
19
|
+
export declare function mapLabelBudget(cameraHeight: number): number;
|
|
20
|
+
export declare function mapLabelRankLimit(cameraHeight: number): number;
|
|
21
|
+
export declare function mapLabelBoxesOverlap(box: MapLabelBox, other: MapLabelBox, horizontalGap?: number, verticalGap?: number): boolean;
|
|
22
|
+
/** Candidate boxes include the edition's full hit area. Selected candidates must
|
|
23
|
+
* already have their higher priority; all labels respect viewport and obstacles. */
|
|
24
|
+
export declare function selectMapLabels<T extends MapLabelCandidate>(candidates: readonly T[], budget: number, viewport: MapLabelBox, obstacles?: readonly MapLabelBox[]): T[];
|
package/map-labels.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function compareMapLabelCandidates(first, second) {
|
|
2
|
+
return first.priority - second.priority || Number(second.retained) - Number(first.retained)
|
|
3
|
+
|| first.rank - second.rank || first.distance - second.distance || first.name.localeCompare(second.name, 'de-CH');
|
|
4
|
+
}
|
|
5
|
+
export function mapLabelBudget(cameraHeight) {
|
|
6
|
+
if (cameraHeight >= 30)
|
|
7
|
+
return 8;
|
|
8
|
+
if (cameraHeight >= 22)
|
|
9
|
+
return 20;
|
|
10
|
+
if (cameraHeight >= 15)
|
|
11
|
+
return 48;
|
|
12
|
+
return 96;
|
|
13
|
+
}
|
|
14
|
+
export function mapLabelRankLimit(cameraHeight) {
|
|
15
|
+
if (cameraHeight >= 30)
|
|
16
|
+
return 8;
|
|
17
|
+
if (cameraHeight >= 22)
|
|
18
|
+
return 20;
|
|
19
|
+
if (cameraHeight >= 15)
|
|
20
|
+
return 48;
|
|
21
|
+
if (cameraHeight >= 14)
|
|
22
|
+
return 96;
|
|
23
|
+
return Number.POSITIVE_INFINITY;
|
|
24
|
+
}
|
|
25
|
+
export function mapLabelBoxesOverlap(box, other, horizontalGap = 5, verticalGap = 4) {
|
|
26
|
+
return box.left < other.right + horizontalGap && box.right > other.left - horizontalGap
|
|
27
|
+
&& box.top < other.bottom + verticalGap && box.bottom > other.top - verticalGap;
|
|
28
|
+
}
|
|
29
|
+
/** Candidate boxes include the edition's full hit area. Selected candidates must
|
|
30
|
+
* already have their higher priority; all labels respect viewport and obstacles. */
|
|
31
|
+
export function selectMapLabels(candidates, budget, viewport, obstacles = []) {
|
|
32
|
+
const selected = [], occupied = [...obstacles];
|
|
33
|
+
const limit = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
|
34
|
+
for (const candidate of [...candidates].sort(compareMapLabelCandidates)) {
|
|
35
|
+
if (selected.length >= limit)
|
|
36
|
+
break;
|
|
37
|
+
const box = candidate.box;
|
|
38
|
+
if (![box.left, box.right, box.top, box.bottom].every(Number.isFinite) || box.right <= box.left || box.bottom <= box.top)
|
|
39
|
+
continue;
|
|
40
|
+
if (box.left < viewport.left || box.right > viewport.right || box.top < viewport.top || box.bottom > viewport.bottom)
|
|
41
|
+
continue;
|
|
42
|
+
if (occupied.some(other => mapLabelBoxesOverlap(box, other)))
|
|
43
|
+
continue;
|
|
44
|
+
selected.push(candidate);
|
|
45
|
+
occupied.push(box);
|
|
46
|
+
}
|
|
47
|
+
return selected;
|
|
48
|
+
}
|