@motionstudies/web 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (68) hide show
  1. package/README.md +231 -2
  2. package/components/AirportBoard.d.ts +27 -0
  3. package/components/AirportBoard.js +28 -0
  4. package/components/AirportHeroCard.d.ts +60 -0
  5. package/components/AirportHeroCard.js +31 -0
  6. package/components/BusStopHeroCard.d.ts +37 -0
  7. package/components/BusStopHeroCard.js +14 -0
  8. package/components/DotMatrixBoard.d.ts +31 -0
  9. package/components/DotMatrixBoard.js +84 -0
  10. package/components/LazyButtonTooltips.d.ts +2 -0
  11. package/components/LazyButtonTooltips.js +8 -0
  12. package/components/NetworkVehicleHeroCard.d.ts +13 -0
  13. package/components/NetworkVehicleHeroCard.js +13 -0
  14. package/components/RailStationHeroCard.d.ts +46 -0
  15. package/components/RailStationHeroCard.js +20 -0
  16. package/components/SbbDepartureBoard.d.ts +26 -0
  17. package/components/SbbDepartureBoard.js +29 -0
  18. package/components/SplitFlapBoard.d.ts +25 -0
  19. package/components/SplitFlapBoard.js +58 -0
  20. package/components/TransportHeroBoard.d.ts +28 -0
  21. package/components/TransportHeroBoard.js +15 -0
  22. package/components/VehicleHeroCard.d.ts +51 -0
  23. package/components/VehicleHeroCard.js +21 -0
  24. package/components/airport-hero-card.css +68 -0
  25. package/components/dot-matrix-board.css +49 -0
  26. package/components/dot-matrix-font.d.ts +2 -0
  27. package/components/dot-matrix-font.js +61 -0
  28. package/components/sbb-departure-board.css +49 -0
  29. package/components/split-flap-board.css +45 -0
  30. package/components/transport-hero-cards.css +39 -0
  31. package/components/vehicle-hero-card.css +64 -0
  32. package/fonts/DM-Mono-OFL.txt +93 -0
  33. package/fonts/Inter-OFL.txt +93 -0
  34. package/fonts/dm-mono-latin-300.woff2 +0 -0
  35. package/fonts/dm-mono-latin-400.woff2 +0 -0
  36. package/fonts/dm-mono-latin-500.woff2 +0 -0
  37. package/fonts/dm-mono-latin-ext-300.woff2 +0 -0
  38. package/fonts/dm-mono-latin-ext-400.woff2 +0 -0
  39. package/fonts/dm-mono-latin-ext-500.woff2 +0 -0
  40. package/fonts/inter-cyrillic-400-600.woff2 +0 -0
  41. package/fonts/inter-cyrillic-ext-400-600.woff2 +0 -0
  42. package/fonts/inter-greek-400-600.woff2 +0 -0
  43. package/fonts/inter-greek-ext-400-600.woff2 +0 -0
  44. package/fonts/inter-latin-400-600.woff2 +0 -0
  45. package/fonts/inter-latin-ext-400-600.woff2 +0 -0
  46. package/fonts/inter-vietnamese-400-600.woff2 +0 -0
  47. package/fonts.css +105 -0
  48. package/mount-motion-study.d.ts +7 -2
  49. package/mount-motion-study.js +2 -2
  50. package/package.json +82 -3
  51. package/study-layout.css +65 -0
  52. package/use-airport-feed.d.ts +9 -0
  53. package/use-airport-feed.js +72 -0
  54. package/use-browser-location.d.ts +13 -0
  55. package/use-browser-location.js +39 -0
  56. package/use-json-asset.d.ts +9 -0
  57. package/use-json-asset.js +45 -0
  58. package/use-now-clock.d.ts +11 -0
  59. package/use-now-clock.js +45 -0
  60. package/use-pattern-network-day.d.ts +17 -0
  61. package/use-pattern-network-day.js +41 -0
  62. package/use-progressive-air-day.d.ts +1 -0
  63. package/use-progressive-chunks.d.ts +3 -0
  64. package/use-progressive-chunks.js +13 -4
  65. package/use-progressive-network-day.d.ts +1 -0
  66. package/use-progressive-road-study.d.ts +1 -0
  67. package/use-transition-value.d.ts +12 -0
  68. package/use-transition-value.js +46 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -20,6 +20,28 @@
20
20
  "tag": "next"
21
21
  },
