@motionstudies/web 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +12 -0
- package/components/AirportHeroCard.d.ts +3 -1
- package/components/AirportHeroCard.js +2 -2
- package/components/airport-hero-card.css +26 -0
- package/package.json +9 -3
- package/study-layout.css +65 -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/README.md
CHANGED
|
@@ -25,6 +25,14 @@ Build release candidates with `npm run build:packages`. Distribution manifests a
|
|
|
25
25
|
|
|
26
26
|
Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
|
|
27
27
|
|
|
28
|
+
## Timetable patterns, station calls and observation windows
|
|
29
|
+
|
|
30
|
+
`@motionstudies/core/domain/network-patterns` provides a transport-neutral repeated-schedule codec. `@motionstudies/web/use-pattern-network-day` lazily decodes current/adjacent chunks after verifying byte length and SHA-256; it exposes retry and drops old-source state when the manifest URL changes. Supply stable options and a stable asset resolver. Format/category options support existing edition wire contracts.
|
|
31
|
+
|
|
32
|
+
`@motionstudies/core/domain/station-calls` supplies station calls, independent source status, namespaced source combination and upcoming-call filtering. Source IDs, repeated visits and acquired windows survive combination. Consumers supply interchange IDs and passenger restrictions; matching display names do not collapse services.
|
|
33
|
+
|
|
34
|
+
`@motionstudies/data/observation-windows` compiles bounded acquired records into hashed windows with explicit time semantics, conflict policy and acquisition-gap reporting. It preserves supplied record IDs, values and source references. It uses elapsed UTC seconds, including 23/25-hour study days, and performs no acquisition or inference. See [contracts, limits and parity evidence](../docs/ALLCHANGE-FOUNDATIONS.md).
|
|
35
|
+
|
|
28
36
|
## Now and browser location
|
|
29
37
|
|
|
30
38
|
`useNowClock` from `@motionstudies/web/use-now-clock` follows the wall clock on each animation frame, so returning from a suspended tab catches up immediately. Call `start()` from a Now button, set the consumer's playback rate to 1, and render its `time` while `active`. With `NationalNetworkScene`, pass `isPlaying={false}` while this external clock owns time. Call `stop()` before pausing, seeking or changing speed, and retain the last clock time for ordinary playback. Clear moving vehicle selections on entry to keep the camera still; panning and zooming remain available.
|
|
@@ -217,3 +225,7 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
|
|
|
217
225
|
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.
|
|
218
226
|
|
|
219
227
|
For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motionstudies/web/components/NetworkVehicleHeroCard` accepts `snapshot`, `train`, playback `time`, a required source `note` and the vehicle card's presentation/state/label options. Import `vehicle-hero-card.css`. It resolves the selected train ID against the current snapshot, retaining destination identity but showing no stale calls when that ID is absent. Calls stay in journey order, use stable journey/call IDs and follow backward seeking. During dwell the heading becomes `atStopLabel` (default “At stop”) and the time is the departure; otherwise it is the upcoming arrival. Times are formatted with `formatServiceTime` by default, without normalizing after midnight or adding delay twice. Use `showTimes={false}` for frequency-modelled journeys, and explain the model in `note`. The core `vehicleCalls` helper is also exported from `@motionstudies/core/domain/vehicle-calls` for custom adapters. Selection callback IDs refer to calls, not station indexes.
|
|
228
|
+
|
|
229
|
+
### Railway preparation
|
|
230
|
+
|
|
231
|
+
`@motionstudies/data/rail-wtt`, `rail-journeys`, `rail-public-calls`, `rail-routing` and `rail-geometry` expose typed offline railway preparation. XLSX reading, dated source-column audits, UID/date joins, public-call anchors and connected OSM paths preserve source evidence. Editions supply regional identities, calendar exceptions and clipping policy. Node 24+ and `unzip` are required; no Python runtime is shipped. See `docs/SHARED-RAIL-READERS.md` in the source repository for calendar limits and parity evidence.
|
|
@@ -52,7 +52,9 @@ export interface AirportHeroCardProps {
|
|
|
52
52
|
readonly selectedFlightId?: string;
|
|
53
53
|
readonly initialDirection?: 'departures' | 'arrivals';
|
|
54
54
|
readonly className?: string;
|
|
55
|
+
/** Compact panels retain the same controls and evidence in less vertical space. */
|
|
56
|
+
readonly density?: 'comfortable' | 'compact';
|
|
55
57
|
}
|
|
56
58
|
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
57
|
-
export declare function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows, dateLabel, formatTime, labels, loading, error, onRetry, onSelectFlight, selectedFlightId, initialDirection, className }: AirportHeroCardProps): import("react").JSX.Element;
|
|
59
|
+
export declare function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows, dateLabel, formatTime, labels, loading, error, onRetry, onSelectFlight, selectedFlightId, initialDirection, className, density }: AirportHeroCardProps): import("react").JSX.Element;
|
|
58
60
|
export {};
|
|
@@ -11,7 +11,7 @@ const defaultLabels = {
|
|
|
11
11
|
studyTime: 'Study time', boardWindow: 'Board window', outsideWindow: 'Outside study window',
|
|
12
12
|
};
|
|
13
13
|
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
14
|
-
export function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows = 8, dateLabel, formatTime = formatServiceTime, labels, loading = false, error, onRetry, onSelectFlight, selectedFlightId, initialDirection = 'departures', className = '' }) {
|
|
14
|
+
export function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows = 8, dateLabel, formatTime = formatServiceTime, labels, loading = false, error, onRetry, onSelectFlight, selectedFlightId, initialDirection = 'departures', className = '', density }) {
|
|
15
15
|
const [direction, setDirection] = useState(initialDirection);
|
|
16
16
|
const titleId = useId();
|
|
17
17
|
const boardId = useId();
|
|
@@ -20,7 +20,7 @@ export function AirportHeroCard({ airport, departures, arrivals, note, study, ho
|
|
|
20
20
|
const entries = movementsForBoard(direction === 'departures' ? departures : arrivals, window, maxRows);
|
|
21
21
|
const rows = entries.map((entry) => ({ id: entry.id, tone: entry.tone,
|
|
22
22
|
cells: { time: formatTime(entry.time), service: entry.service, place: entry.place, stand: entry.stand, status: entry.status } }));
|
|
23
|
-
return _jsxs("section", { className: `ms-airport-hero ${className}`, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-airport-hero__masthead", children: [_jsxs("span", { className: "ms-airport-hero__eyebrow", children: [_jsx("span", { "aria-hidden": "true", children: "\u2197" }), " ", copy.airport, " / ", airport.city] }), dateLabel && _jsx("span", { className: "ms-airport-hero__date", children: dateLabel })] }), _jsxs("div", { className: "ms-airport-hero__identity", children: [_jsx("strong", { className: "ms-airport-hero__code", children: airport.iata }), _jsx("h2", { id: titleId, children: airport.name }), _jsxs("div", { className: "ms-airport-hero__clock", children: [_jsx("span", { className: "ms-airport-hero__clock-label", children: copy.studyTime }), ' ', _jsx("strong", { className: "ms-airport-hero__clock-value", children: Number.isFinite(study.time) ? formatTime(study.time) : '—' })] })] }), _jsxs("div", { className: "ms-airport-hero__board-header", children: [_jsx("div", { className: "ms-airport-hero__directions", role: "group", "aria-label": `${copy.departures} / ${copy.arrivals}`, children: ['departures', 'arrivals'].map((value) => _jsxs("button", { type: "button", "aria-pressed": direction === value, "aria-controls": boardId, onClick: () => setDirection(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'departures' ? '↗' : '↘' }), " ", copy[value]] }, value)) }), _jsx("p", { className: "ms-airport-hero__window", children: window ? `${copy.boardWindow} ${formatTime(window.start)}–${formatTime(window.end)}` : copy.outsideWindow })] }), _jsx("div", { id: boardId, children: error ? _jsxs("div", { className: "ms-airport-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
23
|
+
return _jsxs("section", { className: `ms-airport-hero ${className}`, "data-density": density, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-airport-hero__masthead", children: [_jsxs("span", { className: "ms-airport-hero__eyebrow", children: [_jsx("span", { "aria-hidden": "true", children: "\u2197" }), " ", copy.airport, " / ", airport.city] }), dateLabel && _jsx("span", { className: "ms-airport-hero__date", children: dateLabel })] }), _jsxs("div", { className: "ms-airport-hero__identity", children: [_jsx("strong", { className: "ms-airport-hero__code", children: airport.iata }), _jsx("h2", { id: titleId, children: airport.name }), _jsxs("div", { className: "ms-airport-hero__clock", children: [_jsx("span", { className: "ms-airport-hero__clock-label", children: copy.studyTime }), ' ', _jsx("strong", { className: "ms-airport-hero__clock-value", children: Number.isFinite(study.time) ? formatTime(study.time) : '—' })] })] }), _jsxs("div", { className: "ms-airport-hero__board-header", children: [_jsx("div", { className: "ms-airport-hero__directions", role: "group", "aria-label": `${copy.departures} / ${copy.arrivals}`, children: ['departures', 'arrivals'].map((value) => _jsxs("button", { type: "button", "aria-pressed": direction === value, "aria-controls": boardId, onClick: () => setDirection(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'departures' ? '↗' : '↘' }), " ", copy[value]] }, value)) }), _jsx("p", { className: "ms-airport-hero__window", children: window ? `${copy.boardWindow} ${formatTime(window.start)}–${formatTime(window.end)}` : copy.outsideWindow })] }), _jsx("div", { id: boardId, children: error ? _jsxs("div", { className: "ms-airport-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
24
24
|
: _jsx(SplitFlapBoard, { label: `${airport.iata} ${copy[direction]}`, columns: [
|
|
25
25
|
{ key: 'time', label: copy.time, characters: 5 },
|
|
26
26
|
{ key: 'service', label: copy.service, characters: 7 },
|
|
@@ -40,3 +40,29 @@
|
|
|
40
40
|
.ms-airport-board__modes button:focus-visible, .ms-airport-board__unavailable button:focus-visible { outline: 2px solid #edc96b; outline-offset: 3px; }
|
|
41
41
|
.ms-airport-board__unavailable { padding: 28px; border: 1px solid #4b4d40; border-radius: 12px; color: #f0ecdd; background: #1d211c; font: 14px/1.6 ui-sans-serif, system-ui, sans-serif; }
|
|
42
42
|
.ms-airport-board__detail { color: #b8baaa; font: 13px/1.6 ui-sans-serif, system-ui, sans-serif; overflow-wrap: anywhere; }
|
|
43
|
+
|
|
44
|
+
/* Shared compact presentation, responsive to card width rather than the window. */
|
|
45
|
+
.ms-airport-hero[data-density='compact'] { padding: 12px; }
|
|
46
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__identity { padding: 8px 0; gap: 12px; }
|
|
47
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__code { font-size: 40px; }
|
|
48
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__identity h2 { font-size: 20px; }
|
|
49
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__clock-value { font-size: 28px; }
|
|
50
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__directions { margin-bottom: 8px; }
|
|
51
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__directions button { padding: 6px 0 8px; }
|
|
52
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__window { margin-bottom: 8px; }
|
|
53
|
+
.ms-airport-hero[data-density='compact'] .ms-split-flap-board { padding: 6px 8px 8px; }
|
|
54
|
+
.ms-airport-hero[data-density='compact'] .ms-split-flap-board__table { border-spacing: 0 4px; }
|
|
55
|
+
.ms-airport-hero[data-density='compact'] .ms-split-flap-board__select { min-height: 28px; }
|
|
56
|
+
.ms-airport-hero[data-density='compact'] .ms-flap { font-size: 13px; }
|
|
57
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__note { margin-top: 8px; font-size: 10px; line-height: 1.4; }
|
|
58
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__note a { color: inherit; }
|
|
59
|
+
@container (min-width: 441px) {
|
|
60
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__identity { grid-template-columns: auto minmax(0, 1fr) auto; }
|
|
61
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__clock { grid-column: auto; display: block; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #797c6155; text-align: right; }
|
|
62
|
+
}
|
|
63
|
+
@container (max-width: 440px) {
|
|
64
|
+
.ms-airport-hero[data-density='compact'] .ms-airport-hero__identity h2 { font-size: 16px; }
|
|
65
|
+
}
|
|
66
|
+
@media (pointer: coarse) {
|
|
67
|
+
.ms-airport-hero[data-density='compact'] .ms-split-flap-board__select { min-height: 44px; }
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/web",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Browser controls and loaders for Motion Studies.",
|
|
@@ -87,6 +87,11 @@
|
|
|
87
87
|
"import": "./use-progressive-network-day.js",
|
|
88
88
|
"default": "./use-progressive-network-day.js"
|
|
89
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
|
+
},
|
|
90
95
|
"./use-progressive-road-study": {
|
|
91
96
|
"types": "./use-progressive-road-study.d.ts",
|
|
92
97
|
"import": "./use-progressive-road-study.js",
|
|
@@ -147,7 +152,8 @@
|
|
|
147
152
|
"types": "./components/NetworkVehicleHeroCard.d.ts",
|
|
148
153
|
"import": "./components/NetworkVehicleHeroCard.js",
|
|
149
154
|
"default": "./components/NetworkVehicleHeroCard.js"
|
|
150
|
-
}
|
|
155
|
+
},
|
|
156
|
+
"./study-layout.css": "./study-layout.css"
|
|
151
157
|
},
|
|
152
158
|
"files": [
|
|
153
159
|
"**/*.js",
|
|
@@ -162,7 +168,7 @@
|
|
|
162
168
|
"**/*.css"
|
|
163
169
|
],
|
|
164
170
|
"dependencies": {
|
|
165
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
171
|
+
"@motionstudies/core": "0.1.0-alpha.14"
|
|
166
172
|
},
|
|
167
173
|
"peerDependencies": {
|
|
168
174
|
"react": "^19.2.8",
|
package/study-layout.css
ADDED
|
@@ -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,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
|
+
}
|
|
@@ -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
|
}
|