@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/air-search.d.ts +11 -0
  4. package/air-search.js +32 -0
  5. package/domain/air-day.d.ts +34 -0
  6. package/domain/air-day.js +17 -0
  7. package/domain/air.d.ts +48 -0
  8. package/domain/air.js +83 -0
  9. package/domain/airport.d.ts +17 -0
  10. package/domain/airport.js +41 -0
  11. package/domain/boundary.d.ts +14 -0
  12. package/domain/boundary.js +1 -0
  13. package/domain/corridor.d.ts +64 -0
  14. package/domain/corridor.js +1 -0
  15. package/domain/hub.d.ts +57 -0
  16. package/domain/hub.js +137 -0
  17. package/domain/journey.d.ts +19 -0
  18. package/domain/journey.js +23 -0
  19. package/domain/lakes.d.ts +21 -0
  20. package/domain/lakes.js +1 -0
  21. package/domain/map-reference.d.ts +20 -0
  22. package/domain/map-reference.js +1 -0
  23. package/domain/network-day.d.ts +4 -0
  24. package/domain/network-day.js +21 -0
  25. package/domain/network-layers.d.ts +7 -0
  26. package/domain/network-layers.js +131 -0
  27. package/domain/network.d.ts +173 -0
  28. package/domain/network.js +106 -0
  29. package/domain/operations.d.ts +85 -0
  30. package/domain/operations.js +191 -0
  31. package/domain/realtime.d.ts +41 -0
  32. package/domain/realtime.js +116 -0
  33. package/domain/road-day.d.ts +65 -0
  34. package/domain/road-day.js +76 -0
  35. package/domain/road.d.ts +153 -0
  36. package/domain/road.js +78 -0
  37. package/domain/spatial-layout.d.ts +42 -0
  38. package/domain/spatial-layout.js +12 -0
  39. package/domain/train-time-index.d.ts +14 -0
  40. package/domain/train-time-index.js +28 -0
  41. package/edition.d.ts +34 -0
  42. package/edition.js +1 -0
  43. package/package.json +154 -0
  44. package/road-search.d.ts +11 -0
  45. package/road-search.js +24 -0
  46. package/search-navigation.d.ts +2 -0
  47. package/search-navigation.js +14 -0
  48. package/search-text.d.ts +1 -0
  49. package/search-text.js +12 -0
  50. package/theme.d.ts +19 -0
  51. package/theme.js +16 -0
