@motionstudies/web 0.1.0-alpha.8 → 0.1.0-alpha.9

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 CHANGED
@@ -179,3 +179,17 @@ See [service architecture and operations](../docs/LIVE-AIRPORTS.md). The Worker
179
179
  The same `decodeAdsbHeatmap` now powers `enrichAirEndpoints`; endpoint inference retains full coordinate precision, while playback compilation retains the existing five-decimal coordinates. `transportAirTracks` and `chunkAirSnapshot` are also available for consumers that assemble their own pipeline. All functions have public TypeScript declarations and work in the packed Node package.
180
180
 
181
181
  Editions supply geographic bounds, service date, explicit UTC offset, optional timezone, input files and output paths. Flight IDs and chunk overlap retain the existing contracts. The default splits known callsign changes and gaps over 30 minutes. Set `splitTracks: false` only when reproducing a legacy opening snapshot with one ID per aircraft. See [adoption and compatibility](../docs/AIR-DATA.md).
182
+
183
+ ## Shared edition controllers and performance
184
+
185
+ `positionForTrain` now indexes chronological stop times with binary search and retains sequential behavior for unordered observations. Stop arrays are immutable: replace the array when a timetable changes. Arrival/departure boundaries, dwell, cancellation and backward seeking retain the existing contract.
186
+
187
+ `countableVehicleTrains(network, stations, selection)` and `createActiveTimetableVehicleCounter(trains, options)` from `@motionstudies/core/domain/vehicle-counts` separate station/route/category membership from clock updates. Build the selector and counter with `useMemo` when data or selection changes, then call the counter at the displayed time. It includes both interval endpoints and excludes cancellations and inverted intervals. Missing stations yield no matches. By default it counts timetable intervals even if a journey lacks enough stops to position; `{ requirePositionable: true }` excludes journeys with fewer than two stops. This distinction is explicit so an edition can retain its established metric.
188
+
189
+ The renderer now shares active GPU upload ranges, paused frame reuse, label and trail frame budgets, cached text comparators and batched hub lines. Custom layers can import the low-level helpers from `@motionstudies/three/render-performance`. Recreate frame trackers when their geometry/data/selection inputs change; `batchHubLines` takes ownership of two-vertex source line resources. Edition-specific worker transfer, picking and cartographic adapters remain consumer-owned.
190
+
191
+ `useJsonAsset<T>(url, enabled, parse?, optional?)` from `@motionstudies/web/use-json-asset` loads a single asset lazily and exposes `data`, `loading`, `error`, `unavailable`, and `retry`. Keep the parser stable and perform edition-specific schema/source compatibility checks there. Successful data remains cached while disabled; consumers decide whether to display it. Changing the URL or parser immediately discards prior-source state, and disabling/unmounting cancels requests. `retry()` discards cached state and requests again when enabled. Optional HTTP 404 responses are unavailable; other failures are errors. No source fallback or freshness policy is inferred.
192
+
193
+ `useTransitionValue(target, { durationMs, easing, steps })` from `@motionstudies/web/use-transition-value` animates a numeric value, returning `value` and `transitioning`. It reverses from the current frame, cancels on teardown, and settles immediately when reduced motion becomes active. `smoothTransition` is the default easing; `cosineTransition` and stepped progress support existing edition rhythms. Keep custom easing functions stable. Camera actions and lazy layout loading stay in the edition.
194
+
195
+ Edition chunk scripts can call `runNetworkChunkCli()` from `@motionstudies/data/network-chunk-cli`. It accepts the existing `--input`, `--manifest`, `--opening`, `--chunk-hours`, `--opening-start`, `--opening-end`, and `--focus` arguments. Source acquisition, provenance, output paths and command invocation remain edition-owned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -126,7 +126,17 @@
126
126
  "import": "./components/RailStationHeroCard.js",
127
127
  "default": "./components/RailStationHeroCard.js"
128
128
  },
129
- "./transport-hero-cards.css": "./components/transport-hero-cards.css"
129
+ "./transport-hero-cards.css": "./components/transport-hero-cards.css",
130
+ "./use-json-asset": {
131
+ "types": "./use-json-asset.d.ts",
132
+ "import": "./use-json-asset.js",
133
+ "default": "./use-json-asset.js"
134
+ },
135
+ "./use-transition-value": {
136
+ "types": "./use-transition-value.d.ts",
137
+ "import": "./use-transition-value.js",
138
+ "default": "./use-transition-value.js"
139
+ }
130
140
  },
131
141
  "files": [
132
142
  "**/*.js",
@@ -141,7 +151,7 @@
141
151
  "**/*.css"
142
152
  ],
143
153
  "dependencies": {
144
- "@motionstudies/core": "0.1.0-alpha.8"
154
+ "@motionstudies/core": "0.1.0-alpha.9"
145
155
  },
146
156
  "peerDependencies": {
147
157
  "react": "^19.2.8",
@@ -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,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
+ };
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ export const smoothTransition = (progress) => progress * progress * (3 - 2 * progress);
3
+ export const cosineTransition = (progress) => 0.5 - Math.cos(progress * Math.PI) / 2;
4
+ /** Animate a scalar from its current value; interruptions reverse from the last frame.
5
+ * Reduced-motion changes settle immediately, including during an active transition. */
6
+ export function useTransitionValue(target, { durationMs = 1600, easing = smoothTransition, steps, } = {}) {
7
+ const current = useRef(target);
8
+ const [value, setValue] = useState(target);
9
+ const [transitioning, setTransitioning] = useState(false);
10
+ useEffect(() => {
11
+ const from = current.current;
12
+ const motion = matchMedia('(prefers-reduced-motion: reduce)');
13
+ let frame = 0;
14
+ const finish = () => {
15
+ cancelAnimationFrame(frame);
16
+ current.current = target;
17
+ setValue(target);
18
+ setTransitioning(false);
19
+ };
20
+ if (!Number.isFinite(target) || !Number.isFinite(from))
21
+ return;
22
+ if (motion.matches || from === target || !(durationMs > 0) || !Number.isFinite(durationMs)) {
23
+ finish();
24
+ return;
25
+ }
26
+ const started = performance.now();
27
+ setTransitioning(true);
28
+ const tick = (now) => {
29
+ const progress = Math.min(1, (now - started) / durationMs);
30
+ const eased = easing(progress);
31
+ const fraction = steps && Number.isFinite(steps) && steps > 0 ? Math.round(eased * steps) / steps : eased;
32
+ current.current = from + (target - from) * fraction;
33
+ setValue(current.current);
34
+ if (progress < 1)
35
+ frame = requestAnimationFrame(tick);
36
+ else
37
+ finish();
38
+ };
39
+ frame = requestAnimationFrame(tick);
40
+ const changed = () => { if (motion.matches)
41
+ finish(); };
42
+ motion.addEventListener('change', changed);
43
+ return () => { cancelAnimationFrame(frame); motion.removeEventListener('change', changed); };
44
+ }, [target, durationMs, easing, steps]);
45
+ return { value, transitioning };
46
+ }