22
22
  "exports": {
23
+ "./use-now-clock": {
24
+ "types": "./use-now-clock.d.ts",
25
+ "import": "./use-now-clock.js",
26
+ "default": "./use-now-clock.js"
27
+ },
28
+ "./use-browser-location": {
29
+ "types": "./use-browser-location.d.ts",
30
+ "import": "./use-browser-location.js",
31
+ "default": "./use-browser-location.js"
32
+ },
33
+ "./components/SplitFlapBoard": {
34
+ "types": "./components/SplitFlapBoard.d.ts",
35
+ "import": "./components/SplitFlapBoard.js",
36
+ "default": "./components/SplitFlapBoard.js"
37
+ },
38
+ "./components/AirportHeroCard": {
39
+ "types": "./components/AirportHeroCard.d.ts",
40
+ "import": "./components/AirportHeroCard.js",
41
+ "default": "./components/AirportHeroCard.js"
42
+ },
43
+ "./split-flap-board.css": "./components/split-flap-board.css",
44
+ "./airport-hero-card.css": "./components/airport-hero-card.css",
23
45
  "./components/MobilePicker": {
24
46
  "types": "./components/MobilePicker.d.ts",
25
47
  "import": "./components/MobilePicker.js",
@@ -65,6 +87,11 @@
65
87
  "import": "./use-progressive-network-day.js",
66
88
  "default": "./use-progressive-network-day.js"
67
89
  },
90
+ "./use-pattern-network-day": {
91
+ "types": "./use-pattern-network-day.d.ts",
92
+ "import": "./use-pattern-network-day.js",
93
+ "default": "./use-pattern-network-day.js"
94
+ },
68
95
  "./use-progressive-road-study": {
69
96
  "types": "./use-progressive-road-study.d.ts",
70
97
  "import": "./use-progressive-road-study.js",
@@ -77,7 +104,57 @@
77
104
  "types": "./components/ButtonTooltips.d.ts",
78
105
  "import": "./components/ButtonTooltips.js",
79
106
  "default": "./components/ButtonTooltips.js"
80
- }
107
+ },
108
+ "./use-airport-feed": {
109
+ "types": "./use-airport-feed.d.ts",
110
+ "import": "./use-airport-feed.js",
111
+ "default": "./use-airport-feed.js"
112
+ },
113
+ "./components/AirportBoard": {
114
+ "types": "./components/AirportBoard.d.ts",
115
+ "import": "./components/AirportBoard.js",
116
+ "default": "./components/AirportBoard.js"
117
+ },
118
+ "./components/DotMatrixBoard": {
119
+ "types": "./components/DotMatrixBoard.d.ts",
120
+ "import": "./components/DotMatrixBoard.js",
121
+ "default": "./components/DotMatrixBoard.js"
122
+ },
123
+ "./dot-matrix-board.css": "./components/dot-matrix-board.css",
124
+ "./components/BusStopHeroCard": {
125
+ "types": "./components/BusStopHeroCard.d.ts",
126
+ "import": "./components/BusStopHeroCard.js",
127
+ "default": "./components/BusStopHeroCard.js"
128
+ },
129
+ "./components/VehicleHeroCard": {
130
+ "types": "./components/VehicleHeroCard.d.ts",
131
+ "import": "./components/VehicleHeroCard.js",
132
+ "default": "./components/VehicleHeroCard.js"
133
+ },
134
+ "./vehicle-hero-card.css": "./components/vehicle-hero-card.css",
135
+ "./components/RailStationHeroCard": {
136
+ "types": "./components/RailStationHeroCard.d.ts",
137
+ "import": "./components/RailStationHeroCard.js",
138
+ "default": "./components/RailStationHeroCard.js"
139
+ },
140
+ "./transport-hero-cards.css": "./components/transport-hero-cards.css",
141
+ "./use-json-asset": {
142
+ "types": "./use-json-asset.d.ts",
143
+ "import": "./use-json-asset.js",
144
+ "default": "./use-json-asset.js"
145
+ },
146
+ "./use-transition-value": {
147
+ "types": "./use-transition-value.d.ts",
148
+ "import": "./use-transition-value.js",
149
+ "default": "./use-transition-value.js"
150
+ },
151
+ "./components/NetworkVehicleHeroCard": {
152
+ "types": "./components/NetworkVehicleHeroCard.d.ts",
153
+ "import": "./components/NetworkVehicleHeroCard.js",
154
+ "default": "./components/NetworkVehicleHeroCard.js"
155
+ },
156
+ "./study-layout.css": "./study-layout.css",
157
+ "./fonts.css": "./fonts.css"
81
158
  },