package/domain/road.js ADDED
@@ -0,0 +1,78 @@
1
+ function interpolate(from, to, progress) {
2
+ return from + (to - from) * progress;
3
+ }
4
+ export function roadConditionsAtTime(direction, time) {
5
+ const samples = direction.samples;
6
+ if (!samples.length) {
7
+ return {
8
+ lightFlowPerHour: 0,
9
+ lightSpeedKmh: 0,
10
+ heavyFlowPerHour: 0,
11
+ heavySpeedKmh: 0,
12
+ };
13
+ }
14
+ if (time <= samples[0][0]) {
15
+ const [, lightFlowPerHour, lightSpeedKmh, heavyFlowPerHour, heavySpeedKmh] = samples[0];
16
+ return { lightFlowPerHour, lightSpeedKmh, heavyFlowPerHour, heavySpeedKmh };
17
+ }
18
+ if (time >= samples[samples.length - 1][0]) {
19
+ const [, lightFlowPerHour, lightSpeedKmh, heavyFlowPerHour, heavySpeedKmh] = samples[samples.length - 1];
20
+ return { lightFlowPerHour, lightSpeedKmh, heavyFlowPerHour, heavySpeedKmh };
21
+ }
22
+ let low = 1;
23
+ let high = samples.length - 1;
24
+ while (low < high) {
25
+ const middle = Math.floor((low + high) / 2);
26
+ if (samples[middle][0] < time)
27
+ low = middle + 1;
28
+ else
29
+ high = middle;
30
+ }
31
+ const from = samples[low - 1];
32
+ const to = samples[low];
33
+ const progress = (time - from[0]) / (to[0] - from[0]);
34
+ return {
35
+ lightFlowPerHour: interpolate(from[1], to[1], progress),
36
+ lightSpeedKmh: interpolate(from[2], to[2], progress),
37
+ heavyFlowPerHour: interpolate(from[3], to[3], progress),
38
+ heavySpeedKmh: interpolate(from[4], to[4], progress),
39
+ };
40
+ }
41
+ export function trafficDensity(flowPerHour, speedKmh) {
42
+ return speedKmh > 0 ? Math.max(0, flowPerHour) / speedKmh : 0;
43
+ }
44
+ export function reconstructedVehicleCount(snapshot, time) {
45
+ return Math.round(snapshot.corridors.reduce((total, corridor) => total +
46
+ corridor.directions.reduce((directionTotal, direction) => {
47
+ const conditions = roadConditionsAtTime(direction, time);
48
+ return (directionTotal +
49
+ (trafficDensity(conditions.lightFlowPerHour, conditions.lightSpeedKmh) +
50
+ trafficDensity(conditions.heavyFlowPerHour, conditions.heavySpeedKmh)) *
51
+ corridor.distanceKm);
52
+ }, 0), 0));
53
+ }
54
+ export function visualVehicleCount(flowPerHour, speedKmh, distanceKm, sampleRate, maximum) {
55
+ return Math.min(maximum, Math.max(0, Math.round(trafficDensity(flowPerHour, speedKmh) * distanceKm * sampleRate)));
56
+ }
57
+ export function roadDistanceTravelledKm(direction, time, vehicle) {
58
+ const samples = direction.samples;
59
+ if (samples.length < 2 || time <= samples[0][0])
60
+ return 0;
61
+ const speedIndex = vehicle === 'light' ? 2 : 4;
62
+ const limit = Math.min(time, samples[samples.length - 1][0]);
63
+ let distance = 0;
64
+ for (let index = 1; index < samples.length; index += 1) {
65
+ const from = samples[index - 1];
66
+ const to = samples[index];
67
+ if (from[0] >= limit)
68
+ break;
69
+ const segmentEnd = Math.min(limit, to[0]);
70
+ const duration = segmentEnd - from[0];
71
+ const progress = duration / (to[0] - from[0]);
72
+ const endSpeed = interpolate(from[speedIndex], to[speedIndex], progress);
73
+ distance += ((from[speedIndex] + endSpeed) / 2) * (duration / 3600);
74
+ if (segmentEnd === limit)
75
+ break;
76
+ }
77
+ return distance;
78
+ }
@@ -0,0 +1,42 @@
1
+ import type { NetworkSnapshot } from './network.ts';
2
+ export type SpatialLayoutCoordinate = readonly [x: number, y: number];
3
+ export type SpatialLayoutStop = readonly [
4
+ sourceId: string,
5
+ x: number,
6
+ y: number
7
+ ];
8
+ export interface SpatialLayoutSnapshot {
9
+ readonly metadata: {
10
+ readonly id: string;
11
+ readonly label: string;
12
+ readonly kind: 'topological';
13
+ readonly coordinateSpace: 'normalized';
14
+ readonly sourceNetwork: string;
15
+ readonly sourceSha256: string;
16
+ readonly overridesSource?: string;
17
+ readonly overridesSha256?: string;
18
+ readonly feedVersion: string;
19
+ readonly model: string;
20
+ readonly note: string;
21
+ };
22
+ readonly bounds: {
23
+ readonly minX: number;
24
+ readonly minY: number;
25
+ readonly maxX: number;
26
+ readonly maxY: number;
27
+ };
28
+ readonly stops: readonly SpatialLayoutStop[];
29
+ /** Path indexes deliberately match the source network's `paths` array. */
30
+ readonly paths: readonly (readonly SpatialLayoutCoordinate[])[];
31
+ readonly context?: {
32
+ /** Edition-authored water strokes or other geographic cues in layout space. */
33
+ readonly waterPaths?: readonly (readonly SpatialLayoutCoordinate[])[];
34
+ };
35
+ }
36
+ export interface SpatialLayoutCoverage {
37
+ readonly matchedStops: number;
38
+ readonly totalStops: number;
39
+ readonly matchedPaths: number;
40
+ readonly totalPaths: number;
41
+ }
42
+ export declare function spatialLayoutCoverage(network: NetworkSnapshot, layout: SpatialLayoutSnapshot): SpatialLayoutCoverage;
@@ -0,0 +1,12 @@
1
+ export function spatialLayoutCoverage(network, layout) {
2
+ const layoutStopIds = new Set(layout.stops.map(([sourceId]) => sourceId));
3
+ const matchedStops = network.stops.reduce((count, stop) => count + Number(Boolean(stop[4] && layoutStopIds.has(stop[4]))), 0);
4
+ const totalPaths = network.paths?.length ?? 0;
5
+ const matchedPaths = Math.min(totalPaths, layout.paths.filter((path) => path.length >= 2).length);
6
+ return {
7
+ matchedStops,
8
+ totalStops: network.stops.length,
9
+ matchedPaths,
10
+ totalPaths,
11
+ };
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { NetworkTrain } from './network.ts';
2
+ export interface TrainTimeIndex {
3
+ readonly windowStart: number;
4
+ readonly windowEnd: number;
5
+ readonly bucketSeconds: number;
6
+ readonly buckets: readonly (readonly NetworkTrain[])[];
7
+ }
8
+ /**
9
+ * Groups trips into coarse time buckets so a full-day renderer only examines
10
+ * services near the current clock time. Padding keeps recent trail samples in
11
+ * the candidate set around trip boundaries.
12
+ */
13
+ export declare function buildTrainTimeIndex(trains: readonly NetworkTrain[], windowStart: number, windowEnd: number, paddingSeconds?: number, bucketSeconds?: number): TrainTimeIndex;
14
+ export declare function trainsNearTime(index: TrainTimeIndex, time: number): readonly NetworkTrain[];
@@ -0,0 +1,28 @@
1
+ const DEFAULT_BUCKET_SECONDS = 15 * 60;
2
+ /**
3
+ * Groups trips into coarse time buckets so a full-day renderer only examines
4
+ * services near the current clock time. Padding keeps recent trail samples in
5
+ * the candidate set around trip boundaries.
6
+ */
7
+ export function buildTrainTimeIndex(trains, windowStart, windowEnd, paddingSeconds = 0, bucketSeconds = DEFAULT_BUCKET_SECONDS) {
8
+ const safeBucketSeconds = Math.max(60, bucketSeconds);
9
+ const bucketCount = Math.max(1, Math.ceil((windowEnd - windowStart) / safeBucketSeconds));
10
+ const buckets = Array.from({ length: bucketCount }, () => []);
11
+ for (const train of trains) {
12
+ const firstBucket = Math.max(0, Math.floor((train.start - paddingSeconds - windowStart) / safeBucketSeconds));
13
+ const lastBucket = Math.min(bucketCount - 1, Math.floor((train.end + paddingSeconds - windowStart) / safeBucketSeconds));
14
+ for (let index = firstBucket; index <= lastBucket; index += 1) {
15
+ buckets[index].push(train);
16
+ }
17
+ }
18
+ return {
19
+ windowStart,
20
+ windowEnd,
21
+ bucketSeconds: safeBucketSeconds,
22
+ buckets,
23
+ };
24
+ }
25
+ export function trainsNearTime(index, time) {
26
+ const bucket = Math.min(index.buckets.length - 1, Math.max(0, Math.floor((time - index.windowStart) / index.bucketSeconds)));
27
+ return index.buckets[bucket] ?? [];
28
+ }
package/edition.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { VisualTheme } from './theme.ts';
2
+ export interface MotionStudyIdentity {
3
+ readonly series: 'Motion Studies';
4
+ readonly catalogueNumber: string;
5
+ readonly title: string;
6
+ readonly placeName: string;
7
+ readonly descriptor: string;
8
+ }
9
+ export type SpatialLayoutId = 'geographic' | 'diagram';
10
+ export interface EditionSpatialLayout {
11
+ readonly id: SpatialLayoutId;
12
+ readonly label: string;
13
+ readonly kind: 'geographic' | 'topological';
14
+ readonly artifact?: string;
15
+ }
16
+ export interface EditionOpeningDataCatalog {
17
+ readonly network: string;
18
+ readonly geography?: string;
19
+ readonly dayManifest?: string;
20
+ readonly layouts?: readonly EditionSpatialLayout[];
21
+ }
22
+ export interface EditionDataCatalog {
23
+ readonly opening: EditionOpeningDataCatalog;
24
+ }
25
+ export interface MotionStudyEdition<DataCatalog extends EditionDataCatalog = EditionDataCatalog> {
26
+ readonly id: string;
27
+ readonly identity: MotionStudyIdentity;
28
+ readonly timezone: string;
29
+ readonly languageStorageKey: string;
30
+ readonly defaultNetworkTime: number;
31
+ readonly defaultHubTime?: number;
32
+ readonly theme: VisualTheme;
33
+ readonly data: DataCatalog;
34
+ }
package/edition.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,154 @@
1
+ {
2
+ "name": "@motionstudies/core",
3
+ "version": "0.1.0-alpha.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Transport contracts and motion primitives for Motion Studies.",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/emmettl/motionstudies.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "homepage": "https://github.com/emmettl/motionstudies#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/emmettl/motionstudies/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/",
20
+ "tag": "next"
21
+ },
22
+ "exports": {
23
+ "./air-search": {
24
+ "types": "./air-search.d.ts",
25
+ "import": "./air-search.js",
26
+ "default": "./air-search.js"
27
+ },
28
+ "./road-search": {
29
+ "types": "./road-search.d.ts",
30
+ "import": "./road-search.js",
31
+ "default": "./road-search.js"
32
+ },
33
+ "./search-navigation": {
34
+ "types": "./search-navigation.d.ts",
35
+ "import": "./search-navigation.js",
36
+ "default": "./search-navigation.js"
37
+ },
38
+ "./search-text": {
39
+ "types": "./search-text.d.ts",
40
+ "import": "./search-text.js",
41
+ "default": "./search-text.js"
42
+ },
43
+ "./edition": {
44
+ "types": "./edition.d.ts",
45
+ "import": "./edition.js",
46
+ "default": "./edition.js"
47
+ },
48
+ "./theme": {
49
+ "types": "./theme.d.ts",
50
+ "import": "./theme.js",
51
+ "default": "./theme.js"
52
+ },
53
+ "./domain/air-day": {
54
+ "types": "./domain/air-day.d.ts",
55
+ "import": "./domain/air-day.js",
56
+ "default": "./domain/air-day.js"
57
+ },
58
+ "./domain/air": {
59
+ "types": "./domain/air.d.ts",
60
+ "import": "./domain/air.js",
61
+ "default": "./domain/air.js"
62
+ },
63
+ "./domain/airport": {
64
+ "types": "./domain/airport.d.ts",
65
+ "import": "./domain/airport.js",
66
+ "default": "./domain/airport.js"
67
+ },
68
+ "./domain/boundary": {
69
+ "types": "./domain/boundary.d.ts",
70
+ "import": "./domain/boundary.js",
71
+ "default": "./domain/boundary.js"
72
+ },
73
+ "./domain/corridor": {
74
+ "types": "./domain/corridor.d.ts",
75
+ "import": "./domain/corridor.js",
76
+ "default": "./domain/corridor.js"
77
+ },
78
+ "./domain/hub": {
79
+ "types": "./domain/hub.d.ts",
80
+ "import": "./domain/hub.js",
81
+ "default": "./domain/hub.js"
82
+ },
83
+ "./domain/journey": {
84
+ "types": "./domain/journey.d.ts",
85
+ "import": "./domain/journey.js",
86
+ "default": "./domain/journey.js"
87
+ },
88
+ "./domain/lakes": {
89
+ "types": "./domain/lakes.d.ts",
90
+ "import": "./domain/lakes.js",
91
+ "default": "./domain/lakes.js"
92
+ },
93
+ "./domain/map-reference": {
94
+ "types": "./domain/map-reference.d.ts",
95
+ "import": "./domain/map-reference.js",
96
+ "default": "./domain/map-reference.js"
97
+ },
98
+ "./domain/network-day": {
99
+ "types": "./domain/network-day.d.ts",
100
+ "import": "./domain/network-day.js",
101
+ "default": "./domain/network-day.js"
102
+ },
103
+ "./domain/network-layers": {
104
+ "types": "./domain/network-layers.d.ts",
105
+ "import": "./domain/network-layers.js",
106
+ "default": "./domain/network-layers.js"
107
+ },
108
+ "./domain/network": {
109
+ "types": "./domain/network.d.ts",
110
+ "import": "./domain/network.js",
111
+ "default": "./domain/network.js"
112
+ },
113
+ "./domain/operations": {
114
+ "types": "./domain/operations.d.ts",
115
+ "import": "./domain/operations.js",
116
+ "default": "./domain/operations.js"
117
+ },
118
+ "./domain/realtime": {
119
+ "types": "./domain/realtime.d.ts",
120
+ "import": "./domain/realtime.js",
121
+ "default": "./domain/realtime.js"
122
+ },
123
+ "./domain/road-day": {
124
+ "types": "./domain/road-day.d.ts",
125
+ "import": "./domain/road-day.js",
126
+ "default": "./domain/road-day.js"
127
+ },
128
+ "./domain/road": {
129
+ "types": "./domain/road.d.ts",
130
+ "import": "./domain/road.js",
131
+ "default": "./domain/road.js"
132
+ },
133
+ "./domain/spatial-layout": {
134
+ "types": "./domain/spatial-layout.d.ts",
135
+ "import": "./domain/spatial-layout.js",
136
+ "default": "./domain/spatial-layout.js"
137
+ },
138
+ "./domain/train-time-index": {
139
+ "types": "./domain/train-time-index.d.ts",
140
+ "import": "./domain/train-time-index.js",
141
+ "default": "./domain/train-time-index.js"
142
+ }
143
+ },
144
+ "files": [
145
+ "**/*.js",
146
+ "**/*.mjs",
147
+ "**/*.d.ts",
148
+ "**/*.d.mts",
149
+ "**/*.css",
150
+ "README.md",
151
+ "LICENSE"
152
+ ],
153
+ "sideEffects": false
154
+ }
@@ -0,0 +1,11 @@
1
+ export interface RoadSearchCorridor {
2
+ readonly id: string;
3
+ readonly label: string;
4
+ readonly officialLabel: string;
5
+ readonly description?: string;
6
+ readonly focus: readonly [longitude: number, latitude: number];
7
+ readonly cameraScale: number;
8
+ readonly stationCount?: number;
9
+ }
10
+ export declare function searchRoadCorridors<Road extends RoadSearchCorridor>(roads: readonly Road[], query: string, maximum?: number): readonly Road[];
11
+ export declare function roadCorridorSearchValue(road: RoadSearchCorridor): string;
package/road-search.js ADDED
@@ -0,0 +1,24 @@
1
+ import { foldSearchText } from './search-text.js';
2
+ const ROAD_TERMS = 'motorway autobahn autoroute autostrada autostrasse';
3
+ function searchText(road) {
4
+ return foldSearchText(`${road.label} ${road.officialLabel} ${road.description ?? ''} ${ROAD_TERMS}`);
5
+ }
6
+ export function searchRoadCorridors(roads, query, maximum = 5) {
7
+ const foldedQuery = foldSearchText(query);
8
+ if (!foldedQuery)
9
+ return [];
10
+ return roads
11
+ .filter((road) => searchText(road).includes(foldedQuery))
12
+ .sort((first, second) => {
13
+ const firstText = searchText(first);
14
+ const secondText = searchText(second);
15
+ return (Number(secondText.startsWith(foldedQuery)) -
16
+ Number(firstText.startsWith(foldedQuery)) ||
17
+ (second.stationCount ?? 0) - (first.stationCount ?? 0) ||
18
+ first.id.localeCompare(second.id, 'en', { numeric: true }));
19
+ })
20
+ .slice(0, maximum);
21
+ }
22
+ export function roadCorridorSearchValue(road) {
23
+ return road.description ? `${road.label} · ${road.description}` : road.label;
24
+ }
@@ -0,0 +1,2 @@
1
+ export type SearchNavigationKey = 'ArrowDown' | 'ArrowUp' | 'Home' | 'End';
2
+ export declare function nextSearchResultIndex(currentIndex: number, resultCount: number, key: SearchNavigationKey): number;
@@ -0,0 +1,14 @@
1
+ export function nextSearchResultIndex(currentIndex, resultCount, key) {
2
+ if (resultCount <= 0)
3
+ return -1;
4
+ if (key === 'Home')
5
+ return 0;
6
+ if (key === 'End')
7
+ return resultCount - 1;
8
+ if (key === 'ArrowDown') {
9
+ return currentIndex < 0 ? 0 : (currentIndex + 1) % resultCount;
10
+ }
11
+ return currentIndex < 0
12
+ ? resultCount - 1
13
+ : (currentIndex - 1 + resultCount) % resultCount;
14
+ }
@@ -0,0 +1 @@
1
+ export declare function foldSearchText(value: string): string;
package/search-text.js ADDED
@@ -0,0 +1,12 @@
1
+ const combiningMarks = /\p{Mark}+/gu;
2
+ export function foldSearchText(value) {
3
+ return value
4
+ .trim()
5
+ .toLocaleLowerCase('de-CH')
6
+ .normalize('NFD')
7
+ .replace(combiningMarks, '')
8
+ .replaceAll('ß', 'ss')
9
+ .replaceAll('ae', 'a')
10
+ .replaceAll('oe', 'o')
11
+ .replaceAll('ue', 'u');
12
+ }
package/theme.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { ServiceCategory } from './domain/network.ts';
2
+ export interface VisualTheme {
3
+ readonly background: string;
4
+ readonly ink: string;
5
+ readonly muted: string;
6
+ readonly line: string;
7
+ readonly primary: string;
8
+ readonly secondary: string;
9
+ readonly panel: string;
10
+ readonly air: string;
11
+ readonly roadLight: string;
12
+ readonly roadHeavy: string;
13
+ }
14
+ export declare const SERVICE_CATEGORIES: ReadonlyArray<{
15
+ readonly id: ServiceCategory;
16
+ readonly label: string;
17
+ readonly color: string;
18
+ }>;
19
+ export declare const SERVICE_COLORS: Readonly<Record<ServiceCategory, string>>;
package/theme.js ADDED
@@ -0,0 +1,16 @@
1
+ export const SERVICE_CATEGORIES = [
2
+ { id: 'international', label: 'International', color: '#ffd166' },
3
+ { id: 'intercity', label: 'IC', color: '#ff4fd8' },
4
+ { id: 'interregio', label: 'IR', color: '#9d7bff' },
5
+ { id: 'regional-express', label: 'RE', color: '#4fc3ff' },
6
+ { id: 's-bahn', label: 'S-Bahn', color: '#7dffbb' },
7
+ { id: 'regional', label: 'Regional', color: '#fff3a6' },
8
+ { id: 'tram', label: 'Tram', color: '#ff6ea9' },
9
+ { id: 'metro', label: 'Metro', color: '#a78bfa' },
10
+ { id: 'bus', label: 'Bus', color: '#ff9f43' },
11
+ { id: 'ferry', label: 'Ferry', color: '#48c6ef' },
12
+ { id: 'cableway', label: 'Cableway', color: '#d7ff70' },
13
+ { id: 'funicular', label: 'Funicular', color: '#f8f38d' },
14
+ { id: 'other', label: 'Other', color: '#b9c1da' },
15
+ ];
16
+ export const SERVICE_COLORS = Object.fromEntries(SERVICE_CATEGORIES.map((category) => [category.id, category.color]));