@motionstudies/core 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +26 -0
- package/air-search.d.ts +11 -0
- package/air-search.js +32 -0
- package/domain/air-day.d.ts +34 -0
- package/domain/air-day.js +17 -0
- package/domain/air.d.ts +48 -0
- package/domain/air.js +83 -0
- package/domain/airport.d.ts +17 -0
- package/domain/airport.js +41 -0
- package/domain/boundary.d.ts +14 -0
- package/domain/boundary.js +1 -0
- package/domain/corridor.d.ts +64 -0
- package/domain/corridor.js +1 -0
- package/domain/hub.d.ts +57 -0
- package/domain/hub.js +137 -0
- package/domain/journey.d.ts +19 -0
- package/domain/journey.js +23 -0
- package/domain/lakes.d.ts +21 -0
- package/domain/lakes.js +1 -0
- package/domain/map-reference.d.ts +20 -0
- package/domain/map-reference.js +1 -0
- package/domain/network-day.d.ts +4 -0
- package/domain/network-day.js +21 -0
- package/domain/network-layers.d.ts +7 -0
- package/domain/network-layers.js +131 -0
- package/domain/network.d.ts +173 -0
- package/domain/network.js +106 -0
- package/domain/operations.d.ts +85 -0
- package/domain/operations.js +191 -0
- package/domain/realtime.d.ts +41 -0
- package/domain/realtime.js +116 -0
- package/domain/road-day.d.ts +65 -0
- package/domain/road-day.js +76 -0
- package/domain/road.d.ts +153 -0
- package/domain/road.js +78 -0
- package/domain/spatial-layout.d.ts +42 -0
- package/domain/spatial-layout.js +12 -0
- package/domain/train-time-index.d.ts +14 -0
- package/domain/train-time-index.js +28 -0
- package/edition.d.ts +34 -0
- package/edition.js +1 -0
- package/package.json +154 -0
- package/road-search.d.ts +11 -0
- package/road-search.js +24 -0
- package/search-navigation.d.ts +2 -0
- package/search-navigation.js +14 -0
- package/search-text.d.ts +1 -0
- package/search-text.js +12 -0
- package/theme.d.ts +19 -0
- package/theme.js +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Motion Studies contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Motion Studies packages
|
|
2
|
+
|
|
3
|
+
Shared packages for the Motion Studies transport instrument. The source workspace and compiled distributions expose the same explicit, extensionless module subpaths. The initial release is `0.1.0-alpha.0` under npm’s `next` tag.
|
|
4
|
+
|
|
5
|
+
- `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
|
|
6
|
+
- `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
|
|
7
|
+
- `@motionstudies/web`: picker, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
|
|
8
|
+
- `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { MobilePicker } from '@motionstudies/web/components/MobilePicker'
|
|
12
|
+
import { createDataUrlResolver } from '@motionstudies/web/data-url'
|
|
13
|
+
import { useProgressiveNetworkDay } from '@motionstudies/web/use-progressive-network-day'
|
|
14
|
+
import '@motionstudies/web/tokens.css'
|
|
15
|
+
import '@motionstudies/web/mobile-picker.css'
|
|
16
|
+
|
|
17
|
+
const resolveData = createDataUrlResolver('/my-edition/data/')
|
|
18
|
+
// Inside a React component:
|
|
19
|
+
// const day = useProgressiveNetworkDay('day.json', enabled, time, resolveData)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Keep the resolver stable across renders. Manifest paths and their chunk paths are relative to the supplied asset root. Changing the resolved manifest URL resets the loader; disabling cancels requests, and re-enabling retries failures. Optional road manifests distinguish a 404 from an error. Observations poll serially; changing their endpoint drops the previous source's state, and disabling/unmounting cancels the request and timer.
|
|
23
|
+
|
|
24
|
+
Build release candidates with `npm run build:packages`. Distribution manifests and compiled ESM/declarations are written to `.package-dist/`; workspace manifests continue to point at source for fast local iteration. `npm run check:packed` packs and installs those distributions into a separate consumer, builds the lab and validates the public exports. No source aliases or workspace links are used in that consumer.
|
|
25
|
+
|
|
26
|
+
Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
|
package/air-search.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface AirSearchTrack {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly icaoAddress?: string;
|
|
4
|
+
readonly callsign: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
readonly airportIds?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function airTrackSearchText(track: AirSearchTrack): string;
|
|
10
|
+
export declare function airTrackSearchValue(track: AirSearchTrack): string;
|
|
11
|
+
export declare function searchAirTracks<Track extends AirSearchTrack>(tracks: readonly Track[], searchQuery: string, time: number, limit?: number): readonly Track[];
|
package/air-search.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { foldSearchText } from './search-text.js';
|
|
2
|
+
export function airTrackSearchText(track) {
|
|
3
|
+
return foldSearchText(`${track.callsign} ${track.icaoAddress ?? track.id}`);
|
|
4
|
+
}
|
|
5
|
+
export function airTrackSearchValue(track) {
|
|
6
|
+
return `${track.callsign} · ${(track.icaoAddress ?? track.id).toUpperCase()}`;
|
|
7
|
+
}
|
|
8
|
+
function airTrackMatchRank(track, query) {
|
|
9
|
+
const callsign = foldSearchText(track.callsign);
|
|
10
|
+
const id = foldSearchText(track.icaoAddress ?? track.id);
|
|
11
|
+
if (callsign === query || id === query)
|
|
12
|
+
return 0;
|
|
13
|
+
if (callsign.startsWith(query) || id.startsWith(query))
|
|
14
|
+
return 1;
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
export function searchAirTracks(tracks, searchQuery, time, limit = 8) {
|
|
18
|
+
const query = foldSearchText(searchQuery);
|
|
19
|
+
if (!query)
|
|
20
|
+
return [];
|
|
21
|
+
return tracks
|
|
22
|
+
.filter((track) => airTrackSearchText(track).includes(query))
|
|
23
|
+
.sort((first, second) => {
|
|
24
|
+
const firstActive = first.start <= time && first.end >= time;
|
|
25
|
+
const secondActive = second.start <= time && second.end >= time;
|
|
26
|
+
return (airTrackMatchRank(first, query) - airTrackMatchRank(second, query) ||
|
|
27
|
+
Number(secondActive) - Number(firstActive) ||
|
|
28
|
+
first.start - second.start ||
|
|
29
|
+
first.callsign.localeCompare(second.callsign, 'en', { numeric: true }));
|
|
30
|
+
})
|
|
31
|
+
.slice(0, limit);
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AirSnapshot, AirTrack } from './air.ts';
|
|
2
|
+
export interface AirDayAircraft {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly icaoAddress: string;
|
|
5
|
+
readonly callsign: string;
|
|
6
|
+
readonly start: number;
|
|
7
|
+
readonly end: number;
|
|
8
|
+
readonly airportIds?: readonly string[];
|
|
9
|
+
readonly chunkIds: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export interface AirDayChunkDescriptor {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly windowStart: number;
|
|
14
|
+
readonly windowEnd: number;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly trackCount: number;
|
|
17
|
+
readonly sampleCount: number;
|
|
18
|
+
}
|
|
19
|
+
export interface AirDayChunk {
|
|
20
|
+
readonly windowStart: number;
|
|
21
|
+
readonly windowEnd: number;
|
|
22
|
+
readonly tracks: readonly AirTrack[];
|
|
23
|
+
}
|
|
24
|
+
export interface AirDayManifest {
|
|
25
|
+
readonly metadata: AirSnapshot['metadata'];
|
|
26
|
+
readonly bounds: AirSnapshot['bounds'];
|
|
27
|
+
readonly trackCount: number;
|
|
28
|
+
readonly sampleCount: number;
|
|
29
|
+
readonly aircraft: readonly AirDayAircraft[];
|
|
30
|
+
readonly chunks: readonly AirDayChunkDescriptor[];
|
|
31
|
+
}
|
|
32
|
+
export declare function airDayChunkForTime(manifest: AirDayManifest, time: number): AirDayChunkDescriptor;
|
|
33
|
+
export declare function adjacentAirDayChunks(manifest: AirDayManifest, current: AirDayChunkDescriptor): readonly AirDayChunkDescriptor[];
|
|
34
|
+
export declare function airSnapshotForDayChunk(manifest: AirDayManifest, chunk?: AirDayChunk): AirSnapshot;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function airDayChunkForTime(manifest, time) {
|
|
2
|
+
const last = manifest.chunks.at(-1);
|
|
3
|
+
const match = manifest.chunks.find((chunk) => time >= chunk.windowStart &&
|
|
4
|
+
(time < chunk.windowEnd || (chunk === last && time <= chunk.windowEnd)));
|
|
5
|
+
return match ?? (time < manifest.metadata.windowStart ? manifest.chunks[0] : last);
|
|
6
|
+
}
|
|
7
|
+
export function adjacentAirDayChunks(manifest, current) {
|
|
8
|
+
const index = manifest.chunks.findIndex((chunk) => chunk.id === current.id);
|
|
9
|
+
return manifest.chunks.slice(Math.max(0, index - 1), index + 2);
|
|
10
|
+
}
|
|
11
|
+
export function airSnapshotForDayChunk(manifest, chunk) {
|
|
12
|
+
return {
|
|
13
|
+
metadata: manifest.metadata,
|
|
14
|
+
bounds: manifest.bounds,
|
|
15
|
+
tracks: chunk?.tracks ?? [],
|
|
16
|
+
};
|
|
17
|
+
}
|
package/domain/air.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type AirSample = readonly [
|
|
2
|
+
time: number,
|
|
3
|
+
longitude: number,
|
|
4
|
+
latitude: number,
|
|
5
|
+
altitudeFeet: number,
|
|
6
|
+
groundSpeedKnots: number
|
|
7
|
+
];
|
|
8
|
+
export interface AirTrack {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly icaoAddress?: string;
|
|
11
|
+
readonly callsign: string;
|
|
12
|
+
readonly start: number;
|
|
13
|
+
readonly end: number;
|
|
14
|
+
readonly airportIds?: readonly string[];
|
|
15
|
+
readonly samples: readonly AirSample[];
|
|
16
|
+
}
|
|
17
|
+
export interface AirSnapshot {
|
|
18
|
+
readonly metadata: {
|
|
19
|
+
readonly publisher: string;
|
|
20
|
+
readonly serviceDate: string;
|
|
21
|
+
readonly windowStart: number;
|
|
22
|
+
readonly windowEnd: number;
|
|
23
|
+
readonly sourceUrl: string;
|
|
24
|
+
readonly license: string;
|
|
25
|
+
readonly licenseUrl: string;
|
|
26
|
+
readonly model: string;
|
|
27
|
+
readonly note: string;
|
|
28
|
+
readonly sampleIntervalSeconds: number;
|
|
29
|
+
};
|
|
30
|
+
readonly bounds: {
|
|
31
|
+
readonly minLongitude: number;
|
|
32
|
+
readonly minLatitude: number;
|
|
33
|
+
readonly maxLongitude: number;
|
|
34
|
+
readonly maxLatitude: number;
|
|
35
|
+
};
|
|
36
|
+
readonly tracks: readonly AirTrack[];
|
|
37
|
+
}
|
|
38
|
+
export interface AirPosition {
|
|
39
|
+
readonly longitude: number;
|
|
40
|
+
readonly latitude: number;
|
|
41
|
+
readonly altitudeFeet: number;
|
|
42
|
+
readonly groundSpeedKnots: number;
|
|
43
|
+
readonly headingDegrees: number;
|
|
44
|
+
readonly verticalRateFeetPerMinute: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function headingBetweenSamples(from: AirSample, to: AirSample): number;
|
|
47
|
+
export declare function positionForAirTrack(track: AirTrack, time: number): AirPosition | undefined;
|
|
48
|
+
export declare function activeAirTracks(snapshot: AirSnapshot, time: number): readonly AirTrack[];
|
package/domain/air.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const MAX_INTERPOLATION_GAP_SECONDS = 45;
|
|
2
|
+
function sampleBracket(samples, time) {
|
|
3
|
+
let low = 1;
|
|
4
|
+
let high = samples.length - 1;
|
|
5
|
+
while (low < high) {
|
|
6
|
+
const middle = Math.floor((low + high) / 2);
|
|
7
|
+
if (samples[middle][0] < time)
|
|
8
|
+
low = middle + 1;
|
|
9
|
+
else
|
|
10
|
+
high = middle;
|
|
11
|
+
}
|
|
12
|
+
return [low - 1, low];
|
|
13
|
+
}
|
|
14
|
+
function sampleTangent(samples, index, coordinate, fallback) {
|
|
15
|
+
const previous = samples[index - 1];
|
|
16
|
+
const next = samples[index + 1];
|
|
17
|
+
const current = samples[index];
|
|
18
|
+
if (previous &&
|
|
19
|
+
next &&
|
|
20
|
+
current[0] - previous[0] <= MAX_INTERPOLATION_GAP_SECONDS &&
|
|
21
|
+
next[0] - current[0] <= MAX_INTERPOLATION_GAP_SECONDS) {
|
|
22
|
+
return (next[coordinate] - previous[coordinate]) / (next[0] - previous[0]);
|
|
23
|
+
}
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
function hermiteValue(from, to, fromTangent, toTangent, duration, progress) {
|
|
27
|
+
const progressSquared = progress * progress;
|
|
28
|
+
const progressCubed = progressSquared * progress;
|
|
29
|
+
return ((2 * progressCubed - 3 * progressSquared + 1) * from +
|
|
30
|
+
(progressCubed - 2 * progressSquared + progress) * fromTangent * duration +
|
|
31
|
+
(-2 * progressCubed + 3 * progressSquared) * to +
|
|
32
|
+
(progressCubed - progressSquared) * toTangent * duration);
|
|
33
|
+
}
|
|
34
|
+
function hermiteDerivative(from, to, fromTangent, toTangent, duration, progress) {
|
|
35
|
+
const progressSquared = progress * progress;
|
|
36
|
+
return ((6 * progressSquared - 6 * progress) * from +
|
|
37
|
+
(3 * progressSquared - 4 * progress + 1) * fromTangent * duration +
|
|
38
|
+
(-6 * progressSquared + 6 * progress) * to +
|
|
39
|
+
(3 * progressSquared - 2 * progress) * toTangent * duration);
|
|
40
|
+
}
|
|
41
|
+
export function headingBetweenSamples(from, to) {
|
|
42
|
+
const latitude = ((from[2] + to[2]) * Math.PI) / 360;
|
|
43
|
+
const east = (to[1] - from[1]) * Math.cos(latitude);
|
|
44
|
+
const north = to[2] - from[2];
|
|
45
|
+
return ((Math.atan2(east, north) * 180) / Math.PI + 360) % 360;
|
|
46
|
+
}
|
|
47
|
+
export function positionForAirTrack(track, time) {
|
|
48
|
+
if (time < track.start || time > track.end || track.samples.length < 2) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const [lower, upper] = sampleBracket(track.samples, time);
|
|
52
|
+
const from = track.samples[lower];
|
|
53
|
+
const to = track.samples[upper];
|
|
54
|
+
const duration = to[0] - from[0];
|
|
55
|
+
if (duration <= 0 || duration > MAX_INTERPOLATION_GAP_SECONDS)
|
|
56
|
+
return undefined;
|
|
57
|
+
const progress = Math.max(0, Math.min(1, (time - from[0]) / duration));
|
|
58
|
+
const longitudeRate = (to[1] - from[1]) / duration;
|
|
59
|
+
const latitudeRate = (to[2] - from[2]) / duration;
|
|
60
|
+
const fromLongitudeTangent = sampleTangent(track.samples, lower, 1, longitudeRate);
|
|
61
|
+
const toLongitudeTangent = sampleTangent(track.samples, upper, 1, longitudeRate);
|
|
62
|
+
const fromLatitudeTangent = sampleTangent(track.samples, lower, 2, latitudeRate);
|
|
63
|
+
const toLatitudeTangent = sampleTangent(track.samples, upper, 2, latitudeRate);
|
|
64
|
+
const longitude = hermiteValue(from[1], to[1], fromLongitudeTangent, toLongitudeTangent, duration, progress);
|
|
65
|
+
const latitude = hermiteValue(from[2], to[2], fromLatitudeTangent, toLatitudeTangent, duration, progress);
|
|
66
|
+
const longitudeDerivative = hermiteDerivative(from[1], to[1], fromLongitudeTangent, toLongitudeTangent, duration, progress);
|
|
67
|
+
const latitudeDerivative = hermiteDerivative(from[2], to[2], fromLatitudeTangent, toLatitudeTangent, duration, progress);
|
|
68
|
+
const east = longitudeDerivative * Math.cos((latitude * Math.PI) / 180);
|
|
69
|
+
const headingDegrees = Math.abs(east) + Math.abs(latitudeDerivative) > Number.EPSILON
|
|
70
|
+
? ((Math.atan2(east, latitudeDerivative) * 180) / Math.PI + 360) % 360
|
|
71
|
+
: headingBetweenSamples(from, to);
|
|
72
|
+
return {
|
|
73
|
+
longitude,
|
|
74
|
+
latitude,
|
|
75
|
+
altitudeFeet: from[3] + (to[3] - from[3]) * progress,
|
|
76
|
+
groundSpeedKnots: from[4] + (to[4] - from[4]) * progress,
|
|
77
|
+
headingDegrees,
|
|
78
|
+
verticalRateFeetPerMinute: ((to[3] - from[3]) / duration) * 60,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function activeAirTracks(snapshot, time) {
|
|
82
|
+
return snapshot.tracks.filter((track) => positionForAirTrack(track, time));
|
|
83
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AirTrack } from './air.ts';
|
|
2
|
+
export interface StudyAirport {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly city: string;
|
|
6
|
+
readonly mapLabel?: string;
|
|
7
|
+
readonly iata: string;
|
|
8
|
+
readonly icao: string;
|
|
9
|
+
readonly longitude: number;
|
|
10
|
+
readonly latitude: number;
|
|
11
|
+
readonly approachRadiusKilometres: number;
|
|
12
|
+
readonly maximumApproachAltitudeFeet: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function airportSearchText(airport: StudyAirport): string;
|
|
15
|
+
export declare function searchAirports(airports: readonly StudyAirport[], searchQuery: string, limit?: number): readonly StudyAirport[];
|
|
16
|
+
export declare function airTrackServesAirport(track: AirTrack, airport: StudyAirport): boolean;
|
|
17
|
+
export declare function airportAirTrackIds(tracks: readonly AirTrack[], airport: StudyAirport): ReadonlySet<string>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { foldSearchText } from '../search-text.js';
|
|
2
|
+
export function airportSearchText(airport) {
|
|
3
|
+
return foldSearchText(`${airport.name} ${airport.city} ${airport.mapLabel ?? ''} ${airport.iata} ${airport.icao}`);
|
|
4
|
+
}
|
|
5
|
+
function airportMatchRank(airport, query) {
|
|
6
|
+
const codes = [airport.iata, airport.icao].map(foldSearchText);
|
|
7
|
+
const name = foldSearchText(airport.name);
|
|
8
|
+
if (codes.includes(query) || name === query)
|
|
9
|
+
return 0;
|
|
10
|
+
if (codes.some((code) => code.startsWith(query)) || name.startsWith(query)) {
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
15
|
+
export function searchAirports(airports, searchQuery, limit = 5) {
|
|
16
|
+
const query = foldSearchText(searchQuery);
|
|
17
|
+
if (!query)
|
|
18
|
+
return [];
|
|
19
|
+
return airports
|
|
20
|
+
.filter((airport) => airportSearchText(airport).includes(query))
|
|
21
|
+
.sort((first, second) => airportMatchRank(first, query) - airportMatchRank(second, query) ||
|
|
22
|
+
first.name.localeCompare(second.name, 'en'))
|
|
23
|
+
.slice(0, limit);
|
|
24
|
+
}
|
|
25
|
+
function coordinateDistanceKilometres(firstLongitude, firstLatitude, secondLongitude, secondLatitude) {
|
|
26
|
+
const averageLatitude = ((firstLatitude + secondLatitude) * Math.PI) / 360;
|
|
27
|
+
const east = (secondLongitude - firstLongitude) * 111.32 * Math.cos(averageLatitude);
|
|
28
|
+
const north = (secondLatitude - firstLatitude) * 111.32;
|
|
29
|
+
return Math.hypot(east, north);
|
|
30
|
+
}
|
|
31
|
+
export function airTrackServesAirport(track, airport) {
|
|
32
|
+
if (track.airportIds?.includes(airport.id))
|
|
33
|
+
return true;
|
|
34
|
+
return track.samples.some(([, longitude, latitude, altitudeFeet]) => altitudeFeet <= airport.maximumApproachAltitudeFeet &&
|
|
35
|
+
coordinateDistanceKilometres(longitude, latitude, airport.longitude, airport.latitude) <= airport.approachRadiusKilometres);
|
|
36
|
+
}
|
|
37
|
+
export function airportAirTrackIds(tracks, airport) {
|
|
38
|
+
return new Set(tracks
|
|
39
|
+
.filter((track) => airTrackServesAirport(track, airport))
|
|
40
|
+
.map((track) => track.id));
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type BoundaryCoordinate = readonly [longitude: number, latitude: number];
|
|
2
|
+
export interface MapBoundary {
|
|
3
|
+
readonly metadata: {
|
|
4
|
+
readonly source: string;
|
|
5
|
+
readonly sourceUrl: string;
|
|
6
|
+
readonly productUrl: string;
|
|
7
|
+
readonly edition: string;
|
|
8
|
+
readonly attribution: string;
|
|
9
|
+
readonly sourceCrs: string;
|
|
10
|
+
readonly outputCrs: string;
|
|
11
|
+
readonly simplificationToleranceMetres: number;
|
|
12
|
+
};
|
|
13
|
+
readonly rings: readonly (readonly BoundaryCoordinate[])[];
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface CorridorTerrain {
|
|
2
|
+
readonly columns: number;
|
|
3
|
+
readonly rows: number;
|
|
4
|
+
readonly widthMetres: number;
|
|
5
|
+
readonly depthMetres: number;
|
|
6
|
+
readonly minElevation: number;
|
|
7
|
+
readonly maxElevation: number;
|
|
8
|
+
readonly elevations: readonly number[];
|
|
9
|
+
}
|
|
10
|
+
export interface CorridorRouteStop {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly progress: number;
|
|
13
|
+
readonly departure: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CorridorLake {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly elevation: number;
|
|
19
|
+
readonly rings: readonly (readonly (readonly [number, number])[])[];
|
|
20
|
+
}
|
|
21
|
+
export interface CorridorTunnel {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly lengthMetres: number;
|
|
25
|
+
readonly line: string;
|
|
26
|
+
readonly startProgress: number;
|
|
27
|
+
readonly endProgress: number;
|
|
28
|
+
}
|
|
29
|
+
export interface CorridorSnapshot {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly metadata: {
|
|
32
|
+
readonly source: string;
|
|
33
|
+
readonly releaseDate: string;
|
|
34
|
+
readonly sourceUrl: string;
|
|
35
|
+
readonly productUrl: string;
|
|
36
|
+
readonly attribution: string;
|
|
37
|
+
readonly sourceCrs: string;
|
|
38
|
+
readonly model: string;
|
|
39
|
+
readonly railSource: unknown;
|
|
40
|
+
readonly profileSourceUrl: string;
|
|
41
|
+
readonly routeSource?: string;
|
|
42
|
+
readonly routeAttribution?: string;
|
|
43
|
+
readonly routeProductUrl?: string;
|
|
44
|
+
readonly tunnelSource?: string;
|
|
45
|
+
readonly tunnelSourceUrl?: string;
|
|
46
|
+
readonly tunnelProductUrl?: string;
|
|
47
|
+
};
|
|
48
|
+
readonly origin: {
|
|
49
|
+
readonly easting: number;
|
|
50
|
+
readonly northing: number;
|
|
51
|
+
};
|
|
52
|
+
readonly terrain: CorridorTerrain;
|
|
53
|
+
readonly route: {
|
|
54
|
+
readonly service: string;
|
|
55
|
+
readonly destination: string;
|
|
56
|
+
readonly operator: string;
|
|
57
|
+
readonly representativeTrain: string;
|
|
58
|
+
readonly distanceMetres: number;
|
|
59
|
+
readonly points: readonly (readonly [number, number, number])[];
|
|
60
|
+
readonly stops: readonly CorridorRouteStop[];
|
|
61
|
+
readonly tunnels?: readonly CorridorTunnel[];
|
|
62
|
+
};
|
|
63
|
+
readonly lakes: readonly CorridorLake[];
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/domain/hub.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { NetworkSnapshot, NetworkStop, ServiceCategory } from './network.ts';
|
|
2
|
+
export interface HubDefinition<HubId extends string = string> {
|
|
3
|
+
readonly id: HubId;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly aliases?: readonly string[];
|
|
6
|
+
readonly displayName: string;
|
|
7
|
+
readonly character: string;
|
|
8
|
+
}
|
|
9
|
+
export interface HubCall {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly train: {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly route: string;
|
|
14
|
+
readonly headsign: string;
|
|
15
|
+
readonly shortName: string;
|
|
16
|
+
readonly category: ServiceCategory;
|
|
17
|
+
};
|
|
18
|
+
readonly arrival: number;
|
|
19
|
+
readonly departure: number;
|
|
20
|
+
readonly hubStop: NetworkStop;
|
|
21
|
+
readonly previousStop?: NetworkStop;
|
|
22
|
+
readonly nextStop?: NetworkStop;
|
|
23
|
+
}
|
|
24
|
+
export type HubFlowOrientation = 'radial' | 'orbital';
|
|
25
|
+
export type HubFlowLens = 'all' | HubFlowOrientation;
|
|
26
|
+
export interface HubFlowSummary {
|
|
27
|
+
readonly all: number;
|
|
28
|
+
readonly radial: number;
|
|
29
|
+
readonly orbital: number;
|
|
30
|
+
}
|
|
31
|
+
export interface HubDaySnapshot<HubId extends string = string> {
|
|
32
|
+
readonly metadata: NetworkSnapshot['metadata'];
|
|
33
|
+
readonly hubs: Readonly<Record<HubId, readonly HubCall[]>>;
|
|
34
|
+
}
|
|
35
|
+
export declare const UNASSIGNED_PLATFORM = "\u2014";
|
|
36
|
+
export type StationMotionPhase = 'approaching' | 'dwelling' | 'departing';
|
|
37
|
+
export interface StationMotion {
|
|
38
|
+
readonly phase: StationMotionPhase;
|
|
39
|
+
readonly progress: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function platformCodeForCall(call: HubCall): string;
|
|
42
|
+
export declare function platformTrackForCall(call: HubCall): string;
|
|
43
|
+
export declare function platformsForCalls(calls: readonly HubCall[]): readonly string[];
|
|
44
|
+
export declare function stationMotionForCall(call: HubCall, time: number, horizon?: number, cycle?: number): StationMotion | undefined;
|
|
45
|
+
export declare function callsAtHub(snapshot: NetworkSnapshot, hub: HubDefinition): readonly HubCall[];
|
|
46
|
+
export declare function callsNearTime(calls: readonly HubCall[], time: number, horizon?: number, cycle?: number): readonly HubCall[];
|
|
47
|
+
export declare function nextHubCall(calls: readonly HubCall[], time: number): HubCall | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Classify a movement by comparing its local path through a hub with the line
|
|
50
|
+
* from a study centre to that hub. The absolute dot product makes the result
|
|
51
|
+
* independent of service direction: inbound and outbound calls share a lens.
|
|
52
|
+
*/
|
|
53
|
+
export declare function hubFlowOrientation(call: HubCall, centre: readonly [longitude: number, latitude: number], radialThreshold?: number): HubFlowOrientation;
|
|
54
|
+
export declare function callsForHubFlowLens(calls: readonly HubCall[], lens: HubFlowLens, centre: readonly [longitude: number, latitude: number]): readonly HubCall[];
|
|
55
|
+
export declare function hubFlowSummary(calls: readonly HubCall[], centre: readonly [longitude: number, latitude: number]): HubFlowSummary;
|
|
56
|
+
/** A soft day/night envelope with transitions around 22:00 and 06:30. */
|
|
57
|
+
export declare function hubNightSignalMix(time: number): number;
|
package/domain/hub.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export const UNASSIGNED_PLATFORM = '—';
|
|
2
|
+
export function platformCodeForCall(call) {
|
|
3
|
+
return call.hubStop[3]?.trim() || UNASSIGNED_PLATFORM;
|
|
4
|
+
}
|
|
5
|
+
export function platformTrackForCall(call) {
|
|
6
|
+
const code = platformCodeForCall(call);
|
|
7
|
+
if (code === UNASSIGNED_PLATFORM || code.includes('/'))
|
|
8
|
+
return code;
|
|
9
|
+
return /^\d+/.exec(code)?.[0] ?? code;
|
|
10
|
+
}
|
|
11
|
+
export function platformsForCalls(calls) {
|
|
12
|
+
return [...new Set(calls.map(platformTrackForCall))].sort((first, second) => {
|
|
13
|
+
if (first === UNASSIGNED_PLATFORM)
|
|
14
|
+
return 1;
|
|
15
|
+
if (second === UNASSIGNED_PLATFORM)
|
|
16
|
+
return -1;
|
|
17
|
+
return first.localeCompare(second, 'de-CH', {
|
|
18
|
+
numeric: true,
|
|
19
|
+
sensitivity: 'base',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function stationMotionForCall(call, time, horizon = 6 * 60, cycle = 24 * 3600) {
|
|
24
|
+
const midpoint = (call.arrival + call.departure) / 2;
|
|
25
|
+
const cycleOffset = Math.round((time - midpoint) / cycle) * cycle;
|
|
26
|
+
const arrival = call.arrival + cycleOffset;
|
|
27
|
+
const departure = call.departure + cycleOffset;
|
|
28
|
+
if (time < arrival - horizon || time > departure + horizon)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (time < arrival) {
|
|
31
|
+
return {
|
|
32
|
+
phase: 'approaching',
|
|
33
|
+
progress: (time - (arrival - horizon)) / horizon,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (time <= departure)
|
|
37
|
+
return { phase: 'dwelling', progress: 1 };
|
|
38
|
+
return {
|
|
39
|
+
phase: 'departing',
|
|
40
|
+
progress: (time - departure) / horizon,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function callsAtHub(snapshot, hub) {
|
|
44
|
+
const hubNames = new Set([hub.name, ...(hub.aliases ?? [])]);
|
|
45
|
+
const hubStopIndexes = new Set();
|
|
46
|
+
snapshot.stops.forEach((stop, index) => {
|
|
47
|
+
if (hubNames.has(stop[2]))
|
|
48
|
+
hubStopIndexes.add(index);
|
|
49
|
+
});
|
|
50
|
+
const calls = [];
|
|
51
|
+
for (const train of snapshot.trains) {
|
|
52
|
+
const callIndex = train.stops.findIndex(([stopIndex]) => hubStopIndexes.has(stopIndex));
|
|
53
|
+
if (callIndex < 0)
|
|
54
|
+
continue;
|
|
55
|
+
const stop = train.stops[callIndex];
|
|
56
|
+
const hubStop = snapshot.stops[stop[0]];
|
|
57
|
+
if (!hubStop)
|
|
58
|
+
continue;
|
|
59
|
+
calls.push({
|
|
60
|
+
id: `${train.id}:${stop[0]}`,
|
|
61
|
+
train,
|
|
62
|
+
arrival: stop[1],
|
|
63
|
+
departure: stop[2],
|
|
64
|
+
hubStop,
|
|
65
|
+
previousStop: snapshot.stops[train.stops[callIndex - 1]?.[0]],
|
|
66
|
+
nextStop: snapshot.stops[train.stops[callIndex + 1]?.[0]],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return calls.sort((first, second) => first.arrival - second.arrival);
|
|
70
|
+
}
|
|
71
|
+
export function callsNearTime(calls, time, horizon = 15 * 60, cycle = 24 * 3600) {
|
|
72
|
+
return calls.filter((call) => {
|
|
73
|
+
const midpoint = (call.arrival + call.departure) / 2;
|
|
74
|
+
const cycleOffset = Math.round((time - midpoint) / cycle) * cycle;
|
|
75
|
+
return (time >= call.arrival + cycleOffset - horizon &&
|
|
76
|
+
time <= call.departure + cycleOffset + horizon);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function nextHubCall(calls, time) {
|
|
80
|
+
return calls.find((call) => call.arrival >= time) ?? calls[0];
|
|
81
|
+
}
|
|
82
|
+
function projectedVector(from, to, latitude) {
|
|
83
|
+
const longitudeScale = Math.cos((latitude * Math.PI) / 180);
|
|
84
|
+
return [(to[0] - from[0]) * longitudeScale, to[1] - from[1]];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Classify a movement by comparing its local path through a hub with the line
|
|
88
|
+
* from a study centre to that hub. The absolute dot product makes the result
|
|
89
|
+
* independent of service direction: inbound and outbound calls share a lens.
|
|
90
|
+
*/
|
|
91
|
+
export function hubFlowOrientation(call, centre, radialThreshold = Math.SQRT1_2) {
|
|
92
|
+
const routeStart = call.previousStop ?? call.hubStop;
|
|
93
|
+
const routeEnd = call.nextStop ?? call.hubStop;
|
|
94
|
+
const route = projectedVector(routeStart, routeEnd, call.hubStop[1]);
|
|
95
|
+
const radial = projectedVector(centre, call.hubStop, call.hubStop[1]);
|
|
96
|
+
const routeLength = Math.hypot(route[0], route[1]);
|
|
97
|
+
const radialLength = Math.hypot(radial[0], radial[1]);
|
|
98
|
+
if (routeLength < 1e-8 || radialLength < 1e-8)
|
|
99
|
+
return 'radial';
|
|
100
|
+
const alignment = Math.abs((route[0] * radial[0] + route[1] * radial[1]) /
|
|
101
|
+
(routeLength * radialLength));
|
|
102
|
+
return alignment >= radialThreshold ? 'radial' : 'orbital';
|
|
103
|
+
}
|
|
104
|
+
export function callsForHubFlowLens(calls, lens, centre) {
|
|
105
|
+
if (lens === 'all')
|
|
106
|
+
return calls;
|
|
107
|
+
return calls.filter((call) => hubFlowOrientation(call, centre) === lens);
|
|
108
|
+
}
|
|
109
|
+
export function hubFlowSummary(calls, centre) {
|
|
110
|
+
let radial = 0;
|
|
111
|
+
for (const call of calls) {
|
|
112
|
+
if (hubFlowOrientation(call, centre) === 'radial')
|
|
113
|
+
radial += 1;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
all: calls.length,
|
|
117
|
+
radial,
|
|
118
|
+
orbital: calls.length - radial,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** A soft day/night envelope with transitions around 22:00 and 06:30. */
|
|
122
|
+
export function hubNightSignalMix(time) {
|
|
123
|
+
const timeOfDay = ((time % 86_400) + 86_400) % 86_400;
|
|
124
|
+
const eveningStart = 22 * 3_600;
|
|
125
|
+
const eveningEnd = 23 * 3_600;
|
|
126
|
+
const morningStart = 5 * 3_600;
|
|
127
|
+
const morningEnd = 6.5 * 3_600;
|
|
128
|
+
if (timeOfDay >= eveningStart) {
|
|
129
|
+
return Math.min(1, (timeOfDay - eveningStart) / (eveningEnd - eveningStart));
|
|
130
|
+
}
|
|
131
|
+
if (timeOfDay < morningStart)
|
|
132
|
+
return 1;
|
|
133
|
+
if (timeOfDay < morningEnd) {
|
|
134
|
+
return 1 - (timeOfDay - morningStart) / (morningEnd - morningStart);
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface JourneyStop {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly progress: number;
|
|
4
|
+
readonly departure: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Journey {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly service: string;
|
|
9
|
+
readonly destination: string;
|
|
10
|
+
readonly operator: string;
|
|
11
|
+
readonly speedKmh: number;
|
|
12
|
+
readonly stops: readonly JourneyStop[];
|
|
13
|
+
}
|
|
14
|
+
export interface JourneyPosition {
|
|
15
|
+
readonly previous: JourneyStop;
|
|
16
|
+
readonly next: JourneyStop;
|
|
17
|
+
readonly legProgress: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function positionOnJourney(journey: Journey, progress: number): JourneyPosition;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function positionOnJourney(journey, progress) {
|
|
2
|
+
const clamped = Math.min(1, Math.max(0, progress));
|
|
3
|
+
const nextIndex = journey.stops.findIndex((stop) => stop.progress >= clamped);
|
|
4
|
+
if (nextIndex <= 0) {
|
|
5
|
+
return {
|
|
6
|
+
previous: journey.stops[0],
|
|
7
|
+
next: journey.stops[1],
|
|
8
|
+
legProgress: 0,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (nextIndex === -1) {
|
|
12
|
+
const last = journey.stops.at(-1);
|
|
13
|
+
return { previous: last, next: last, legProgress: 1 };
|
|
14
|
+
}
|
|
15
|
+
const previous = journey.stops[nextIndex - 1];
|
|
16
|
+
const next = journey.stops[nextIndex];
|
|
17
|
+
const legLength = next.progress - previous.progress;
|
|
18
|
+
return {
|
|
19
|
+
previous,
|
|
20
|
+
next,
|
|
21
|
+
legProgress: legLength === 0 ? 1 : (clamped - previous.progress) / legLength,
|
|
22
|
+
};
|
|
23
|
+
}
|