@motionstudies/web 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +247 -2
- package/components/AirportBoard.d.ts +27 -0
- package/components/AirportBoard.js +28 -0
- package/components/AirportHeroCard.d.ts +60 -0
- package/components/AirportHeroCard.js +31 -0
- package/components/BusStopHeroCard.d.ts +37 -0
- package/components/BusStopHeroCard.js +14 -0
- package/components/DotMatrixBoard.d.ts +31 -0
- package/components/DotMatrixBoard.js +84 -0
- package/components/LazyButtonTooltips.d.ts +2 -0
- package/components/LazyButtonTooltips.js +8 -0
- package/components/NetworkVehicleHeroCard.d.ts +13 -0
- package/components/NetworkVehicleHeroCard.js +13 -0
- package/components/RailStationHeroCard.d.ts +46 -0
- package/components/RailStationHeroCard.js +20 -0
- package/components/SbbDepartureBoard.d.ts +26 -0
- package/components/SbbDepartureBoard.js +29 -0
- package/components/SplitFlapBoard.d.ts +25 -0
- package/components/SplitFlapBoard.js +58 -0
- package/components/StudyTimeline.d.ts +25 -0
- package/components/StudyTimeline.js +14 -0
- package/components/TimelineScrubber.d.ts +19 -0
- package/components/TimelineScrubber.js +26 -0
- package/components/TransportHeroBoard.d.ts +28 -0
- package/components/TransportHeroBoard.js +15 -0
- package/components/VehicleHeroCard.d.ts +51 -0
- package/components/VehicleHeroCard.js +21 -0
- package/components/airport-hero-card.css +68 -0
- package/components/dot-matrix-board.css +49 -0
- package/components/dot-matrix-font.d.ts +2 -0
- package/components/dot-matrix-font.js +61 -0
- package/components/sbb-departure-board.css +49 -0
- package/components/split-flap-board.css +45 -0
- package/components/study-timeline.css +8 -0
- package/components/timeline-scrubber.css +16 -0
- package/components/transport-hero-cards.css +39 -0
- package/components/vehicle-hero-card.css +64 -0
- package/fonts/DM-Mono-OFL.txt +93 -0
- package/fonts/Inter-OFL.txt +93 -0
- package/fonts/dm-mono-latin-300.woff2 +0 -0
- package/fonts/dm-mono-latin-400.woff2 +0 -0
- package/fonts/dm-mono-latin-500.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-300.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-400.woff2 +0 -0
- package/fonts/dm-mono-latin-ext-500.woff2 +0 -0
- package/fonts/inter-cyrillic-400-600.woff2 +0 -0
- package/fonts/inter-cyrillic-ext-400-600.woff2 +0 -0
- package/fonts/inter-greek-400-600.woff2 +0 -0
- package/fonts/inter-greek-ext-400-600.woff2 +0 -0
- package/fonts/inter-latin-400-600.woff2 +0 -0
- package/fonts/inter-latin-ext-400-600.woff2 +0 -0
- package/fonts/inter-vietnamese-400-600.woff2 +0 -0
- package/fonts.css +105 -0
- package/mount-motion-study.d.ts +7 -2
- package/mount-motion-study.js +2 -2
- package/package.json +94 -3
- package/study-layout.css +65 -0
- package/use-airport-feed.d.ts +9 -0
- package/use-airport-feed.js +72 -0
- package/use-browser-location.d.ts +13 -0
- package/use-browser-location.js +39 -0
- package/use-json-asset.d.ts +9 -0
- package/use-json-asset.js +45 -0
- package/use-now-clock.d.ts +11 -0
- package/use-now-clock.js +45 -0
- package/use-pattern-network-day.d.ts +17 -0
- package/use-pattern-network-day.js +41 -0
- package/use-progressive-air-day.d.ts +1 -0
- package/use-progressive-chunks.d.ts +3 -0
- package/use-progressive-chunks.js +13 -4
- package/use-progressive-network-day.d.ts +1 -0
- package/use-progressive-road-study.d.ts +1 -0
- package/use-transition-value.d.ts +12 -0
- package/use-transition-value.js +46 -0
|
@@ -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
|
+
}
|
|
@@ -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: { ...
|
|
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
|
}
|
|
@@ -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
|
+
}
|