82
159
  "files": [
83
160
  "**/*.js",
@@ -85,6 +162,8 @@
85
162
  "**/*.d.ts",
86
163
  "**/*.d.mts",
87
164
  "**/*.css",
165
+ "**/*.woff2",
166
+ "**/*-OFL.txt",
88
167
  "README.md",
89
168
  "LICENSE"
90
169
  ],
@@ -92,7 +171,7 @@
92
171
  "**/*.css"
93
172
  ],
94
173
  "dependencies": {
95
- "@motionstudies/core": "0.1.0-alpha.2"
174
+ "@motionstudies/core": "0.1.0-alpha.21"
96
175
  },
97
176
  "peerDependencies": {
98
177
  "react": "^19.2.8",
@@ -0,0 +1,65 @@
1
+ /* Opt-in overlay layout. Clearances describe the edition's surrounding controls. */
2
+ .ms-study-layout {
3
+ --ms-layout-gap: 12px;
4
+ --ms-layout-gutter: clamp(16px, 3vw, 48px);
5
+ --ms-layout-top: 180px;
6
+ --ms-layout-bottom: 160px;
7
+ --ms-layout-end: 72px;
8
+ --ms-layer-panel: 6;
9
+ --ms-control-size: 36px;
10
+ --ms-control-radius: 2px;
11
+ --ms-control-border: var(--line, #c1ccff33);
12
+ --ms-control-surface: var(--panel, #070716ee);
13
+ --ms-control-ink: var(--ink, #f8f7ff);
14
+ --ms-control-accent: var(--cyan, #8dfaff);
15
+ }
16
+
17
+ .ms-study-layout .ms-study-panel {
18
+ --ms-panel-top: max(var(--ms-layout-top, 180px), env(safe-area-inset-top));
19
+ --ms-panel-bottom: max(var(--ms-layout-bottom, 160px), env(safe-area-inset-bottom));
20
+ --ms-panel-start: max(var(--ms-layout-gutter, 16px), env(safe-area-inset-left));
21
+ --ms-panel-end: max(var(--ms-layout-end, 72px), env(safe-area-inset-right));
22
+ position: absolute;
23
+ z-index: var(--ms-layer-panel, 6);
24
+ inset-block-start: var(--ms-panel-top);
25
+ inset-inline-start: var(--ms-panel-start);
26
+ box-sizing: border-box;
27
+ inline-size: min(var(--ms-panel-width, 560px), max(0px, calc(100% - var(--ms-panel-start) - var(--ms-panel-end))));
28
+ max-block-size: max(0px, calc(100% - var(--ms-panel-top) - var(--ms-panel-bottom)));
29
+ min-inline-size: 0;
30
+ overflow: auto;
31
+ overscroll-behavior: contain;
32
+ scroll-padding-block: var(--ms-layout-gap, 12px);
33
+ }
34
+
35
+ [data-ms-chrome='hidden'] .ms-study-panel {
36
+ visibility: hidden;
37
+ pointer-events: none;
38
+ }
39
+
40
+ .ms-control {
41
+ box-sizing: border-box;
42
+ min-inline-size: var(--ms-control-size, 36px);
43
+ min-block-size: var(--ms-control-size, 36px);
44
+ padding: 6px 10px;
45
+ border: 1px solid var(--ms-control-border, #c1ccff33);
46
+ border-radius: var(--ms-control-radius, 2px);
47
+ background: var(--ms-control-surface, #070716ee);
48
+ color: var(--ms-control-ink, #f8f7ff);
49
+ font: inherit;
50
+ cursor: pointer;
51
+ touch-action: manipulation;
52
+ }
53
+ .ms-control:focus-visible {
54
+ outline: 2px solid var(--ms-control-accent, #8dfaff);
55
+ outline-offset: 2px;
56
+ }
57
+ .ms-control[aria-pressed='true'] { border-color: var(--ms-control-accent, #8dfaff); }
58
+ .ms-control:disabled { opacity: .5; cursor: default; }
59
+ .ms-control-group { display: flex; flex-wrap: wrap; align-items: stretch; gap: 6px; }
60
+ .ms-control-strip { display: flex; max-inline-size: 100%; overflow-x: auto; overscroll-behavior-inline: contain; scroll-padding-inline: 4px; }
61
+ .ms-control-strip > * { flex-shrink: 0; }
62
+ .ms-panel-dismiss { position: sticky; inset-block-start: 0; float: inline-end; z-index: 2; margin: 0 0 8px 8px; }
63
+ @media (pointer: coarse) {
64
+ .ms-study-layout { --ms-control-size: 44px; }
65
+ }
@@ -0,0 +1,9 @@
1
+ import { type AirportFeedResponse } from '@motionstudies/core/domain/live-airport';
2
+ export interface AirportFeedState {
3
+ readonly available: boolean;
4
+ readonly checking: boolean;
5
+ readonly loading: boolean;
6
+ readonly response?: AirportFeedResponse;
7
+ }
8
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
9
+ export declare function useAirportFeed(baseUrl: string, edition: string, airport: string, enabled: boolean): AirportFeedState;
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { isAirportFeedResponse } from '@motionstudies/core/domain/live-airport';
3
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
4
+ export function useAirportFeed(baseUrl, edition, airport, enabled) {
5
+ const source = `${baseUrl.replace(/\/$/, '')}/api/airports/v1/${encodeURIComponent(edition)}`;
6
+ const identity = `${source}/${airport}/${enabled}`;
7
+ const empty = { available: false, checking: true, loading: false };
8
+ const [stored, setStored] = useState({ ...empty, identity });
9
+ const state = stored.identity === identity ? stored : empty;
10
+ useEffect(() => {
11
+ const controller = new AbortController();
12
+ let timer, failures = 0;
13
+ const update = (patch) => {
14
+ if (!controller.signal.aborted)
15
+ setStored((old) => ({ ...(old.identity === identity ? old : empty), ...patch, identity }));
16
+ };
17
+ update(empty);
18
+ const tick = async () => {
19
+ let delay = 60_000;
20
+ if (document.hidden) {
21
+ timer = window.setTimeout(() => void tick(), delay);
22
+ return;
23
+ }
24
+ try {
25
+ const options = { cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]) };
26
+ const capabilitiesResponse = await fetch(`${source}/capabilities`, options);
27
+ if (!capabilitiesResponse.ok)
28
+ throw new Error('Capabilities unavailable');
29
+ const capabilities = await capabilitiesResponse.json();
30
+ if (capabilities.version !== 1 || typeof capabilities.enabled !== 'boolean' || !Array.isArray(capabilities.airports))
31
+ throw new Error('Invalid capabilities');
32
+ const available = capabilities.enabled && capabilities.airports.includes(airport);
33
+ update({ available, checking: false });
34
+ if (!available) {
35
+ update({ loading: false, response: { version: 1, status: 'disabled', reason: 'disabled', retryAfterSeconds: 60 } });
36
+ }
37
+ else if (enabled) {
38
+ update({ loading: true });
39
+ const r = await fetch(`${source}/${encodeURIComponent(airport)}/board`, options);
40
+ const response = await r.json();
41
+ if (!isAirportFeedResponse(response))
42
+ throw new Error('Invalid board');
43
+ if (response.snapshot && (response.snapshot.airport.iata !== airport || response.snapshot.fetchedAt > Date.now() / 1000 + 60))
44
+ throw new Error('Mismatched board');
45
+ update({ response, loading: false, available: response.status !== 'disabled' });
46
+ // Check capabilities every minute even when the upstream circuit is paused.
47
+ }
48
+ failures = 0;
49
+ }
50
+ catch {
51
+ failures += 1;
52
+ delay = Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
53
+ if (!controller.signal.aborted)
54
+ setStored((old) => {
55
+ const snapshot = old.identity === identity ? old.response?.snapshot : undefined;
56
+ return { identity, available: old.identity === identity && old.available, checking: false, loading: false,
57
+ response: { version: 1, status: snapshot && snapshot.expiresAt > Date.now() / 1000 ? 'stale' : 'unavailable', reason: 'provider', retryAfterSeconds: delay / 1000,
58
+ ...(snapshot && snapshot.expiresAt > Date.now() / 1000 ? { snapshot } : {}) } };
59
+ });
60
+ }
61
+ finally {
62
+ if (!controller.signal.aborted)
63
+ timer = window.setTimeout(() => void tick(), delay);
64
+ }
65
+ };
66
+ void tick();
67
+ return () => { controller.abort(); window.clearTimeout(timer); };
68
+ // empty is deliberately reconstructed only when the source effect restarts.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [identity, source, airport, enabled]);
71
+ return state;
72
+ }
@@ -0,0 +1,13 @@
1
+ export interface BrowserLocation {
2
+ readonly longitude: number;
3
+ readonly latitude: number;
4
+ readonly accuracy: number;
5
+ }
6
+ export type BrowserLocationStatus = 'idle' | 'locating' | 'ready' | 'denied' | 'timeout' | 'unavailable';
7
+ /** One explicit request, kept in memory. Clearing or unmounting ignores late replies. */
8
+ export declare function useBrowserLocation(): {
9
+ location: BrowserLocation | undefined;
10
+ status: BrowserLocationStatus;
11
+ locate: () => void;
12
+ clear: () => void;
13
+ };
@@ -0,0 +1,39 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ /** One explicit request, kept in memory. Clearing or unmounting ignores late replies. */
3
+ export function useBrowserLocation() {
4
+ const [location, setLocation] = useState();
5
+ const [status, setStatus] = useState('idle');
6
+ const request = useRef(0);
7
+ useEffect(() => () => { request.current += 1; }, []);
8
+ const clear = useCallback(() => {
9
+ request.current += 1;
10
+ setLocation(undefined);
11
+ setStatus('idle');
12
+ }, []);
13
+ const locate = useCallback(() => {
14
+ const id = ++request.current;
15
+ setLocation(undefined);
16
+ if (!navigator.geolocation) {
17
+ setStatus('unavailable');
18
+ return;
19
+ }
20
+ setStatus('locating');
21
+ navigator.geolocation.getCurrentPosition(({ coords }) => {
22
+ if (request.current !== id)
23
+ return;
24
+ if (!Number.isFinite(coords.longitude) || !Number.isFinite(coords.latitude) ||
25
+ Math.abs(coords.longitude) > 180 || Math.abs(coords.latitude) > 90 ||
26
+ !Number.isFinite(coords.accuracy) || coords.accuracy < 0) {
27
+ setStatus('unavailable');
28
+ return;
29
+ }
30
+ setLocation({ longitude: coords.longitude, latitude: coords.latitude, accuracy: coords.accuracy });
31
+ setStatus('ready');
32
+ }, (error) => {
33
+ if (request.current !== id)
34
+ return;
35
+ setStatus(error.code === 1 ? 'denied' : error.code === 3 ? 'timeout' : 'unavailable');
36
+ }, { enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 });
37
+ }, []);
38
+ return { location, status, locate, clear };
39
+ }
@@ -0,0 +1,9 @@
1
+ /** A single optional asset, cached until its URL/parser changes or retry is called.
2
+ * Keep parse stable. Source compatibility and schema checks belong in that parser. */
3
+ export declare function useJsonAsset<T>(url: string | undefined, enabled?: boolean, parse?: (value: unknown) => T, optional?: boolean): {
4
+ data: T | undefined;
5
+ error: boolean;
6
+ unavailable: boolean;
7
+ loading: boolean;
8
+ retry: () => void;
9
+ };
@@ -0,0 +1,45 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ const identity = (value) => value;
3
+ /** A single optional asset, cached until its URL/parser changes or retry is called.
4
+ * Keep parse stable. Source compatibility and schema checks belong in that parser. */
5
+ export function useJsonAsset(url, enabled = true, parse = identity, optional = false) {
6
+ const [attempt, setAttempt] = useState(0);
7
+ const [stored, setStored] = useState({ url, parse, attempt });
8
+ const state = stored.url === url && stored.parse === parse && stored.attempt === attempt
9
+ ? stored : { url, parse, attempt };
10
+ if (stored !== state)
11
+ setStored(state);
12
+ const { data, error, unavailable } = state;
13
+ useEffect(() => {
14
+ if (!enabled || !url || data !== undefined || unavailable)
15
+ return;
16
+ const controller = new AbortController();
17
+ const save = (result) => {
18
+ if (!controller.signal.aborted)
19
+ setStored(current => current.url === url && current.parse === parse && current.attempt === attempt
20
+ ? { ...current, ...result } : current);
21
+ };
22
+ void (async () => {
23
+ try {
24
+ const response = await fetch(url, { signal: controller.signal });
25
+ if (optional && response.status === 404) {
26
+ save({ unavailable: true, error: false });
27
+ return;
28
+ }
29
+ if (!response.ok)
30
+ throw new Error(`Asset returned ${response.status}`);
31
+ const next = parse(await response.json());
32
+ if (next === undefined)
33
+ throw new Error('Asset parser returned no data');
34
+ save({ data: next, error: false });
35
+ }
36
+ catch {
37
+ save({ error: true });
38
+ }
39
+ })();
40
+ return () => controller.abort();
41
+ }, [url, enabled, parse, optional, attempt, data, unavailable]);
42
+ const retry = useCallback(() => setAttempt(value => value + 1), []);
43
+ return { data, error: Boolean(error), unavailable: Boolean(unavailable),
44
+ loading: Boolean(enabled && url && data === undefined && !error && !unavailable), retry };
45
+ }
@@ -0,0 +1,11 @@
1
+ /** The consumer maps an instant into its own service date, timezone and data window.
2
+ * The consumer may explicitly choose a representative timetable for the present.
3
+ * Return null when its data cannot meaningfully represent that instant.
4
+ */
5
+ export declare function useNowClock(resolveTime: (instant: Date) => number | null): {
6
+ active: boolean;
7
+ time: number | null;
8
+ unavailable: boolean;
9
+ start: () => void;
10
+ stop: () => void;
11
+ };
@@ -0,0 +1,45 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ /** The consumer maps an instant into its own service date, timezone and data window.
3
+ * The consumer may explicitly choose a representative timetable for the present.
4
+ * Return null when its data cannot meaningfully represent that instant.
5
+ */
6
+ export function useNowClock(resolveTime) {
7
+ const [active, setActive] = useState(false);
8
+ const [time, setTime] = useState(null);
9
+ const [unavailable, setUnavailable] = useState(false);
10
+ const running = useRef(false);
11
+ const resolver = useRef(resolveTime);
12
+ useLayoutEffect(() => { resolver.current = resolveTime; }, [resolveTime]);
13
+ const update = useCallback(() => {
14
+ const next = resolver.current(new Date());
15
+ if (next === null || !Number.isFinite(next)) {
16
+ running.current = false;
17
+ setActive(false);
18
+ setUnavailable(true);
19
+ return false;
20
+ }
21
+ setTime(next);
22
+ return true;
23
+ }, []);
24
+ const start = useCallback(() => {
25
+ setUnavailable(false);
26
+ running.current = update();
27
+ setActive(running.current);
28
+ }, [update]);
29
+ const stop = useCallback(() => {
30
+ running.current = false;
31
+ setActive(false);
32
+ }, []);
33
+ useEffect(() => {
34
+ if (!active)
35
+ return;
36
+ let frame;
37
+ const tick = () => {
38
+ if (running.current && update())
39
+ frame = requestAnimationFrame(tick);
40
+ };
41
+ frame = requestAnimationFrame(tick);
42
+ return () => cancelAnimationFrame(frame);
43
+ }, [active, update]);
44
+ return { active, time, unavailable, start, stop };
45
+ }
@@ -0,0 +1,17 @@
1
+ import type { PatternChunkOptions } from '@motionstudies/core/domain/network-patterns';
2
+ import type { NetworkDayChunk, NetworkDayChunkDescriptor, NetworkDayManifest } from '@motionstudies/core/domain/network';
3
+ import type { DataUrlResolver } from './data-url.ts';
4
+ export interface PatternNetworkManifest extends NetworkDayManifest {
5
+ readonly format: string;
6
+ }
7
+ export declare function verifiedPatternNetworkChunk(response: Response, descriptor: NetworkDayChunkDescriptor, options?: PatternChunkOptions): Promise<NetworkDayChunk>;
8
+ /** Fetch and decode only the current and adjacent chunks; keep the manifest independent. */
9
+ export declare function usePatternNetworkDay(manifestFile: string, active: boolean, time: number, resolveAssetUrl: DataUrlResolver, options?: PatternChunkOptions): {
10
+ manifest: PatternNetworkManifest | undefined;
11
+ chunkReady: boolean;
12
+ loading: boolean;
13
+ unavailable: boolean;
14
+ error: boolean;
15
+ retry: () => void;
16
+ network: import("@motionstudies/core/domain/network").NetworkSnapshot | undefined;
17
+ };
@@ -0,0 +1,41 @@
1
+ import { useMemo } from 'react';
2
+ import { adjacentDayChunks, dayChunkForTime, networkSnapshotForDayChunk } from '@motionstudies/core/domain/network-day';
3
+ import { useProgressiveChunks } from './use-progressive-chunks.js';
4
+ export async function verifiedPatternNetworkChunk(response, descriptor, options = {}) {
5
+ const bytes = await response.arrayBuffer();
6
+ if (!Number.isSafeInteger(descriptor.bytes) || bytes.byteLength !== descriptor.bytes || !/^[a-f0-9]{64}$/.test(descriptor.sha256 ?? ''))
7
+ throw new Error('Pattern chunk integrity metadata missing or size mismatch');
8
+ const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(byte => byte.toString(16).padStart(2, '0')).join('');
9
+ if (hash !== descriptor.sha256)
10
+ throw new Error('Pattern chunk integrity mismatch');
11
+ const { decodeNetworkPatterns } = await import('@motionstudies/core/domain/network-patterns');
12
+ const chunk = decodeNetworkPatterns(JSON.parse(new TextDecoder().decode(bytes)), options);
13
+ if (chunk.windowStart !== descriptor.windowStart || chunk.windowEnd !== descriptor.windowEnd || chunk.trains.length !== descriptor.tripCount)
14
+ throw new Error('Pattern chunk descriptor mismatch');
15
+ return chunk;
16
+ }
17
+ /** Fetch and decode only the current and adjacent chunks; keep the manifest independent. */
18
+ export function usePatternNetworkDay(manifestFile, active, time, resolveAssetUrl, options = {}) {
19
+ const format = options.format ?? 'network-patterns-v1', categories = options.categories;
20
+ const adapter = useMemo(() => ({
21
+ chunkForTime: dayChunkForTime, adjacentChunks: adjacentDayChunks, retainAdjacentOnly: true,
22
+ validateManifest(manifest) {
23
+ if (manifest.format !== format || !manifest.metadata || !Array.isArray(manifest.stops) || !Array.isArray(manifest.edges))
24
+ throw new Error('Invalid pattern manifest');
25
+ const ids = new Set();
26
+ let previous = -Infinity;
27
+ for (const chunk of manifest.chunks) {
28
+ if (!chunk.id || ids.has(chunk.id) || !chunk.path || !Number.isFinite(chunk.windowStart) || !Number.isFinite(chunk.windowEnd) ||
29
+ chunk.windowStart < previous || chunk.windowEnd <= chunk.windowStart || !Number.isSafeInteger(chunk.tripCount) || chunk.tripCount < 0 ||
30
+ !Number.isSafeInteger(chunk.bytes) || chunk.bytes < 1 || !/^[a-f0-9]{64}$/.test(chunk.sha256 ?? ''))
31
+ throw new Error('Invalid pattern descriptor');
32
+ ids.add(chunk.id);
33
+ previous = chunk.windowEnd;
34
+ }
35
+ },
36
+ readChunk: (response, descriptor) => verifiedPatternNetworkChunk(response, descriptor, { format, categories }),
37
+ }), [format, categories]);
38
+ const { chunk, ...state } = useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter);
39
+ const network = useMemo(() => state.manifest ? networkSnapshotForDayChunk(state.manifest, chunk) : undefined, [state.manifest, chunk]);
40
+ return { ...state, network };
41
+ }
@@ -6,5 +6,6 @@ export declare function useProgressiveAirDay(manifestFile: string, active: boole
6
6
  loading: boolean;
7
7
  unavailable: boolean;
8
8
  error: boolean;
9
+ retry: () => void;
9
10
  snapshot: import("@motionstudies/core/domain/air").AirSnapshot | undefined;
10
11
  };
@@ -11,6 +11,8 @@ export interface ProgressiveChunkAdapter<Manifest, Descriptor, Chunk> {
11
11
  readonly adjacentChunks: (manifest: Manifest, current: Descriptor) => readonly Descriptor[];
12
12
  readonly readChunk: (response: Response, descriptor: Descriptor) => Promise<Chunk>;
13
13
  readonly optional?: boolean;
14
+ readonly validateManifest?: (manifest: Manifest) => void;
15
+ readonly retainAdjacentOnly?: boolean;
14
16
  }
15
17
  /** Internal lifecycle shared by the network, aircraft and road loaders. */
16
18
  export declare function useProgressiveChunks<Descriptor extends ChunkDescriptor, Manifest extends ChunkManifest<Descriptor>, Chunk>(manifestFile: string, active: boolean, time: number, resolveAssetUrl: DataUrlResolver, adapter: ProgressiveChunkAdapter<Manifest, Descriptor, Chunk>): {
@@ -20,5 +22,6 @@ export declare function useProgressiveChunks<Descriptor extends ChunkDescriptor,
20
22
  loading: boolean;
21
23
  unavailable: boolean;
22
24
  error: boolean;
25
+ retry: () => void;
23
26
  };
24
27
  export {};
@@ -1,8 +1,13 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  /** Internal lifecycle shared by the network, aircraft and road loaders. */
3
3
  export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter) {
4
4
  const key = resolveAssetUrl(manifestFile);
5
5
  const [stored, setStored] = useState({ key, chunks: {} });
6
+ const [attempt, setAttempt] = useState(0);
7
+ const retry = useCallback(() => {
8
+ setStored(current => current.key === key ? { ...current, manifestError: false, failedChunk: undefined, unavailable: false } : current);
9
+ setAttempt(value => value + 1);
10
+ }, [key]);
6
11
  // Reset during render so no effect (or consumer) sees another source's data.
7
12
  // Late responses are also rejected below, independently of fetch cancellation.
8
13
  const state = stored.key === key ? stored : { key, chunks: {} };
@@ -32,6 +37,7 @@ export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl
32
37
  if (!Array.isArray(next.chunks) || !next.chunks.length) {
33
38
  throw new Error('Day manifest has no chunks');
34
39
  }
40
+ adapter.validateManifest?.(next);
35
41
  if (!controller.signal.aborted) {
36
42
  setStored((current) => current.key === key
37
43
  ? { ...current, manifest: next, manifestError: false }
@@ -45,7 +51,7 @@ export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl
45
51
  }
46
52
  })();
47
53
  return () => controller.abort();
48
- }, [active, adapter, key, manifest, unavailable]);
54
+ }, [active, adapter, attempt, key, manifest, unavailable]);
49
55
  useEffect(() => {
50
56
  if (!active || !manifest || !descriptor)
51
57
  return;
@@ -66,7 +72,9 @@ export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl
66
72
  const next = await adapter.readChunk(response, candidate);
67
73
  if (!controller.signal.aborted) {
68
74
  setStored((current) => current.key === key
69
- ? { ...current, chunks: { ...current.chunks, [candidate.id]: next },
75
+ ? { ...current, chunks: { ...(adapter.retainAdjacentOnly
76
+ ? Object.fromEntries(Object.entries(current.chunks).filter(([id]) => adapter.adjacentChunks(manifest, descriptor).some(item => item.id === id)))
77
+ : current.chunks), [candidate.id]: next },
70
78
  failedChunk: current.failedChunk === candidate.id ? undefined : current.failedChunk }
71
79
  : current);
72
80
  }
@@ -79,7 +87,7 @@ export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl
79
87
  })();
80
88
  }
81
89
  return () => controller.abort();
82
- }, [active, adapter, chunks, descriptor, key, manifest, resolveAssetUrl]);
90
+ }, [active, adapter, attempt, chunks, descriptor, key, manifest, resolveAssetUrl]);
83
91
  return {
84
92
  manifest,
85
93
  chunk,
@@ -87,5 +95,6 @@ export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl
87
95
  loading: active && !unavailable && !error && (!manifest || !chunkReady),
88
96
  unavailable: Boolean(unavailable),
89
97
  error,
98
+ retry,
90
99
  };
91
100
  }
