@motionstudies/web 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/components/MobilePicker.d.ts +16 -0
- package/components/MobilePicker.js +92 -0
- package/components/mobile-picker.css +147 -0
- package/data-url.d.ts +3 -0
- package/data-url.js +5 -0
- package/mount-motion-study.d.ts +3 -0
- package/mount-motion-study.js +13 -0
- package/package.json +96 -0
- package/recording.d.ts +11 -0
- package/recording.js +62 -0
- package/shell.css +715 -0
- package/tokens.css +17 -0
- package/use-local-performance.d.ts +5 -0
- package/use-local-performance.js +34 -0
- package/use-observed-operations.d.ts +8 -0
- package/use-observed-operations.js +46 -0
- package/use-progressive-air-day.d.ts +10 -0
- package/use-progressive-air-day.js +13 -0
- package/use-progressive-chunks.d.ts +24 -0
- package/use-progressive-chunks.js +91 -0
- package/use-progressive-network-day.d.ts +11 -0
- package/use-progressive-network-day.js +29 -0
- package/use-progressive-road-study.d.ts +10 -0
- package/use-progressive-road-study.js +14 -0
- package/visual-theme.d.ts +2 -0
- package/visual-theme.js +12 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useLocalPerformance(enabled) {
|
|
3
|
+
const [sample, setSample] = useState();
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
if (!enabled)
|
|
6
|
+
return;
|
|
7
|
+
let animationFrame = 0;
|
|
8
|
+
let frames = 0;
|
|
9
|
+
let slowFrames = 0;
|
|
10
|
+
let previous = performance.now();
|
|
11
|
+
let windowStart = previous;
|
|
12
|
+
const measure = (now) => {
|
|
13
|
+
const frameDuration = now - previous;
|
|
14
|
+
previous = now;
|
|
15
|
+
frames += 1;
|
|
16
|
+
if (frameDuration > 34)
|
|
17
|
+
slowFrames += 1;
|
|
18
|
+
const elapsed = now - windowStart;
|
|
19
|
+
if (elapsed >= 1_000) {
|
|
20
|
+
setSample({
|
|
21
|
+
fps: Math.round((frames * 1_000) / elapsed),
|
|
22
|
+
slowFramePercent: Math.round((slowFrames / frames) * 100),
|
|
23
|
+
});
|
|
24
|
+
frames = 0;
|
|
25
|
+
slowFrames = 0;
|
|
26
|
+
windowStart = now;
|
|
27
|
+
}
|
|
28
|
+
animationFrame = requestAnimationFrame(measure);
|
|
29
|
+
};
|
|
30
|
+
animationFrame = requestAnimationFrame(measure);
|
|
31
|
+
return () => cancelAnimationFrame(animationFrame);
|
|
32
|
+
}, [enabled]);
|
|
33
|
+
return sample;
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TransitOperationsSnapshot } from '@motionstudies/core/domain/operations';
|
|
2
|
+
export interface ObservedOperationsState {
|
|
3
|
+
readonly snapshot?: TransitOperationsSnapshot;
|
|
4
|
+
readonly loading: boolean;
|
|
5
|
+
readonly error: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Poll after each response settles, retaining only the current source's data. */
|
|
8
|
+
export declare function useObservedOperations(endpoint: string, enabled: boolean, refreshMilliseconds?: number): ObservedOperationsState;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
/** Poll after each response settles, retaining only the current source's data. */
|
|
3
|
+
export function useObservedOperations(endpoint, enabled, refreshMilliseconds = 30_000) {
|
|
4
|
+
if (!Number.isFinite(refreshMilliseconds) || refreshMilliseconds <= 0) {
|
|
5
|
+
throw new Error('Observation refresh interval must be positive');
|
|
6
|
+
}
|
|
7
|
+
const [stored, setStored] = useState({ endpoint, loading: false, error: false });
|
|
8
|
+
const state = stored.endpoint === endpoint ? stored : { endpoint, loading: false, error: false };
|
|
9
|
+
if (stored.endpoint !== endpoint)
|
|
10
|
+
setStored(state);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!enabled)
|
|
13
|
+
return;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let refresh;
|
|
16
|
+
function update(patch) {
|
|
17
|
+
if (!controller.signal.aborted) {
|
|
18
|
+
setStored((current) => current.endpoint === endpoint ? { ...current, ...patch } : current);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const load = async () => {
|
|
22
|
+
update({ loading: true });
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(endpoint, { cache: 'no-store', signal: controller.signal });
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
throw new Error(`Observed operations returned ${response.status}`);
|
|
27
|
+
const snapshot = await response.json();
|
|
28
|
+
update({ snapshot, error: false });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
update({ error: true });
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
update({ loading: false });
|
|
35
|
+
if (!controller.signal.aborted)
|
|
36
|
+
refresh = window.setTimeout(() => void load(), refreshMilliseconds);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
void load();
|
|
40
|
+
return () => {
|
|
41
|
+
controller.abort();
|
|
42
|
+
window.clearTimeout(refresh);
|
|
43
|
+
};
|
|
44
|
+
}, [enabled, endpoint, refreshMilliseconds]);
|
|
45
|
+
return { snapshot: state.snapshot, loading: enabled && state.loading, error: state.error };
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AirDayManifest } from '@motionstudies/core/domain/air-day';
|
|
2
|
+
import type { DataUrlResolver } from './data-url.ts';
|
|
3
|
+
export declare function useProgressiveAirDay(manifestFile: string, active: boolean, time: number, resolveAssetUrl: DataUrlResolver): {
|
|
4
|
+
manifest: AirDayManifest | undefined;
|
|
5
|
+
chunkReady: boolean;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
unavailable: boolean;
|
|
8
|
+
error: boolean;
|
|
9
|
+
snapshot: import("@motionstudies/core/domain/air").AirSnapshot | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { adjacentAirDayChunks, airDayChunkForTime, airSnapshotForDayChunk, } from '@motionstudies/core/domain/air-day';
|
|
3
|
+
import { useProgressiveChunks } from './use-progressive-chunks.js';
|
|
4
|
+
const adapter = {
|
|
5
|
+
chunkForTime: airDayChunkForTime,
|
|
6
|
+
adjacentChunks: adjacentAirDayChunks,
|
|
7
|
+
readChunk: (response) => response.json(),
|
|
8
|
+
};
|
|
9
|
+
export function useProgressiveAirDay(manifestFile, active, time, resolveAssetUrl) {
|
|
10
|
+
const { chunk, ...state } = useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter);
|
|
11
|
+
const snapshot = useMemo(() => state.manifest ? airSnapshotForDayChunk(state.manifest, chunk) : undefined, [state.manifest, chunk]);
|
|
12
|
+
return { ...state, snapshot };
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DataUrlResolver } from './data-url.ts';
|
|
2
|
+
interface ChunkDescriptor {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}
|
|
6
|
+
interface ChunkManifest<Descriptor extends ChunkDescriptor> {
|
|
7
|
+
readonly chunks: readonly Descriptor[];
|
|
8
|
+
}
|
|
9
|
+
export interface ProgressiveChunkAdapter<Manifest, Descriptor, Chunk> {
|
|
10
|
+
readonly chunkForTime: (manifest: Manifest, time: number) => Descriptor;
|
|
11
|
+
readonly adjacentChunks: (manifest: Manifest, current: Descriptor) => readonly Descriptor[];
|
|
12
|
+
readonly readChunk: (response: Response, descriptor: Descriptor) => Promise<Chunk>;
|
|
13
|
+
readonly optional?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Internal lifecycle shared by the network, aircraft and road loaders. */
|
|
16
|
+
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>): {
|
|
17
|
+
manifest: Manifest | undefined;
|
|
18
|
+
chunk: Chunk | undefined;
|
|
19
|
+
chunkReady: boolean;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
unavailable: boolean;
|
|
22
|
+
error: boolean;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
/** Internal lifecycle shared by the network, aircraft and road loaders. */
|
|
3
|
+
export function useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter) {
|
|
4
|
+
const key = resolveAssetUrl(manifestFile);
|
|
5
|
+
const [stored, setStored] = useState({ key, chunks: {} });
|
|
6
|
+
// Reset during render so no effect (or consumer) sees another source's data.
|
|
7
|
+
// Late responses are also rejected below, independently of fetch cancellation.
|
|
8
|
+
const state = stored.key === key ? stored : { key, chunks: {} };
|
|
9
|
+
if (stored.key !== key)
|
|
10
|
+
setStored(state);
|
|
11
|
+
const { manifest, chunks, manifestError, unavailable, failedChunk } = state;
|
|
12
|
+
const descriptor = useMemo(() => manifest?.chunks.length ? adapter.chunkForTime(manifest, time) : undefined, [adapter, manifest, time]);
|
|
13
|
+
const chunk = descriptor ? chunks[descriptor.id] : undefined;
|
|
14
|
+
const chunkReady = chunk !== undefined;
|
|
15
|
+
const error = Boolean(manifestError || (descriptor && failedChunk === descriptor.id));
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active || manifest || unavailable)
|
|
18
|
+
return;
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
void (async () => {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(key, { signal: controller.signal });
|
|
23
|
+
if (adapter.optional && response.status === 404) {
|
|
24
|
+
if (!controller.signal.aborted) {
|
|
25
|
+
setStored((current) => current.key === key ? { ...current, unavailable: true } : current);
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
throw new Error(`Day manifest returned ${response.status}`);
|
|
31
|
+
const next = await response.json();
|
|
32
|
+
if (!Array.isArray(next.chunks) || !next.chunks.length) {
|
|
33
|
+
throw new Error('Day manifest has no chunks');
|
|
34
|
+
}
|
|
35
|
+
if (!controller.signal.aborted) {
|
|
36
|
+
setStored((current) => current.key === key
|
|
37
|
+
? { ...current, manifest: next, manifestError: false }
|
|
38
|
+
: current);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
if (!controller.signal.aborted) {
|
|
43
|
+
setStored((current) => current.key === key ? { ...current, manifestError: true } : current);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
return () => controller.abort();
|
|
48
|
+
}, [active, adapter, key, manifest, unavailable]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!active || !manifest || !descriptor)
|
|
51
|
+
return;
|
|
52
|
+
const currentMissing = chunks[descriptor.id] === undefined;
|
|
53
|
+
const targets = currentMissing
|
|
54
|
+
? [descriptor]
|
|
55
|
+
: adapter.adjacentChunks(manifest, descriptor).filter((candidate) => chunks[candidate.id] === undefined);
|
|
56
|
+
if (!targets.length)
|
|
57
|
+
return;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
// Keep successful prefetches even when a neighbouring chunk fails.
|
|
60
|
+
for (const candidate of targets) {
|
|
61
|
+
void (async () => {
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(resolveAssetUrl(candidate.path), { signal: controller.signal });
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
throw new Error(`Day chunk returned ${response.status}`);
|
|
66
|
+
const next = await adapter.readChunk(response, candidate);
|
|
67
|
+
if (!controller.signal.aborted) {
|
|
68
|
+
setStored((current) => current.key === key
|
|
69
|
+
? { ...current, chunks: { ...current.chunks, [candidate.id]: next },
|
|
70
|
+
failedChunk: current.failedChunk === candidate.id ? undefined : current.failedChunk }
|
|
71
|
+
: current);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (!controller.signal.aborted && currentMissing) {
|
|
76
|
+
setStored((current) => current.key === key ? { ...current, failedChunk: candidate.id } : current);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
return () => controller.abort();
|
|
82
|
+
}, [active, adapter, chunks, descriptor, key, manifest, resolveAssetUrl]);
|
|
83
|
+
return {
|
|
84
|
+
manifest,
|
|
85
|
+
chunk,
|
|
86
|
+
chunkReady,
|
|
87
|
+
loading: active && !unavailable && !error && (!manifest || !chunkReady),
|
|
88
|
+
unavailable: Boolean(unavailable),
|
|
89
|
+
error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NetworkDayChunk, NetworkDayChunkDescriptor, NetworkDayManifest } from '@motionstudies/core/domain/network';
|
|
2
|
+
import type { DataUrlResolver } from './data-url.ts';
|
|
3
|
+
export declare function verifiedNetworkDayChunk(response: Response, descriptor: NetworkDayChunkDescriptor): Promise<NetworkDayChunk>;
|
|
4
|
+
export declare function useProgressiveNetworkDay(manifestFile: string, active: boolean, time: number, resolveAssetUrl: DataUrlResolver): {
|
|
5
|
+
manifest: NetworkDayManifest | undefined;
|
|
6
|
+
chunkReady: boolean;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
unavailable: boolean;
|
|
9
|
+
error: boolean;
|
|
10
|
+
network: import("@motionstudies/core/domain/network").NetworkSnapshot | undefined;
|
|
11
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
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 verifiedNetworkDayChunk(response, descriptor) {
|
|
5
|
+
const bytes = await response.arrayBuffer();
|
|
6
|
+
if (descriptor.bytes !== undefined && bytes.byteLength !== descriptor.bytes) {
|
|
7
|
+
throw new Error(`Network day chunk ${descriptor.id} has an unexpected size`);
|
|
8
|
+
}
|
|
9
|
+
if (descriptor.sha256) {
|
|
10
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
11
|
+
const actual = [...new Uint8Array(digest)]
|
|
12
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
13
|
+
.join('');
|
|
14
|
+
if (actual !== descriptor.sha256) {
|
|
15
|
+
throw new Error(`Network day chunk ${descriptor.id} failed its integrity check`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
19
|
+
}
|
|
20
|
+
const adapter = {
|
|
21
|
+
chunkForTime: dayChunkForTime,
|
|
22
|
+
adjacentChunks: adjacentDayChunks,
|
|
23
|
+
readChunk: verifiedNetworkDayChunk,
|
|
24
|
+
};
|
|
25
|
+
export function useProgressiveNetworkDay(manifestFile, active, time, resolveAssetUrl) {
|
|
26
|
+
const { chunk, ...state } = useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter);
|
|
27
|
+
const network = useMemo(() => state.manifest ? networkSnapshotForDayChunk(state.manifest, chunk) : undefined, [state.manifest, chunk]);
|
|
28
|
+
return { ...state, network };
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type NationalRoadStudyManifest } from '@motionstudies/core/domain/road-day';
|
|
2
|
+
import type { DataUrlResolver } from './data-url.ts';
|
|
3
|
+
export declare function useProgressiveRoadStudy(manifestFile: string, active: boolean, time: number, resolveAssetUrl: DataUrlResolver): {
|
|
4
|
+
manifest: NationalRoadStudyManifest | undefined;
|
|
5
|
+
chunkReady: boolean;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
unavailable: boolean;
|
|
8
|
+
error: boolean;
|
|
9
|
+
snapshot: import("@motionstudies/core/domain/road-day").NationalRoadStudySnapshot | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { adjacentRoadChunks, roadChunkForTime, roadSnapshotForChunk, } from '@motionstudies/core/domain/road-day';
|
|
3
|
+
import { useProgressiveChunks } from './use-progressive-chunks.js';
|
|
4
|
+
const adapter = {
|
|
5
|
+
chunkForTime: roadChunkForTime,
|
|
6
|
+
adjacentChunks: adjacentRoadChunks,
|
|
7
|
+
readChunk: (response) => response.json(),
|
|
8
|
+
optional: true,
|
|
9
|
+
};
|
|
10
|
+
export function useProgressiveRoadStudy(manifestFile, active, time, resolveAssetUrl) {
|
|
11
|
+
const { chunk, ...state } = useProgressiveChunks(manifestFile, active, time, resolveAssetUrl, adapter);
|
|
12
|
+
const snapshot = useMemo(() => state.manifest ? roadSnapshotForChunk(state.manifest, chunk) : undefined, [state.manifest, chunk]);
|
|
13
|
+
return { ...state, snapshot };
|
|
14
|
+
}
|
package/visual-theme.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function applyVisualTheme(theme, root = document.documentElement) {
|
|
2
|
+
root.style.setProperty('--background', theme.background);
|
|
3
|
+
root.style.setProperty('--ink', theme.ink);
|
|
4
|
+
root.style.setProperty('--muted', theme.muted);
|
|
5
|
+
root.style.setProperty('--line', theme.line);
|
|
6
|
+
root.style.setProperty('--cyan', theme.primary);
|
|
7
|
+
root.style.setProperty('--pink', theme.secondary);
|
|
8
|
+
root.style.setProperty('--panel', theme.panel);
|
|
9
|
+
root.style.setProperty('--air', theme.air);
|
|
10
|
+
root.style.setProperty('--road-light', theme.roadLight);
|
|
11
|
+
root.style.setProperty('--road-heavy', theme.roadHeavy);
|
|
12
|
+
}
|