@motionstudies/web 0.1.0-alpha.6 → 0.1.0-alpha.7
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 +17 -1
- package/components/AirportBoard.d.ts +27 -0
- package/components/AirportBoard.js +28 -0
- package/components/AirportHeroCard.js +4 -4
- package/components/airport-hero-card.css +32 -11
- package/package.json +12 -2
- package/use-airport-feed.d.ts +9 -0
- package/use-airport-feed.js +72 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Shared packages for the Motion Studies transport instrument. The source workspac
|
|
|
5
5
|
- `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
|
|
6
6
|
- `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
|
|
7
7
|
- `@motionstudies/web`: picker, button tooltips, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
|
|
8
|
-
- `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
|
|
8
|
+
- `@motionstudies/data`: Node-only GTFS readers, ADS-B heatmap compilation, air endpoint enrichment, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
|
|
9
9
|
|
|
10
10
|
```tsx
|
|
11
11
|
import { MobilePicker } from '@motionstudies/web/components/MobilePicker'
|
|
@@ -83,3 +83,19 @@ Empty messages also appear on the flaps, in the widest column (the destination/o
|
|
|
83
83
|
Rail and other transport consumers can share the same time filtering through `movementBoardWindow(study, horizon)` and `movementsForBoard(entries, window, maxRows)` from `@motionstudies/core/domain/movement-board`. Format the returned numeric times when mapping them into `SplitFlapBoard` cells. The lab's rail board follows the same study clock and horizon as its airport card.
|
|
84
84
|
|
|
85
85
|
`@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
|
|
86
|
+
|
|
87
|
+
## Optional live airport feed
|
|
88
|
+
|
|
89
|
+
`AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
|
|
90
|
+
|
|
91
|
+
Live timestamps are Unix seconds and use the airport's timezone for display, independently of recorded service time. The wrapper does not pass live flight IDs to the recorded scene's selection callback. The recorded card remains mounted while hidden; the edition still owns playback and can pause its study when appropriate. Only Now mode fetches flight boards; checking capabilities does not query the paid provider. Stale results carry their retrieval time and disappear when expired. Source data never falls back to synthetic or recorded flights under a live label.
|
|
92
|
+
|
|
93
|
+
See [service architecture and operations](../docs/LIVE-AIRPORTS.md). The Worker is deployed separately; npm publication and edition adoption remain explicit release steps.
|
|
94
|
+
|
|
95
|
+
## Shared recorded air compilation
|
|
96
|
+
|
|
97
|
+
`@motionstudies/data/adsb-heatmap` consolidates the offline heatmap pipeline previously copied between editions. `ingestAdsbHeatmaps` reads cached gzip slices, decodes observations, filters transport-scale tracks, splits flights, and writes either an opening snapshot or an indexed day with overlapping chunks. Source hashes, chunk hashes and ODbL attribution accompany the output. It makes no network requests.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
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).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AirportHeroCardProps } from './AirportHeroCard.tsx';
|
|
2
|
+
declare const defaults: {
|
|
3
|
+
study: string;
|
|
4
|
+
now: string;
|
|
5
|
+
checking: string;
|
|
6
|
+
unavailable: string;
|
|
7
|
+
returnToStudy: string;
|
|
8
|
+
loading: string;
|
|
9
|
+
updated: string;
|
|
10
|
+
stale: string;
|
|
11
|
+
localTime: string;
|
|
12
|
+
scheduled: string;
|
|
13
|
+
revised: string;
|
|
14
|
+
note: string;
|
|
15
|
+
};
|
|
16
|
+
export interface AirportBoardProps {
|
|
17
|
+
readonly studyCard: AirportHeroCardProps;
|
|
18
|
+
readonly live: {
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
readonly edition: string;
|
|
21
|
+
readonly airport: string;
|
|
22
|
+
};
|
|
23
|
+
readonly labels?: Partial<typeof defaults>;
|
|
24
|
+
}
|
|
25
|
+
/** Optional live enhancement. Keeps the study clock and recorded flight selection separate. */
|
|
26
|
+
export declare function AirportBoard({ studyCard, live, labels }: AirportBoardProps): import("react").JSX.Element;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { AirportHeroCard } from './AirportHeroCard.js';
|
|
4
|
+
import { useAirportFeed } from '../use-airport-feed.js';
|
|
5
|
+
import { useNowClock } from '../use-now-clock.js';
|
|
6
|
+
const defaults = {
|
|
7
|
+
study: 'Study', now: 'Now', checking: 'Checking live availability…', unavailable: 'Live updates unavailable.',
|
|
8
|
+
returnToStudy: 'Return to study', loading: 'Loading current airport board…', updated: 'Retrieved', stale: 'Updates delayed',
|
|
9
|
+
localTime: 'Local time', scheduled: 'Scheduled', revised: 'Revised', note: 'AeroDataBox · Scheduled or revised times as supplied. Revisions may be estimated or actual. All times local. Coverage may be incomplete.',
|
|
10
|
+
};
|
|
11
|
+
/** Optional live enhancement. Keeps the study clock and recorded flight selection separate. */
|
|
12
|
+
export function AirportBoard({ studyCard, live, labels }) {
|
|
13
|
+
const copy = { ...defaults, ...labels };
|
|
14
|
+
const clock = useNowClock((instant) => Math.floor(instant.getTime() / 1000));
|
|
15
|
+
const feed = useAirportFeed(live.baseUrl, live.edition, live.airport, clock.active);
|
|
16
|
+
const [selected, setSelected] = useState();
|
|
17
|
+
const response = feed.response, snapshot = response?.snapshot;
|
|
18
|
+
const now = clock.time ?? snapshot?.fetchedAt ?? 0;
|
|
19
|
+
const usable = snapshot && snapshot.expiresAt > now && ['fresh', 'stale'].includes(response.status);
|
|
20
|
+
const stale = usable && (response?.status === 'stale' || now >= snapshot.freshUntil);
|
|
21
|
+
const timeZone = snapshot?.airport.timeZone ?? 'UTC';
|
|
22
|
+
const formatter = new Intl.DateTimeFormat(undefined, { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
23
|
+
const formatTime = (time) => formatter.format(new Date(time * 1000));
|
|
24
|
+
const dateLabel = new Intl.DateTimeFormat(undefined, { timeZone, day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(now * 1000));
|
|
25
|
+
function stop() { clock.stop(); setSelected(undefined); }
|
|
26
|
+
const selectedFlight = usable ? [...snapshot.departures, ...snapshot.arrivals].find((flight) => flight.id === selected) : undefined;
|
|
27
|
+
return _jsxs("div", { className: "ms-airport-board", children: [_jsxs("div", { className: "ms-airport-board__modes", role: "group", "aria-label": `${copy.study} / ${copy.now}`, children: [_jsx("button", { type: "button", "aria-pressed": !clock.active, onClick: stop, children: copy.study }), _jsx("button", { type: "button", "aria-pressed": clock.active, disabled: !clock.active && !feed.available, onClick: clock.start, children: copy.now }), !clock.active && _jsx("span", { children: feed.checking ? copy.checking : !feed.available ? copy.unavailable : null })] }), _jsx("div", { hidden: clock.active, children: _jsx(AirportHeroCard, { ...studyCard }) }), clock.active && _jsx(_Fragment, { children: usable ? _jsxs(_Fragment, { children: [_jsx(AirportHeroCard, { airport: snapshot.airport, study: { time: now, windowStart: snapshot.windowStart, windowEnd: snapshot.windowEnd }, departures: snapshot.departures.map((f) => ({ ...f, time: f.revisedTime ?? f.scheduledTime, stand: f.gate })), arrivals: snapshot.arrivals.map((f) => ({ ...f, time: f.revisedTime ?? f.scheduledTime, stand: f.gate })), formatTime: formatTime, dateLabel: dateLabel, labels: { ...studyCard.labels, studyTime: copy.localTime }, maxRows: studyCard.maxRows, horizon: studyCard.horizon, selectedFlightId: selected, onSelectFlight: setSelected, note: _jsxs(_Fragment, { children: [_jsxs("span", { role: "status", children: [stale ? `${copy.stale} · ` : '', copy.updated, " ", formatTime(snapshot.fetchedAt), " \u00B7 "] }), copy.note] }) }, snapshot.airport.iata), selectedFlight && _jsxs("p", { className: "ms-airport-board__detail", children: [selectedFlight.service, " \u00B7 ", selectedFlight.place ?? '—', " \u00B7 ", selectedFlight.status ?? '—', selectedFlight.scheduledTime !== undefined && _jsxs(_Fragment, { children: [" \u00B7 ", copy.scheduled, " ", formatTime(selectedFlight.scheduledTime)] }), selectedFlight.revisedTime !== undefined && _jsxs(_Fragment, { children: [" \u00B7 ", copy.revised, " ", formatTime(selectedFlight.revisedTime)] })] })] }) : _jsxs("div", { className: "ms-airport-board__unavailable", role: "status", children: [_jsx("p", { children: feed.loading || feed.checking ? copy.loading : copy.unavailable }), _jsx("button", { type: "button", onClick: stop, children: copy.returnToStudy })] }) })] });
|
|
28
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId, useState } from 'react';
|
|
3
3
|
import { formatServiceTime } from '@motionstudies/core/domain/network';
|
|
4
4
|
import { movementBoardWindow, movementsForBoard } from '@motionstudies/core/domain/movement-board';
|
|
@@ -20,12 +20,12 @@ 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] }),
|
|
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 })] })
|
|
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 },
|
|
27
|
-
{ key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters:
|
|
27
|
+
{ key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters: 18 },
|
|
28
28
|
{ key: 'stand', label: copy.stand, characters: 3 },
|
|
29
|
-
{ key: 'status', label: copy.status, characters:
|
|
29
|
+
{ key: 'status', label: copy.status, characters: 11 },
|
|
30
30
|
], rows: rows, loading: loading, loadingMessage: copy.loading, loadingRows: rows.length || Math.min(maxRows, 5), emptyMessage: direction === 'departures' ? copy.emptyDepartures : copy.emptyArrivals, selectionColumn: "service", onSelectRow: onSelectFlight, selectedRowId: selectedFlightId }) }), _jsx("div", { className: "ms-airport-hero__note", children: note })] });
|
|
31
31
|
}
|
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
@import './split-flap-board.css';
|
|
2
2
|
|
|
3
|
-
.ms-airport-hero { --airport-accent: #edc96b; color: #f0ecdd; background: linear-gradient(125deg, #292d27, #191d19 65%); border: 1px solid #4b4d40; border-radius: 12px; padding: clamp(16px, 3vw, 30px); box-shadow: 0 20px 60px #0003; font-family: ui-sans-serif, system-ui, sans-serif; min-width: 0; }
|
|
4
|
-
.ms-airport-hero__masthead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
|
3
|
+
.ms-airport-hero { --airport-accent: #edc96b; container-type: inline-size; color: #f0ecdd; background: linear-gradient(125deg, #292d27, #191d19 65%); border: 1px solid #4b4d40; border-radius: 12px; padding: clamp(16px, 3vw, 30px); box-shadow: 0 20px 60px #0003; font-family: ui-sans-serif, system-ui, sans-serif; min-width: 0; }
|
|
4
|
+
.ms-airport-hero__masthead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }
|
|
5
5
|
.ms-airport-hero__eyebrow { color: var(--airport-accent); text-transform: uppercase; font-size: 10px; letter-spacing: .18em; }
|
|
6
6
|
.ms-airport-hero__eyebrow > span { font-size: 18px; margin-right: 6px; }
|
|
7
|
-
.ms-airport-
|
|
8
|
-
.ms-airport-
|
|
9
|
-
.ms-airport-
|
|
10
|
-
.ms-airport-
|
|
11
|
-
.ms-airport-
|
|
12
|
-
.ms-airport-
|
|
13
|
-
.ms-airport-
|
|
14
|
-
.ms-airport-
|
|
7
|
+
.ms-airport-hero__date { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; letter-spacing: .06em; }
|
|
8
|
+
.ms-airport-hero__identity { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 18px 24px; padding: 18px 0 20px; }
|
|
9
|
+
.ms-airport-hero__code { color: var(--airport-accent); font-size: clamp(54px, 10cqi, 82px); font-weight: 650; line-height: .95; letter-spacing: -.065em; }
|
|
10
|
+
.ms-airport-hero__identity h2 { max-width: 300px; color: #f0ecdd; font: 400 clamp(20px, 3.3cqi, 27px)/1.15 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
|
|
11
|
+
.ms-airport-hero__clock { padding-left: 24px; border-left: 1px solid #797c6155; text-align: right; }
|
|
12
|
+
.ms-airport-hero__clock-label { display: block; color: #bebfac; font-size: 10px; line-height: 1.5; letter-spacing: .12em; text-transform: uppercase; }
|
|
13
|
+
.ms-airport-hero__clock-value { display: block; margin-top: 4px; color: #f0ecdd; font: 400 42px/1.1 ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: -.06em; white-space: nowrap; }
|
|
14
|
+
.ms-airport-hero__board-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0 20px; border-bottom: 1px solid #4b4d40; margin-bottom: 14px; }
|
|
15
|
+
.ms-airport-hero__directions { display: flex; gap: 22px; }
|
|
16
|
+
.ms-airport-hero__directions button { min-height: 44px; padding: 10px 0 12px; margin-bottom: -1px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: #b8baaa; font: 500 13px/1.4 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
|
|
15
17
|
.ms-airport-hero__directions button[aria-pressed='true'] { color: var(--airport-accent); border-bottom-color: var(--airport-accent); }
|
|
16
18
|
.ms-airport-hero__directions button:hover { color: #f0ecdd; }
|
|
19
|
+
.ms-airport-hero__window { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; font-variant-numeric: tabular-nums; margin: 8px 0; }
|
|
17
20
|
.ms-airport-hero button:focus-visible { outline: 2px solid var(--airport-accent); outline-offset: 4px; }
|
|
21
|
+
.ms-airport-hero .ms-flap { font-size: 17px; }
|
|
22
|
+
.ms-airport-hero .ms-split-flap-board__table th { color: #c4c3b0; font-size: 11px; letter-spacing: .12em; padding-bottom: 5px; }
|
|
18
23
|
.ms-airport-hero__message { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 30px 0; margin: 0; font-size: 14px; color: #c5c7b6; }
|
|
19
24
|
.ms-airport-hero__message button { background: #30352a; border: 1px solid #666b50; border-radius: 4px; color: var(--airport-accent); padding: 6px 12px; font: inherit; cursor: pointer; }
|
|
20
25
|
.ms-airport-hero__note { color: #b8baaa; font-size: 11px; line-height: 1.6; margin-top: 14px; }
|
|
21
|
-
@
|
|
26
|
+
@container (max-width: 600px) {
|
|
27
|
+
.ms-airport-hero__identity { grid-template-columns: auto minmax(0, 1fr); gap: 16px; }
|
|
28
|
+
.ms-airport-hero__clock { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 0; border-left: 0; border-top: 1px solid #797c6155; text-align: left; }
|
|
29
|
+
.ms-airport-hero__clock-value { margin-top: 0; font-size: 32px; }
|
|
30
|
+
}
|
|
31
|
+
@container (max-width: 440px) {
|
|
32
|
+
.ms-airport-hero__board-header { gap: 4px; }
|
|
33
|
+
.ms-airport-hero__directions { width: 100%; gap: 20px; }
|
|
34
|
+
.ms-airport-hero__window { margin: 6px 0 10px; }
|
|
35
|
+
}
|
|
36
|
+
.ms-airport-board__modes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; color: #b8baaa; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; }
|
|
37
|
+
.ms-airport-board__modes button, .ms-airport-board__unavailable button { min-height: 44px; padding: 8px 16px; border: 1px solid #4b4d40; border-radius: 6px; color: #f0ecdd; background: #1d211c; font: inherit; cursor: pointer; }
|
|
38
|
+
.ms-airport-board__modes button[aria-pressed='true'] { border-color: #edc96b; color: #edc96b; }
|
|
39
|
+
.ms-airport-board__modes button:disabled { opacity: .5; cursor: default; }
|
|
40
|
+
.ms-airport-board__modes button:focus-visible, .ms-airport-board__unavailable button:focus-visible { outline: 2px solid #edc96b; outline-offset: 3px; }
|
|
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
|
+
.ms-airport-board__detail { color: #b8baaa; font: 13px/1.6 ui-sans-serif, system-ui, sans-serif; overflow-wrap: anywhere; }
|
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.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Browser controls and loaders for Motion Studies.",
|
|
@@ -99,6 +99,16 @@
|
|
|
99
99
|
"types": "./components/ButtonTooltips.d.ts",
|
|
100
100
|
"import": "./components/ButtonTooltips.js",
|
|
101
101
|
"default": "./components/ButtonTooltips.js"
|
|
102
|
+
},
|
|
103
|
+
"./use-airport-feed": {
|
|
104
|
+
"types": "./use-airport-feed.d.ts",
|
|
105
|
+
"import": "./use-airport-feed.js",
|
|
106
|
+
"default": "./use-airport-feed.js"
|
|
107
|
+
},
|
|
108
|
+
"./components/AirportBoard": {
|
|
109
|
+
"types": "./components/AirportBoard.d.ts",
|
|
110
|
+
"import": "./components/AirportBoard.js",
|
|
111
|
+
"default": "./components/AirportBoard.js"
|
|
102
112
|
}
|
|
103
113
|
},
|
|
104
114
|
"files": [
|
|
@@ -114,7 +124,7 @@
|
|
|
114
124
|
"**/*.css"
|
|
115
125
|
],
|
|
116
126
|
"dependencies": {
|
|
117
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
127
|
+
"@motionstudies/core": "0.1.0-alpha.7"
|
|
118
128
|
},
|
|
119
129
|
"peerDependencies": {
|
|
120
130
|
"react": "^19.2.8",
|
|
@@ -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
|
+
}
|