@@ -7,5 +7,6 @@ export declare function useProgressiveNetworkDay(manifestFile: string, active: b
7
7
  loading: boolean;
8
8
  unavailable: boolean;
9
9
  error: boolean;
10
+ retry: () => void;
10
11
  network: import("@motionstudies/core/domain/network").NetworkSnapshot | undefined;
11
12
  };
@@ -6,5 +6,6 @@ export declare function useProgressiveRoadStudy(manifestFile: string, active: bo
6
6
  loading: boolean;
7
7
  unavailable: boolean;
8
8
  error: boolean;
9
+ retry: () => void;
9
10
  snapshot: import("@motionstudies/core/domain/road-day").NationalRoadStudySnapshot | undefined;
10
11
  };
@@ -0,0 +1,12 @@
1
+ export declare const smoothTransition: (progress: number) => number;
2
+ export declare const cosineTransition: (progress: number) => number;
3
+ /** Animate a scalar from its current value; interruptions reverse from the last frame.
4
+ * Reduced-motion changes settle immediately, including during an active transition. */
5
+ export declare function useTransitionValue(target: number, { durationMs, easing, steps, }?: {
6
+ readonly durationMs?: number;
7
+ readonly easing?: (progress: number) => number;
8
+ readonly steps?: number;
9
+ }): {
10
+ value: number;
11
+ transitioning: boolean;
12
+ };