@motionstudies/web 0.1.0-alpha.11 → 0.1.0-alpha.13
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 +36 -0
- package/components/NetworkVehicleHeroCard.d.ts +13 -0
- package/components/NetworkVehicleHeroCard.js +13 -0
- package/components/VehicleHeroCard.d.ts +51 -0
- package/components/VehicleHeroCard.js +21 -0
- package/components/vehicle-hero-card.css +64 -0
- package/package.json +18 -2
- 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.
|
|
@@ -164,6 +172,28 @@ Rail and other transport consumers can share the same time filtering through `mo
|
|
|
164
172
|
|
|
165
173
|
`@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.
|
|
166
174
|
|
|
175
|
+
## Vehicle hero cards
|
|
176
|
+
|
|
177
|
+
`VehicleHeroCard` shows one vehicle's destination, next stop and remaining calling points. Import it from `@motionstudies/web/components/VehicleHeroCard` with `@motionstudies/web/vehicle-hero-card.css`. The **Vehicle heroes** lab compares `uk-bus` (amber onboard display), `uk-rail` (dark rail display), `yellow-bus` (yellow next-stop panel) and `sbb` (blue next-stop panel with a red service badge). These are presentation studies, not official operator components.
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<VehicleHeroCard
|
|
181
|
+
presentation="sbb"
|
|
182
|
+
vehicle={{ service: 'IC 1', operator: 'SBB CFF FFS', destination: 'Genève-Aéroport' }}
|
|
183
|
+
stops={[
|
|
184
|
+
{ id: 'bern', name: 'Bern', time: '10:28', platform: '6' },
|
|
185
|
+
{ id: 'fribourg', name: 'Fribourg/Freiburg', time: '10:56' },
|
|
186
|
+
]}
|
|
187
|
+
note="Synthetic journey · Example calls and times."
|
|
188
|
+
onSelectStop={selectStation}
|
|
189
|
+
selectedStopId={selectedStationId}
|
|
190
|
+
/>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Consumers supply remaining `stops` in journey order and update them with playback, seeking or observations; the card has no independent clock. The first supplied call is the next stop. Optional `time`, `platform` and `detail` are consumer-formatted; absent values stay absent. `vehicle.destination` is optional and shows an explicit unavailable label when missing. The final supplied stop is never inferred to be the destination: set `isDestination` only when confirmed, especially when data is progressively chunked. Empty calls mean no upcoming stops are available, without asserting that the vehicle has arrived.
|
|
194
|
+
|
|
195
|
+
The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
|
|
196
|
+
|
|
167
197
|
## Optional live airport feed
|
|
168
198
|
|
|
169
199
|
`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.
|
|
@@ -193,3 +223,9 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
|
|
|
193
223
|
`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
224
|
|
|
195
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.
|
|
226
|
+
|
|
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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type NetworkSnapshot, type NetworkTrain } from '@motionstudies/core/domain/network';
|
|
2
|
+
import { type VehicleHeroCardProps } from './VehicleHeroCard.tsx';
|
|
3
|
+
export interface NetworkVehicleHeroCardProps extends Omit<VehicleHeroCardProps, 'vehicle' | 'stops'> {
|
|
4
|
+
readonly snapshot: NetworkSnapshot;
|
|
5
|
+
readonly train: NetworkTrain;
|
|
6
|
+
readonly time: number;
|
|
7
|
+
/** Hide clock values for frequency-based or otherwise approximate motion. */
|
|
8
|
+
readonly showTimes?: boolean;
|
|
9
|
+
readonly atStopLabel?: string;
|
|
10
|
+
readonly formatTime?: (time: number) => string;
|
|
11
|
+
}
|
|
12
|
+
/** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
|
|
13
|
+
export declare function NetworkVehicleHeroCard({ snapshot, train, time, showTimes, atStopLabel, formatTime, ...props }: NetworkVehicleHeroCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { formatServiceTime } from '@motionstudies/core/domain/network';
|
|
4
|
+
import { vehicleCalls } from '@motionstudies/core/domain/vehicle-calls';
|
|
5
|
+
import { VehicleHeroCard } from './VehicleHeroCard.js';
|
|
6
|
+
/** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
|
|
7
|
+
export function NetworkVehicleHeroCard({ snapshot, train, time, showTimes = true, atStopLabel = 'At stop', formatTime = (value) => formatServiceTime(value).slice(0, 5), ...props }) {
|
|
8
|
+
const current = useMemo(() => snapshot.trains.find(entry => entry.id === train.id), [snapshot, train.id]);
|
|
9
|
+
const calls = current ? vehicleCalls(snapshot, current, time) : [];
|
|
10
|
+
const identity = current ?? train;
|
|
11
|
+
return _jsx(VehicleHeroCard, { ...props, vehicle: { service: identity.route || identity.shortName,
|
|
12
|
+
destination: identity.headsign.trim() || undefined }, labels: { ...props.labels, ...(calls[0]?.atStop ? { nextStop: atStopLabel } : {}) }, stops: calls.map(call => ({ ...call, time: showTimes ? formatTime(call.time) : undefined })) });
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type VehicleHeroPresentation = 'uk-bus' | 'uk-rail' | 'yellow-bus' | 'sbb';
|
|
3
|
+
export interface VehicleCallingPoint {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** Consumer-formatted arrival time or estimate. Omit when unknown. */
|
|
7
|
+
readonly time?: string;
|
|
8
|
+
readonly platform?: string;
|
|
9
|
+
readonly detail?: string;
|
|
10
|
+
/** Set only for a confirmed destination, never just the end of a data chunk. */
|
|
11
|
+
readonly isDestination?: boolean;
|
|
12
|
+
}
|
|
13
|
+
declare const defaultLabels: {
|
|
14
|
+
bus: string;
|
|
15
|
+
rail: string;
|
|
16
|
+
destination: string;
|
|
17
|
+
destinationUnknown: string;
|
|
18
|
+
nextStop: string;
|
|
19
|
+
callingAt: string;
|
|
20
|
+
terminus: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
loading: string;
|
|
23
|
+
empty: string;
|
|
24
|
+
retry: string;
|
|
25
|
+
};
|
|
26
|
+
export interface VehicleHeroCardProps {
|
|
27
|
+
readonly vehicle: {
|
|
28
|
+
readonly service: string;
|
|
29
|
+
readonly destination?: string;
|
|
30
|
+
readonly operator?: string;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Remaining calls in journey order, starting with the next stop. The consumer owns playback/filtering. */
|
|
34
|
+
readonly stops: readonly VehicleCallingPoint[];
|
|
35
|
+
readonly presentation?: VehicleHeroPresentation;
|
|
36
|
+
/** Explicit source/freshness explanation. */
|
|
37
|
+
readonly note: ReactNode;
|
|
38
|
+
readonly status?: string;
|
|
39
|
+
readonly statusTone?: 'neutral' | 'warning';
|
|
40
|
+
readonly clockLabel?: string;
|
|
41
|
+
readonly labels?: Partial<typeof defaultLabels>;
|
|
42
|
+
readonly loading?: boolean;
|
|
43
|
+
readonly error?: string;
|
|
44
|
+
readonly onRetry?: () => void;
|
|
45
|
+
readonly onSelectStop?: (id: string) => void;
|
|
46
|
+
readonly selectedStopId?: string;
|
|
47
|
+
readonly className?: string;
|
|
48
|
+
}
|
|
49
|
+
/** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
|
|
50
|
+
export declare function VehicleHeroCard({ vehicle, stops, presentation, note, status, statusTone, clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className }: VehicleHeroCardProps): import("react").JSX.Element;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
const defaultLabels = {
|
|
4
|
+
bus: 'Bus', rail: 'Train', destination: 'Destination', destinationUnknown: 'Destination unavailable',
|
|
5
|
+
nextStop: 'Next stop', callingAt: 'Then calling at', terminus: 'Destination', platform: 'Platform',
|
|
6
|
+
loading: 'Loading upcoming stops…', empty: 'No upcoming stops available.', retry: 'Retry',
|
|
7
|
+
};
|
|
8
|
+
/** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
|
|
9
|
+
export function VehicleHeroCard({ vehicle, stops, presentation = 'uk-rail', note, status, statusTone = 'neutral', clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className = '' }) {
|
|
10
|
+
const titleId = useId();
|
|
11
|
+
const copy = { ...defaultLabels, ...labels };
|
|
12
|
+
const bus = presentation === 'uk-bus' || presentation === 'yellow-bus';
|
|
13
|
+
const [next, ...following] = stops;
|
|
14
|
+
const stopContent = (stop) => _jsxs(_Fragment, { children: [_jsxs("span", { className: "ms-vehicle-hero__stop-name", children: [stop.name, stop.isDestination && _jsx("small", { children: copy.terminus }), stop.detail && _jsx("span", { className: "ms-vehicle-hero__detail", children: stop.detail })] }), (stop.time || stop.platform) && _jsxs("span", { className: "ms-vehicle-hero__timing", children: [stop.time && _jsx("strong", { children: stop.time }), stop.platform && _jsxs("small", { children: [copy.platform, " ", stop.platform] })] })] });
|
|
15
|
+
const renderStop = (stop) => onSelectStop
|
|
16
|
+
? _jsx("button", { type: "button", className: "ms-vehicle-hero__stop", "aria-pressed": selectedStopId === stop.id, onClick: () => onSelectStop(stop.id), children: stopContent(stop) })
|
|
17
|
+
: _jsx("div", { className: "ms-vehicle-hero__stop", children: stopContent(stop) });
|
|
18
|
+
return _jsxs("section", { className: `ms-vehicle-hero ${className}`, "data-presentation": presentation, "aria-labelledby": titleId, "aria-busy": loading || undefined, children: [_jsxs("div", { className: "ms-vehicle-hero__masthead", children: [_jsxs("span", { children: [bus ? copy.bus : copy.rail, vehicle.operator ? ` · ${vehicle.operator}` : ''] }), clockLabel && _jsx("span", { children: clockLabel })] }), _jsxs("div", { className: "ms-vehicle-hero__identity", children: [_jsx("strong", { className: "ms-vehicle-hero__service", children: vehicle.service }), _jsxs("div", { children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.destination }), _jsx("h2", { id: titleId, children: vehicle.destination || copy.destinationUnknown }), vehicle.description && _jsx("p", { className: "ms-vehicle-hero__description", children: vehicle.description })] })] }), status && _jsx("div", { className: "ms-vehicle-hero__status", "data-tone": statusTone, children: status }), error ? _jsxs("div", { className: "ms-vehicle-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
19
|
+
: loading ? _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.loading })
|
|
20
|
+
: next ? _jsxs("div", { className: "ms-vehicle-hero__journey", children: [_jsxs("div", { className: "ms-vehicle-hero__next", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.nextStop }), renderStop(next)] }), following.length > 0 && _jsxs("div", { className: "ms-vehicle-hero__following", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.callingAt }), _jsx("ol", { children: following.map((stop) => _jsx("li", { "data-terminus": stop.isDestination || undefined, children: renderStop(stop) }, stop.id)) })] })] }) : _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.empty }), _jsx("div", { className: "ms-vehicle-hero__note", children: note })] });
|
|
21
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
.ms-vehicle-hero {
|
|
2
|
+
--vehicle-bg: #101820; --vehicle-panel: #070b10; --vehicle-ink: #f1b94f;
|
|
3
|
+
--vehicle-text: #f4f4ed; --vehicle-muted: #a9b2ba; --vehicle-line: #33404b;
|
|
4
|
+
--vehicle-badge: #183b55; --vehicle-badge-ink: #fff;
|
|
5
|
+
container-type: inline-size; width: 100%; min-width: 0; overflow: hidden;
|
|
6
|
+
border: 1px solid var(--vehicle-line); border-radius: 8px;
|
|
7
|
+
background: var(--vehicle-bg); color: var(--vehicle-text); font: 16px/1.4 Arial, Helvetica, sans-serif;
|
|
8
|
+
}
|
|
9
|
+
.ms-vehicle-hero, .ms-vehicle-hero * { box-sizing: border-box; }
|
|
10
|
+
.ms-vehicle-hero[data-presentation="uk-bus"] {
|
|
11
|
+
--vehicle-bg: #1a201d; --vehicle-panel: #080d09; --vehicle-ink: #ffbd59;
|
|
12
|
+
--vehicle-badge: #ffbd59; --vehicle-badge-ink: #10150e; --vehicle-line: #3c473d;
|
|
13
|
+
}
|
|
14
|
+
.ms-vehicle-hero[data-presentation="sbb"] {
|
|
15
|
+
--vehicle-bg: #fff; --vehicle-panel: #123579; --vehicle-ink: #fff; --vehicle-text: #162846;
|
|
16
|
+
--vehicle-muted: #54647c; --vehicle-line: #d5dce6; --vehicle-badge: #eb0000; --vehicle-badge-ink: #fff;
|
|
17
|
+
border-radius: 2px;
|
|
18
|
+
}
|
|
19
|
+
.ms-vehicle-hero[data-presentation="yellow-bus"] {
|
|
20
|
+
--vehicle-bg: #fffcf3; --vehicle-panel: #ffdc00; --vehicle-ink: #202318; --vehicle-text: #202318;
|
|
21
|
+
--vehicle-muted: #62624e; --vehicle-line: #d9d5bd; --vehicle-badge: #202318; --vehicle-badge-ink: #ffdc00;
|
|
22
|
+
border-radius: 2px;
|
|
23
|
+
}
|
|
24
|
+
.ms-vehicle-hero__masthead { display: flex; justify-content: space-between; gap: 12px; padding: 15px 24px; color: var(--vehicle-muted); font-size: 12px; font-variant-numeric: tabular-nums; border-bottom: 1px solid var(--vehicle-line); }
|
|
25
|
+
.ms-vehicle-hero__masthead span { overflow-wrap: anywhere; }
|
|
26
|
+
.ms-vehicle-hero__identity { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 20px; padding: 28px 24px; }
|
|
27
|
+
.ms-vehicle-hero__service { background: var(--vehicle-badge); color: var(--vehicle-badge-ink); font-size: clamp(22px, 6cqi, 36px); padding: 8px 12px; line-height: 1.1; max-width: 160px; overflow-wrap: anywhere; }
|
|
28
|
+
.ms-vehicle-hero__eyebrow { margin: 0 0 8px; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; font-weight: 600; }
|
|
29
|
+
.ms-vehicle-hero__identity h2 { margin: 0; font-size: clamp(24px, 5.5cqi, 38px); line-height: 1.12; letter-spacing: -.035em; overflow-wrap: anywhere; font-weight: 600; }
|
|
30
|
+
.ms-vehicle-hero__description { color: var(--vehicle-muted); font-size: 13px; margin: 10px 0 0; overflow-wrap: anywhere; }
|
|
31
|
+
.ms-vehicle-hero__status { margin: 0 24px 20px; padding: 9px 12px; border-left: 3px solid var(--vehicle-muted); font-size: 13px; overflow-wrap: anywhere; }
|
|
32
|
+
.ms-vehicle-hero__status[data-tone="warning"] { border-color: #c87d00; background: #ffe6ae; color: #493000; }
|
|
33
|
+
.ms-vehicle-hero__next { background: var(--vehicle-panel); color: var(--vehicle-ink); padding: 20px 24px 24px; }
|
|
34
|
+
.ms-vehicle-hero__next .ms-vehicle-hero__stop-name { font-size: clamp(23px, 5cqi, 34px); line-height: 1.16; }
|
|
35
|
+
.ms-vehicle-hero[data-presentation^="uk-"] .ms-vehicle-hero__next { font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; }
|
|
36
|
+
.ms-vehicle-hero__stop { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 8px 0; min-width: 0; width: 100%; text-align: left; color: inherit; font: inherit; }
|
|
37
|
+
.ms-vehicle-hero button.ms-vehicle-hero__stop { border: 0; border-radius: 0; background: none; cursor: pointer; }
|
|
38
|
+
.ms-vehicle-hero button.ms-vehicle-hero__stop:hover { box-shadow: 0 1px currentColor; }
|
|
39
|
+
.ms-vehicle-hero button.ms-vehicle-hero__stop[aria-pressed="true"] { box-shadow: 0 2px currentColor; }
|
|
40
|
+
.ms-vehicle-hero button:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
|
41
|
+
.ms-vehicle-hero__stop-name { min-width: 0; overflow-wrap: anywhere; font-size: 17px; font-weight: 600; }
|
|
42
|
+
.ms-vehicle-hero__stop small, .ms-vehicle-hero__detail { display: block; font: 12px/1.5 Arial, Helvetica, sans-serif; margin-top: 5px; }
|
|
43
|
+
.ms-vehicle-hero__timing { flex-shrink: 0; max-width: 40%; text-align: right; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
|
44
|
+
.ms-vehicle-hero__timing strong { font-size: 16px; font-weight: 500; }
|
|
45
|
+
.ms-vehicle-hero__following { padding: 24px; }
|
|
46
|
+
.ms-vehicle-hero__following > p { color: var(--vehicle-muted); }
|
|
47
|
+
.ms-vehicle-hero__following ol { list-style: none; padding: 0 0 0 20px; margin: 14px 0 0; }
|
|
48
|
+
.ms-vehicle-hero__following li { position: relative; padding: 2px 0 14px 14px; border-left: 2px solid var(--vehicle-line); }
|
|
49
|
+
.ms-vehicle-hero__following li:last-child { padding-bottom: 0; border-color: transparent; }
|
|
50
|
+
.ms-vehicle-hero__following li:last-child::after { content: ''; position: absolute; left: -2px; top: 0; width: 2px; height: 18px; background: var(--vehicle-line); }
|
|
51
|
+
.ms-vehicle-hero__following li::before { content: ''; position: absolute; width: 9px; height: 9px; border: 2px solid var(--vehicle-text); background: var(--vehicle-bg); border-radius: 50%; left: -5.5px; top: 18px; }
|
|
52
|
+
.ms-vehicle-hero__following li[data-terminus]::before { background: var(--vehicle-text); }
|
|
53
|
+
.ms-vehicle-hero__message { padding: 28px 24px; min-height: 120px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px; overflow-wrap: anywhere; }
|
|
54
|
+
.ms-vehicle-hero__message button { padding: 8px 12px; border: 1px solid currentColor; color: inherit; background: transparent; font: inherit; cursor: pointer; }
|
|
55
|
+
.ms-vehicle-hero__note { padding: 16px 24px; border-top: 1px solid var(--vehicle-line); color: var(--vehicle-muted); font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
56
|
+
@container (max-width: 360px) {
|
|
57
|
+
.ms-vehicle-hero__identity { gap: 12px; padding: 22px 16px; }
|
|
58
|
+
.ms-vehicle-hero__service { max-width: 100px; padding: 8px; }
|
|
59
|
+
.ms-vehicle-hero__masthead, .ms-vehicle-hero__next, .ms-vehicle-hero__following, .ms-vehicle-hero__note, .ms-vehicle-hero__message { padding-left: 16px; padding-right: 16px; }
|
|
60
|
+
.ms-vehicle-hero__status { margin-left: 16px; margin-right: 16px; }
|
|
61
|
+
.ms-vehicle-hero__stop { gap: 10px; }
|
|
62
|
+
.ms-vehicle-hero__next .ms-vehicle-hero__stop { flex-wrap: wrap; }
|
|
63
|
+
.ms-vehicle-hero__next .ms-vehicle-hero__timing { max-width: 100%; text-align: left; }
|
|
64
|
+
}
|
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.13",
|
|
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",
|
|
@@ -121,6 +126,12 @@
|
|
|
121
126
|
"import": "./components/BusStopHeroCard.js",
|
|
122
127
|
"default": "./components/BusStopHeroCard.js"
|
|
123
128
|
},
|
|
129
|
+
"./components/VehicleHeroCard": {
|
|
130
|
+
"types": "./components/VehicleHeroCard.d.ts",
|
|
131
|
+
"import": "./components/VehicleHeroCard.js",
|
|
132
|
+
"default": "./components/VehicleHeroCard.js"
|
|
133
|
+
},
|
|
134
|
+
"./vehicle-hero-card.css": "./components/vehicle-hero-card.css",
|
|
124
135
|
"./components/RailStationHeroCard": {
|
|
125
136
|
"types": "./components/RailStationHeroCard.d.ts",
|
|
126
137
|
"import": "./components/RailStationHeroCard.js",
|
|
@@ -136,6 +147,11 @@
|
|
|
136
147
|
"types": "./use-transition-value.d.ts",
|
|
137
148
|
"import": "./use-transition-value.js",
|
|
138
149
|
"default": "./use-transition-value.js"
|
|
150
|
+
},
|
|
151
|
+
"./components/NetworkVehicleHeroCard": {
|
|
152
|
+
"types": "./components/NetworkVehicleHeroCard.d.ts",
|
|
153
|
+
"import": "./components/NetworkVehicleHeroCard.js",
|
|
154
|
+
"default": "./components/NetworkVehicleHeroCard.js"
|
|
139
155
|
}
|
|
140
156
|
},
|
|
141
157
|
"files": [
|
|
@@ -151,7 +167,7 @@
|
|
|
151
167
|
"**/*.css"
|
|
152
168
|
],
|
|
153
169
|
"dependencies": {
|
|
154
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
170
|
+
"@motionstudies/core": "0.1.0-alpha.13"
|
|
155
171
|
},
|
|
156
172
|
"peerDependencies": {
|
|
157
173
|
"react": "^19.2.8",
|
|
@@ -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
|
}
|