mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5cf579174 | ||
|
|
332551ec84 | ||
|
|
6e655b801d | ||
|
|
083da40d88 | ||
|
|
16d163a886 | ||
|
|
c653b3878d | ||
|
|
53cbd55181 | ||
|
|
a91a89ec1c | ||
|
|
c43bf103d6 | ||
|
|
6c55daf9ac | ||
|
|
9b46692c64 | ||
|
|
4bb7b411e9 | ||
|
|
9d01f8df77 | ||
|
|
f0ee1b95ca | ||
|
|
f29b0b2639 | ||
|
|
7f18943ec4 | ||
|
|
0325805eb0 | ||
|
|
73bd952736 | ||
|
|
7df581765d | ||
|
|
974023510f | ||
|
|
92d34bc4cc | ||
|
|
546ac22708 | ||
|
|
432a29ba59 | ||
|
|
a9d390c165 | ||
|
|
df41c0affb | ||
|
|
d42b6fe764 | ||
|
|
fbc1a9a0ec | ||
|
|
22f43aab01 | ||
|
|
a7d722a1f4 | ||
|
|
0584292405 | ||
|
|
09f3380203 | ||
|
|
c24352ff5e | ||
|
|
feaf59776c | ||
|
|
4ae67fcb35 | ||
|
|
fe7590d66a | ||
|
|
557fe7169a | ||
|
|
a17346be6f | ||
|
|
294ccc0ee8 | ||
|
|
a4f321a9ef | ||
|
|
6fbc98952f | ||
|
|
4c33552105 | ||
|
|
1e4060f6ba | ||
|
|
57d87d2bbf | ||
|
|
01a74fceec | ||
|
|
f75179eea0 | ||
|
|
29cf9ff8f6 | ||
|
|
897a74ea27 | ||
|
|
bd1da596b8 | ||
|
|
6976cbc941 | ||
|
|
771ed98e3b | ||
|
|
282ad6c5f6 | ||
|
|
d1f4d1acdc | ||
|
|
ebaa439739 | ||
|
|
265074aa54 | ||
|
|
c763c38193 | ||
|
|
2448662574 | ||
|
|
d9e34f10c6 | ||
|
|
02a8442e6e | ||
|
|
628de8386d | ||
|
|
abfc85e03d | ||
|
|
fbec1488ac |
No files matched your search
@@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
|
||||
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
|
||||
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
|
||||
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- When the globe stops drawing after a render error or a lost WebGL context,
|
||||
an alert dialog says so instead of leaving a frozen or black globe behind
|
||||
working controls. Retry restarts drawing (Reload after a lost context), and
|
||||
Reset view reopens the app without the view restored from the address bar.
|
||||
Cesium's "Rendering has stopped" panel no longer appears in the app
|
||||
(German, @gekh, #1579).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Let an application add its own Cockpit subjects through
|
||||
`services.cockpitSubjects`. A provider supplies the subject's position,
|
||||
heading and HUD (in the page's language, via `getLocale` and
|
||||
`onLocaleChange` from `./i18n`); the camera can follow it without the
|
||||
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
|
||||
banner, a veil (off under reduced motion) and a bottom progress line. The
|
||||
Rail and Transit layers expose read and hold APIs so a selected train or
|
||||
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
|
||||
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
|
||||
provider. GEV itself registers none, and entry to the built-in aircraft,
|
||||
satellite and vessel views is unchanged; a provider's subject takes entry
|
||||
when it was selected after them (Denis Groshev, @ZloyS, #1686).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Draw a vessel whose feed sends no length at its ship type's typical size, so
|
||||
a cruise ship or container ship no longer shows as its type's smallest model
|
||||
(samehkhamis, #1715).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Collapse the CCTV panel when the layer is switched off. It stayed open with
|
||||
an empty frame and a meta line that still described the last camera; the
|
||||
meta line of a disabled layer now reads the catalog count. Contacts, Space
|
||||
Missions and Clear selected data layers leave the panel as it is
|
||||
(asturksever, #1723).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Draw airport ground vehicles and ground stations as what they are instead of
|
||||
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
|
||||
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
|
||||
radio mast, and one with no altitude stands on the ground instead of at the
|
||||
10 km airborne default (samehkhamis, #1729).
|
||||
@@ -230,3 +230,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
loader with `node --import=<core package>/build/register-vue`.
|
||||
|
||||
### Cockpit subjects
|
||||
|
||||
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
|
||||
selected AIS vessel. An application adds kinds of its own with
|
||||
`services.cockpitSubjects` on `createApplicationControls`: a list of
|
||||
providers, checked once when the controls are built
|
||||
(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
|
||||
that throws when read (a getter), is left out with a warning and the rest are
|
||||
kept. A provider's subject takes entry when it was selected after every
|
||||
built-in subject present (a tracked aircraft or satellite, a selected vessel),
|
||||
whose own fixed order is unchanged, and needs the provider's `layerId` on; the
|
||||
provider then holds its subject, hands the camera a frame and the generic HUD
|
||||
a view model on every update, and releases it on exit. Provider methods are
|
||||
synchronous and are called on the provider, so a class instance's methods keep
|
||||
their `this`; one that throws or returns a promise is treated as having
|
||||
failed. What a method returns may carry getters: one that throws when read
|
||||
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
|
||||
the last; a worded member (labels, entry face, look) that throws keeps its
|
||||
last good words.
|
||||
|
||||
```js
|
||||
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
|
||||
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
|
||||
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: {
|
||||
view: 'Fahrtansicht',
|
||||
exit: 'Fahrtansicht verlassen',
|
||||
exitText: 'VERLASSEN',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
|
||||
const ride = {
|
||||
kind: 'example-ride',
|
||||
layerId: 'rail',
|
||||
entryFace: { icon: 'train', label: 'RIDE' },
|
||||
// A function is read again when the page's language changes.
|
||||
labels: () => words[getLocale()] ?? words.en,
|
||||
candidate() {
|
||||
const train = rail.getSelectedTrain();
|
||||
return (
|
||||
train && {
|
||||
id: train.key,
|
||||
name: train.label,
|
||||
selectedAt: train.selectedAtMs,
|
||||
}
|
||||
);
|
||||
},
|
||||
// Only a train bound to its line can be ridden: read() needs its path.
|
||||
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
|
||||
hold: (id) => rail.setCockpitSubject(id),
|
||||
read(nowMs, ctx) {
|
||||
const train = rail.getCockpitSubject();
|
||||
if (!train?.path) return null; // ends the view
|
||||
const point = pointAtS(train.path.line, train.path.s);
|
||||
return {
|
||||
id: train.key,
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
heightM: train.position.railHeightM, // null stands it on the shared floor
|
||||
name: train.label,
|
||||
headingDeg:
|
||||
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
|
||||
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
|
||||
// The layer already moves the train smoothly: sit on it.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5 },
|
||||
};
|
||||
},
|
||||
hud: (frame) => ({ title: frame.name }),
|
||||
release(id, { restore }) {
|
||||
rail.setCockpitSubject(null);
|
||||
if (restore) rail.focusTrain(id);
|
||||
},
|
||||
};
|
||||
|
||||
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
|
||||
```
|
||||
|
||||
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
|
||||
Core translates only its own strings, so a provider words its labels, entry
|
||||
face and HUD for the page's language (labels may be functions, as above; the
|
||||
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
|
||||
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
|
||||
for a provider placing a train along its line. The frame, HUD view and
|
||||
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
|
||||
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
||||
`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in
|
||||
it changed is the one read before, so `read` may take one on every update; a
|
||||
train's `path.line` is a frozen copy of its line, for `pointAtS` and
|
||||
`projectOnPath`. The `layers/rail/paths` export and the
|
||||
Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
|
||||
`getCockpitSubject` and the snapshots they return, `path.line` among them)
|
||||
are experimental and may change without notice.
|
||||
|
||||
A candidate's `selectedAt` is a token that changes each time the user selects
|
||||
the subject, even the same one again, so entry counts it as selected then; a
|
||||
new `id` under the same token is the same selection (a train's selection
|
||||
moving to another feed's copy of it). The Rail and Transit snapshots give one,
|
||||
`selectedAtMs`: when the user last selected the train or vehicle by a click or
|
||||
`focusTrain`/`focusVehicle`. It is null after a deselection and for one
|
||||
selected only by a hold, since a hold is not the user's selection.
|
||||
|
||||
A frame with `heightM: null` stands on the shared floor, or where it last
|
||||
stood while that floor is cold. If no height has been known since entry, the
|
||||
view waits, showing ACQUIRING SURFACE, for at most five seconds
|
||||
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject.
|
||||
|
||||
`hold(id)` returns `true` once it holds the subject, or `false` to refuse
|
||||
entry; any other answer, `undefined` included, refuses entry too, with one
|
||||
warning. A refused hold, and one that throws or returns a promise, is followed
|
||||
by `release(id, { restore: false })`, again once a returned promise settles,
|
||||
so `release` must be harmless for a subject it does not hold and when called
|
||||
more than once.
|
||||
|
||||
Leaving the view does not track again an aircraft that was tracked before
|
||||
entry, as with the bridge and orbital views: when the user leaves by the exit
|
||||
control, the map goes back to the ridden subject through
|
||||
`release(id, { restore: true })`.
|
||||
+136
-1
@@ -2208,6 +2208,83 @@ the subject (tracking stopped, Satellites off) or tracking another target
|
||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||
covers entry, camera, zoom, look, HUD and exit.
|
||||
|
||||
### Application subjects
|
||||
|
||||
An application can add its own first-person subjects beside the aircraft,
|
||||
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
|
||||
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
|
||||
the coordinator; a malformed one, or one with a member that throws when read,
|
||||
is left out with a warning and the rest are kept). A provider names
|
||||
its layer, an entry face and labels in the page's language, and supplies four
|
||||
operations: `candidate()` (what entry would ride now), `hold(id)`,
|
||||
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
|
||||
view model), with `release(id, {restore})` on exit. Entry needs the provider's
|
||||
layer on, no Context change in flight and the provider's own
|
||||
`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
|
||||
the reason, while a provider's view is in use.
|
||||
|
||||
The built-in kinds keep their fixed entry order (a tracked aircraft, then the
|
||||
tracked satellite, then the selected vessel), so without providers entry is
|
||||
unchanged. A provider's candidate takes entry only when it was selected after
|
||||
every built-in subject present, by when the Cockpit first saw it or saw it
|
||||
selected again (its `selectedAt` changing): a train picked after an aircraft
|
||||
was tracked takes entry, and an aircraft tracked after it takes it back.
|
||||
Providers' candidates are ordered most recently selected first, then by
|
||||
priority; one first seen together with a built-in subject comes after it. The
|
||||
first in that order decides alone: when its gate is shut there is no entry
|
||||
rather than the next subject's.
|
||||
|
||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||
frame gives none, or where it last stood while that floor is cold; with
|
||||
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
|
||||
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
|
||||
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
|
||||
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
||||
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
||||
layer that already moves it smoothly; a frame with no `speedMps` is always
|
||||
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
|
||||
camera every rendered frame. A frame that is null, malformed or for another
|
||||
subject ends the view (`restoreTracking: false`), as an aircraft tracked
|
||||
meanwhile and the provider's layer turning off do; the exit control asks the
|
||||
provider to restore its subject. The generic HUD shows the provider's kicker,
|
||||
title, meta line, speed with its unit and tag, readout cards, always-visible
|
||||
source rows, banner, route card and Live Signals rows, a veil that darkens the
|
||||
picture under the HUD (with passing light streaks, which stop under reduced
|
||||
motion), arrow-key look-around when the provider asks for it, and a progress
|
||||
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
|
||||
labels never overlap). Core translates none of the provider's text: a host
|
||||
words it for the page's language (`gods-eye-view/i18n`:
|
||||
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
|
||||
functions, read again when they are shown and on a change of language, as the
|
||||
HUD view is.
|
||||
|
||||
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
|
||||
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
||||
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
||||
reported or derived, position source, feed credit and stops, and
|
||||
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
|
||||
even again; null after a deselection and for a train only a hold selected),
|
||||
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
|
||||
feed held, clicks and Escape left to the view; the held copy stays the one
|
||||
drawn of its train while held, another feed's copy hidden, and it keeps
|
||||
moving, still selected, when its category is turned off) and
|
||||
`focusTrain(key)` (false for a train the layer does not draw); its pure path
|
||||
module is exported as `gods-eye-view/layers/rail/paths`. That export
|
||||
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
|
||||
the one read before, and its `path.line` is a frozen copy. Transit
|
||||
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
|
||||
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
|
||||
out of the camera's view, and its sprite, 3D model, card, trail and detection
|
||||
box are hidden; released, it is out of play until a visibility sweep finds it in
|
||||
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
|
||||
it (the drawn marker's height while it is placed, else its segment's two
|
||||
reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the
|
||||
way the drawn train runs: once playback has placed it, a report that runs back
|
||||
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
|
||||
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
|
||||
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
|
||||
cover them.
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
@@ -2229,6 +2306,17 @@ shown without a fade once it is decoded (or after one second if its decode never
|
||||
settles), so a new camera's preview is blank only while its frame loads. Disposal
|
||||
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
|
||||
|
||||
The panel opens when a camera becomes active and collapses when the layer is
|
||||
switched off, from its own switch or anywhere else. The panel chrome applies
|
||||
the collapse, so a panel the desktop accordion only folded has its saved and
|
||||
shared state closed too. Clear selected data layers and Context isolation
|
||||
(Contacts, Space Missions) switch the layer off without the user asking: the
|
||||
panel and its share preference stay as they are, and the layer comes back to an
|
||||
open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
|
||||
while `_clearLayersOutsideContextMode` switches the layer off): the user
|
||||
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
|
||||
meta line reads the catalog count, not the last active camera.
|
||||
|
||||
## UI disposal
|
||||
|
||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||
@@ -2443,6 +2531,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
|
||||
detection is the backstop for when no probe context can be created. Applied
|
||||
during viewer construction, before any tile builds a draw command.
|
||||
|
||||
## Render failure notice
|
||||
|
||||
When the globe stops drawing, the scene chrome says so instead of leaving a
|
||||
black globe behind working controls. `installRenderFailureNotice()`
|
||||
(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
|
||||
`webglcontextlost`, logs the error, and publishes a notice that
|
||||
`RenderFailure.vue` shows as an alert dialog with focus on its first action.
|
||||
The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
|
||||
"Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
|
||||
keeps it by default for other callers.
|
||||
|
||||
- **Retry** restarts the render loop. The notice stays until a frame starts
|
||||
and ends without an error: Cesium raises `postRender` after a failed frame
|
||||
too.
|
||||
- **Reload**, after a lost context, reloads the page; the address keeps the
|
||||
view.
|
||||
- **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
|
||||
path; the query, such as `?lang=`, is kept.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- An error thrown outside Scene's error handling (a frame listener, `onTick`,
|
||||
a resize) stops the loop without a `renderError`. The notice wraps the
|
||||
widget's `render()` and `resize()` and raises it as one, once even with
|
||||
`rethrowRenderErrors`.
|
||||
- A throwing listener leaves its Cesium event mid-raise, with listener changes
|
||||
only queued. The wrapper finishes those raises first (`finishRaise()` over
|
||||
`frameEvents()`), so a layer turned off or on meanwhile behaves.
|
||||
- A render error marks the loop stopped while its next frame is still queued.
|
||||
The wrapper keeps the loop marked running, so a Retry or a shown tab
|
||||
(`src/app/tools.js`) before that frame starts no second loop.
|
||||
- After a lost context, later render errors are only logged, so the notice
|
||||
keeps saying why. The listener neither prevents nor restores the loss.
|
||||
- When the globe draws again, focus goes back where it was, or to the globe's
|
||||
canvas, which the map's keys read as the map; the canvas is focusable only
|
||||
until focus leaves or the globe is pressed. Focus the user moved stays put.
|
||||
- The notice sits above floating panels such as the Québec camera viewer, and
|
||||
its buttons are 44 px tall on touch screens.
|
||||
|
||||
An inline conversation panel never reloads or navigates its host's page: it
|
||||
offers Retry after a render error only, and no notice after a lost context,
|
||||
where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`).
|
||||
Its timer (`keepPanelRendering()`) draws only while the render loop is on and
|
||||
logs nothing itself, so each render error is logged once, by the notice. An
|
||||
error a timer frame throws is caught there: it has already been raised as a
|
||||
render error.
|
||||
|
||||
## Map Source control ownership
|
||||
|
||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||
@@ -7294,7 +7429,7 @@ pins and share links already supply complete boxes.
|
||||
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
|
||||
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||
can retain a bounded retry delay of up to 30 seconds.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
|
||||
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
||||
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
},
|
||||
"./sources/rail-service": "./src/sources/railService.js",
|
||||
"./layers/rail/source": "./src/layers/rail/source.js",
|
||||
"./layers/rail/paths": "./src/data/railPaths.js",
|
||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||
"./server/providers/mapillary": {
|
||||
"node": "./server/providers/mapillary.js"
|
||||
|
||||
@@ -1785,6 +1785,25 @@
|
||||
"tris": 1172,
|
||||
"icon": "heli"
|
||||
},
|
||||
"radio_mast": {
|
||||
"category": "other",
|
||||
"file": "other/radio_mast.glb",
|
||||
"min": [
|
||||
-0.7,
|
||||
-0.815,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
1.2,
|
||||
0.815,
|
||||
11.8
|
||||
],
|
||||
"length": 1.9,
|
||||
"beam": 1.63,
|
||||
"clearance": 0.0,
|
||||
"tris": 940,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"regional_dmu": {
|
||||
"category": "transit",
|
||||
"file": "transit/regional_dmu.glb",
|
||||
|
||||
Binary file not shown.
@@ -983,6 +983,14 @@
|
||||
"SHIP": [
|
||||
"blimp",
|
||||
75.0
|
||||
],
|
||||
"ZVEH": [
|
||||
"car",
|
||||
4.61
|
||||
],
|
||||
"GRND": [
|
||||
"radio_mast",
|
||||
1.9
|
||||
]
|
||||
},
|
||||
"helicopters": {
|
||||
@@ -1143,12 +1151,41 @@
|
||||
5.0
|
||||
]
|
||||
},
|
||||
"aircraft_callsign": {
|
||||
"ZVEH": [
|
||||
{
|
||||
"prefixes": [
|
||||
"FIRE"
|
||||
],
|
||||
"model": "fire_truck",
|
||||
"length": 11.01
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"PUSH",
|
||||
"TUG"
|
||||
],
|
||||
"model": "pushback",
|
||||
"length": 6.0
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"DEICER",
|
||||
"SQUID",
|
||||
"SQB"
|
||||
],
|
||||
"model": "van",
|
||||
"length": 5.51
|
||||
}
|
||||
]
|
||||
},
|
||||
"vessels": [
|
||||
{
|
||||
"types": [
|
||||
70,
|
||||
79
|
||||
],
|
||||
"typical_len": 300,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1188,6 +1225,7 @@
|
||||
80,
|
||||
89
|
||||
],
|
||||
"typical_len": 250,
|
||||
"rules": [
|
||||
{
|
||||
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
||||
@@ -1214,6 +1252,7 @@
|
||||
60,
|
||||
69
|
||||
],
|
||||
"typical_len": 140,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1243,6 +1282,7 @@
|
||||
37,
|
||||
37
|
||||
],
|
||||
"typical_len": 19,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 35,
|
||||
@@ -1273,6 +1313,7 @@
|
||||
30,
|
||||
30
|
||||
],
|
||||
"typical_len": 24,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 15,
|
||||
@@ -1343,6 +1384,7 @@
|
||||
55,
|
||||
55
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 25,
|
||||
@@ -1358,6 +1400,7 @@
|
||||
40,
|
||||
49
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 20,
|
||||
@@ -1373,6 +1416,7 @@
|
||||
33,
|
||||
33
|
||||
],
|
||||
"typical_len": 100,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 50,
|
||||
|
||||
@@ -557,6 +557,13 @@
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/ui/frameRateView.test.mjs",
|
||||
"src/ui/renderFailure.test.mjs",
|
||||
"src/ui/renderFailureView.test.mjs",
|
||||
"src/ui/renderFailureLoopErrors.test.mjs",
|
||||
"src/ui/renderFailureEventRetry.test.mjs",
|
||||
"src/app/renderFailureInlineRetry.test.mjs",
|
||||
"src/ui/renderFailureSecondLoop.test.mjs",
|
||||
"src/ui/renderLoopFixture.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
@@ -1429,8 +1436,18 @@
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitSubjectProviders.test.mjs",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitProgress.test.mjs",
|
||||
"src/ui/cockpitSubjectView.test.mjs",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/cockpitSubject.test.mjs",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/data/fleetModels.test.mjs",
|
||||
"src/data/fleetModelInstances.test.mjs",
|
||||
"src/testSupport/fleetGlb.mjs",
|
||||
"src/testSupport/fleetModelFakes.mjs",
|
||||
"scripts/measure-fleet-trail-anchors.mjs"
|
||||
]
|
||||
@@ -22,6 +22,9 @@
|
||||
"src/ui/vueRuntime.js",
|
||||
"src/ui/views/index.js",
|
||||
"src/ui/views/CockpitView.vue",
|
||||
"src/ui/views/panels/CockpitProgress.vue",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/ui/views/CommandDockView.vue",
|
||||
"src/ui/views/ContextView.vue",
|
||||
"src/ui/views/panels/RadioPanel.vue",
|
||||
@@ -163,6 +166,7 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/views/panels/GlobeActions.vue",
|
||||
"src/ui/views/panels/RenderFailure.vue",
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
@@ -1009,6 +1013,8 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1174,6 +1180,8 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1356,6 +1364,7 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
@@ -3115,6 +3124,8 @@
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -3132,6 +3143,7 @@
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/renderFailure.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
@@ -3302,6 +3314,7 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -3328,6 +3341,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -4290,6 +4304,7 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -4315,6 +4330,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -4799,7 +4815,9 @@
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js"
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/fleetTrailAnchors.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -5226,6 +5244,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/movement.js",
|
||||
@@ -5296,6 +5315,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -5918,5 +5938,10 @@
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"rail-paths": {
|
||||
"exports": ["./layers/rail/paths"],
|
||||
"modules": ["src/data/railPaths.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,45 @@ try {
|
||||
'#cctv-frame',
|
||||
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
|
||||
);
|
||||
// The address bar follows the share state after a short delay: wait for the
|
||||
// CCTV panel token (1 collapsed, 0 open) the step expects.
|
||||
const panelState = async (expectedToken) => {
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
(token) =>
|
||||
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
|
||||
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
expectedToken,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
return page.evaluate(
|
||||
(shared) => ({
|
||||
collapsed: document
|
||||
.getElementById('cctv-panel')
|
||||
.classList.contains('collapsed'),
|
||||
shared,
|
||||
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
|
||||
}),
|
||||
settled ? expectedToken : null,
|
||||
);
|
||||
};
|
||||
const closedOnDisable = await panelState('1');
|
||||
check(
|
||||
'disable collapses the panel and its share preference',
|
||||
closedOnDisable.collapsed && closedOnDisable.shared === '1',
|
||||
JSON.stringify(closedOnDisable),
|
||||
);
|
||||
// The switch sits inside the collapsed panel: reopen it from the right rail.
|
||||
await page.click('[data-rail-item="cctv-panel"]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById('cctv-panel').classList.contains('collapsed'),
|
||||
);
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -351,6 +390,42 @@ try {
|
||||
'disable clears the preview and re-enable reacquires the camera frame',
|
||||
disabledCleanly,
|
||||
);
|
||||
// Contacts switches CCTV off to isolate itself and restores it on the way
|
||||
// out: the open panel and its share preference survive the round trip.
|
||||
const cameraBeforeContacts = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId,
|
||||
);
|
||||
const entered = await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode('flights'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const duringContacts = await panelState('0');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode(null),
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const afterContacts = await panelState('0');
|
||||
check(
|
||||
'Contacts isolation keeps the open CCTV panel and restores its camera',
|
||||
entered?.ok === true &&
|
||||
!duringContacts.collapsed &&
|
||||
duringContacts.shared === '0' &&
|
||||
!afterContacts.collapsed &&
|
||||
afterContacts.shared === '0' &&
|
||||
(await page.evaluate(
|
||||
(cameraId) =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId === cameraId,
|
||||
cameraBeforeContacts,
|
||||
)),
|
||||
JSON.stringify({ entered, duringContacts, afterContacts }),
|
||||
);
|
||||
await page.$eval('#cctv-frame', (image) =>
|
||||
image.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
|
||||
@@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
}
|
||||
});
|
||||
|
||||
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.27);
|
||||
|
||||
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
|
||||
// draws again and the control that had focus is gone.
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
|
||||
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
Vendored
+2
@@ -11,6 +11,8 @@ import { t } from '../i18n/index.js';
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that offers its own language choice (`switchLocale` from
|
||||
* the `./i18n` entry).
|
||||
* `services.cockpitSubjects` adds the application's own first-person subjects
|
||||
* to the Cockpit (src/cockpitSubjectProviders.js).
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
|
||||
+11
-28
@@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const PANEL_FRAME_MS = 33;
|
||||
const MISSED_FRAMES_MS = 250;
|
||||
|
||||
/** A render error's details, including the plain objects workers report. */
|
||||
function describeError(error) {
|
||||
if (error instanceof Error)
|
||||
return `${error.name}: ${error.message}\n${error.stack}`;
|
||||
try {
|
||||
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the globe drawing in an inline panel. Some hosts report a panel on
|
||||
* screen as hidden, which stops the browser's animation frames and with them
|
||||
@@ -122,33 +111,27 @@ export function keepPanelRendering(
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
// Cesium stops drawing after a render error, and so does the timer.
|
||||
// Report the error in full: workers report plain objects, which Cesium
|
||||
// prints as [object Object].
|
||||
let failed = false;
|
||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||
(_scene, error) => {
|
||||
failed = true;
|
||||
console.error(
|
||||
"[God's Eye View panel] render error:",
|
||||
describeError(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
// Cesium stops its render loop after a render error, and the timer stops
|
||||
// with it; Retry restarts both. The render failure notice logs the error
|
||||
// (src/ui/renderFailure.js).
|
||||
const timer = windowRef.setInterval(() => {
|
||||
if (
|
||||
failed ||
|
||||
viewer.useDefaultRenderLoop === false ||
|
||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||
viewer.isDestroyed?.()
|
||||
)
|
||||
return;
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
try {
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
} catch {
|
||||
// Raised as a render error by now (src/ui/renderFailure.js), which
|
||||
// stops this timer; thrown on, it would only reach the host page.
|
||||
}
|
||||
}, PANEL_FRAME_MS);
|
||||
return () => {
|
||||
windowRef.clearInterval(timer);
|
||||
windowRef.cancelAnimationFrame(frameRequest);
|
||||
removeErrorListener?.();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+14
-27
@@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||||
assert.equal(frame, null);
|
||||
});
|
||||
|
||||
test('a panel reports a render error in full and stops drawing', () => {
|
||||
let listener = null;
|
||||
test('a panel stops drawing while its render loop is stopped', () => {
|
||||
let tick = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
@@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => {
|
||||
clearInterval() {},
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize() {},
|
||||
render: () => drawn.push('render'),
|
||||
scene: {
|
||||
renderError: {
|
||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const logged = [];
|
||||
const original = console.error;
|
||||
console.error = (...args) => logged.push(args.join(' '));
|
||||
try {
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
stop();
|
||||
assert.equal(listener, null);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
const viewer = { resize() {}, render: () => drawn.push('render') };
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
// Cesium stops its render loop after a render error.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
// Retry restarts it, and the timer draws again.
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
tick();
|
||||
assert.deepEqual(drawn, ['render']);
|
||||
stop();
|
||||
});
|
||||
|
||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Retry in an inline panel whose host stops animation frames, so the panel
|
||||
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
|
||||
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { keepPanelRendering } from './embed.js';
|
||||
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
|
||||
|
||||
function setup() {
|
||||
let tick = null;
|
||||
let clock = 0;
|
||||
// The host reports the panel hidden: its own animation frames never arrive.
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval() {},
|
||||
};
|
||||
const previousError = console.error;
|
||||
console.error = () => {};
|
||||
const state = { failure: null, drawn: 0 };
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
initializeFrame() {},
|
||||
// Scene.render: a failing primitive update raises renderError
|
||||
// (tryAndCatchError) and returns; a failing listener throws.
|
||||
render() {
|
||||
state.drawn += 1;
|
||||
this.preUpdate.raiseEvent(this);
|
||||
if (state.failure === 'caught')
|
||||
this.renderError.raiseEvent(this, new Error('boom'));
|
||||
if (state.failure === 'thrown') throw new Error('boom');
|
||||
this.postRender.raiseEvent(this);
|
||||
},
|
||||
};
|
||||
const { frames, widget, viewer, store, runFrames, restore } =
|
||||
renderLoopFixture(scene, {
|
||||
viewer: {
|
||||
isDestroyed: () => false,
|
||||
render: () => widget.render(),
|
||||
resize: () => widget.resize(),
|
||||
},
|
||||
notice: { allowReload: false },
|
||||
});
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
const timerFrame = () => {
|
||||
clock += 400;
|
||||
tick();
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
state,
|
||||
store,
|
||||
timerFrame,
|
||||
animationFrames: runFrames,
|
||||
restore: () => {
|
||||
stop();
|
||||
restore();
|
||||
console.error = previousError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const failure of ['caught', 'thrown']) {
|
||||
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
// The host stops animation frames with the loop's next one queued.
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = failure;
|
||||
timerFrame(); // an error thrown here does not reach the host page
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(typeof store.renderFailure.retry, 'function');
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
const before = state.drawn;
|
||||
timerFrame();
|
||||
assert.ok(state.drawn > before, 'the panel draws again after Retry');
|
||||
assert.equal(
|
||||
store.renderFailure,
|
||||
null,
|
||||
'a clean frame clears the notice',
|
||||
);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
// The host resumes frames: the globe draws once a frame.
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
state.failure = 'caught';
|
||||
animationFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
timerFrame();
|
||||
assert.equal(store.renderFailure, null);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { isEmbeddedInline } from './embed.js';
|
||||
import { installRenderFailureNotice } from '../ui/renderFailure.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
@@ -55,11 +57,17 @@ export async function createApplicationScene({
|
||||
const viewer = createApplicationViewer({
|
||||
container: 'cesiumContainer',
|
||||
creditContainer,
|
||||
// The render failure notice below replaces Cesium's dead-end panel.
|
||||
showRenderLoopErrors: false,
|
||||
});
|
||||
defer(() => {
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
// An inline panel must not reload or navigate its host's page.
|
||||
defer(
|
||||
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
|
||||
);
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
|
||||
+16
-4
@@ -375,8 +375,15 @@ export function installGlobeInViewRecovery(
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
/**
|
||||
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
|
||||
* false leaves render errors to the caller's own notice instead of Cesium's
|
||||
* panel.
|
||||
*/
|
||||
export function applicationViewerOptions(
|
||||
creditContainer,
|
||||
{ showRenderLoopErrors = true } = {},
|
||||
) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
@@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) {
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
showRenderLoopErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
export function createApplicationViewer({
|
||||
container,
|
||||
creditContainer,
|
||||
showRenderLoopErrors,
|
||||
}) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer),
|
||||
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
|
||||
+68
-2
@@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) {
|
||||
* then uses this amount to converge on the layer's delayed display position.
|
||||
* Capping correction below forward speed prevents a late feed fix from
|
||||
* becoming a first-person surge or reversal while still removing drift.
|
||||
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
|
||||
* application subject's own (cockpitSubjectStepSec).
|
||||
*/
|
||||
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
export function cockpitAnchorCorrectionStep(
|
||||
distanceM,
|
||||
speedMps,
|
||||
dtSec,
|
||||
maxDtSec = 0.1,
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM <= 0 ||
|
||||
@@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
dtSec <= 0
|
||||
)
|
||||
return 0;
|
||||
const dt = Math.min(0.1, dtSec);
|
||||
const dt = Math.min(
|
||||
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
|
||||
dtSec,
|
||||
);
|
||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
||||
const correctionRateMps = Math.max(0.75, speed * 0.22);
|
||||
return Math.min(distanceM, eased, correctionRateMps * dt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seconds an application subject's camera update advances its heading
|
||||
* slew, ground lift and anchor by: the time since the last update, at most
|
||||
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
|
||||
* and eases at its configured rates, while a long stall (a hidden tab) still
|
||||
* moves it no further than one late update would.
|
||||
* @param {number} elapsedMs Time since the last camera update.
|
||||
* @param {number} intervalMs The subject's update interval (0: every frame).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
|
||||
const elapsedSec = Number.isFinite(elapsedMs)
|
||||
? Math.max(0, elapsedMs) / 1000
|
||||
: 0;
|
||||
const intervalSec = Number.isFinite(intervalMs)
|
||||
? Math.max(0, intervalMs) / 1000
|
||||
: 0;
|
||||
return Math.min(intervalSec + 0.1, elapsedSec);
|
||||
}
|
||||
|
||||
/** Return whether a throttled cockpit presentation update is due. */
|
||||
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
||||
@@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
||||
return nowMs - startedMs >= timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How fast the clearance a ground clamp adds under an application subject's
|
||||
* eye comes and goes (m/s).
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_LIFT_MPS = 1.5;
|
||||
|
||||
/**
|
||||
* The lift the ground clamp gives an application subject's eye this update.
|
||||
* The floor is read from coarse cells that refine as tiles load, so the lift
|
||||
* it asks for jumps; a ridden eye takes the clearance part of it at
|
||||
* `rateMps`, while what it needs to stay out of the floor itself (`hardM`)
|
||||
* applies at once.
|
||||
* @param {number|null} previousM The lift given last update; null on entry.
|
||||
* @param {number} targetM The lift the floor and clearance ask for (m).
|
||||
* @param {number} hardM The lift that keeps the eye out of the floor (m).
|
||||
* @param {number} dtSec Seconds since the last update.
|
||||
* @param {number} [rateMps]
|
||||
* @returns {number} At least 0, at least `hardM`.
|
||||
*/
|
||||
export function easeCockpitLift(
|
||||
previousM,
|
||||
targetM,
|
||||
hardM,
|
||||
dtSec,
|
||||
rateMps = COCKPIT_SUBJECT_LIFT_MPS,
|
||||
) {
|
||||
const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0;
|
||||
const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0;
|
||||
if (!Number.isFinite(previousM)) return Math.max(target, hard);
|
||||
const step =
|
||||
Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) *
|
||||
Math.max(0, Number.isFinite(dtSec) ? dtSec : 0);
|
||||
const eased = previousM + Math.max(-step, Math.min(step, target - previousM));
|
||||
return Math.max(0, hard, eased);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the cockpit camera above the shared rendered-surface floor.
|
||||
* Unknown floors preserve the proposed camera height.
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitAltitudeDisplayFt,
|
||||
cockpitGroundSafeHeight,
|
||||
cockpitSubjectStepSec,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
COCKPIT_SUBJECT_LIFT_MPS,
|
||||
easeCockpitLift,
|
||||
compassDivisions,
|
||||
compassMarkerSlot,
|
||||
scrollingCompassDivisions,
|
||||
@@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
|
||||
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
|
||||
});
|
||||
|
||||
test('an application subject on a slow cadence is corrected for its whole step', () => {
|
||||
// The built-in views' step stays capped at 0.1 s...
|
||||
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
|
||||
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
|
||||
// ...while a subject updated every 500 ms is corrected for all of it.
|
||||
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
|
||||
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
|
||||
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
|
||||
});
|
||||
|
||||
test('an application subject steps by its interval, never by a long stall', () => {
|
||||
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
|
||||
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
|
||||
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
|
||||
// A hidden tab's minutes count as one late update.
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
|
||||
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
|
||||
});
|
||||
|
||||
test('cockpit camera clearance never falls below the rendered ground floor', () => {
|
||||
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
|
||||
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
|
||||
@@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||
});
|
||||
|
||||
test('an application subject’s ground lift eases its clearance, never the floor', () => {
|
||||
// On entry the lift asked for applies at once.
|
||||
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
|
||||
// A floor cell that rises by a metre is met at the lift rate...
|
||||
let lift = 0;
|
||||
const frames = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
lift = easeCockpitLift(lift, 1, 0, 0.016);
|
||||
frames.push(lift);
|
||||
}
|
||||
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
|
||||
for (let i = 1; i < frames.length; i += 1)
|
||||
assert.ok(
|
||||
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
|
||||
);
|
||||
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
|
||||
// ...and given back the same way.
|
||||
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
|
||||
// What keeps the eye out of the floor itself is never eased.
|
||||
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
|
||||
assert.equal(
|
||||
easeCockpitLift(0.2, -3, -3, 0.016),
|
||||
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
|
||||
);
|
||||
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @module cockpitProgress
|
||||
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
|
||||
* at a width in pixels: where each tick, label and the marker go.
|
||||
*
|
||||
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
|
||||
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
|
||||
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
|
||||
* with each other at their mean place. A merged tick takes the strongest tone
|
||||
* of those it holds. Labels never overlap: where two would, the one of lower
|
||||
* priority is left out (on a tie, the later one).
|
||||
*
|
||||
* Pure: no DOM.
|
||||
*/
|
||||
|
||||
/** Bare ticks closer than this merge (px). */
|
||||
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
|
||||
/** Room kept between two labels (px). */
|
||||
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
|
||||
|
||||
/** Tones from weakest to strongest, for a merged tick. */
|
||||
const TONE_RANK = Object.freeze([
|
||||
'muted',
|
||||
'normal',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
const stronger = (a, b) =>
|
||||
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
|
||||
|
||||
/** A label's width estimate (px): its longer line in small capitals. */
|
||||
export function estimateProgressLabelPx(label) {
|
||||
const longest = Math.max(
|
||||
String(label?.text ?? '').length,
|
||||
String(label?.detail ?? '').length,
|
||||
);
|
||||
return longest * 6.4 + 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* The progress line at `widthPx`, or null when there is nothing to draw.
|
||||
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
|
||||
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
|
||||
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
|
||||
* @param {number} widthPx
|
||||
* @param {{minTickPx?: number, gapPx?: number,
|
||||
* measure?: (label: object) => number}} [options]
|
||||
* @returns {{ticks: Array<{x: number, tone: string, count: number,
|
||||
* labelled: boolean}>, labels: Array<{x: number, left: number,
|
||||
* width: number, align: string, text: string, detail: string,
|
||||
* tone: string}>, marker: {x: number, dwell: boolean}|null,
|
||||
* travelledX: number, tag: string}|null}
|
||||
*/
|
||||
export function cockpitProgressLayout(
|
||||
progress,
|
||||
widthPx,
|
||||
{
|
||||
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
|
||||
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
|
||||
measure = estimateProgressLabelPx,
|
||||
} = {},
|
||||
) {
|
||||
const ticks = progress?.ticks;
|
||||
if (!Array.isArray(ticks) || ticks.length < 2) return null;
|
||||
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
|
||||
const placed = ticks.map((tick, order) => ({
|
||||
x: tick.at * widthPx,
|
||||
tone: tick.tone,
|
||||
label: tick.label,
|
||||
order,
|
||||
}));
|
||||
const labelled = placed.filter((tick) => tick.label);
|
||||
// Bare ticks beside a labelled one go into it.
|
||||
const kept = labelled.map((tick) => ({
|
||||
x: tick.x,
|
||||
tone: tick.tone,
|
||||
count: 1,
|
||||
labelled: true,
|
||||
label: tick.label,
|
||||
order: tick.order,
|
||||
}));
|
||||
const bare = [];
|
||||
for (const tick of placed) {
|
||||
if (tick.label) continue;
|
||||
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
|
||||
if (host) {
|
||||
host.tone = stronger(host.tone, tick.tone);
|
||||
host.count += 1;
|
||||
} else bare.push(tick);
|
||||
}
|
||||
// Bare ticks in a chain closer than minTickPx merge at their mean place.
|
||||
let cluster = null;
|
||||
const flush = () => {
|
||||
if (!cluster) return;
|
||||
kept.push({
|
||||
x: cluster.sum / cluster.count,
|
||||
tone: cluster.tone,
|
||||
count: cluster.count,
|
||||
labelled: false,
|
||||
label: null,
|
||||
order: cluster.order,
|
||||
});
|
||||
cluster = null;
|
||||
};
|
||||
for (const tick of bare) {
|
||||
if (cluster && tick.x - cluster.last < minTickPx) {
|
||||
cluster.sum += tick.x;
|
||||
cluster.count += 1;
|
||||
cluster.last = tick.x;
|
||||
cluster.tone = stronger(cluster.tone, tick.tone);
|
||||
} else {
|
||||
flush();
|
||||
cluster = {
|
||||
sum: tick.x,
|
||||
count: 1,
|
||||
last: tick.x,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
};
|
||||
}
|
||||
}
|
||||
flush();
|
||||
kept.sort((a, b) => a.x - b.x || a.order - b.order);
|
||||
|
||||
// Labels by priority; one that would overlap a placed label is left out.
|
||||
const boxes = [];
|
||||
const candidates = kept
|
||||
.filter((tick) => tick.labelled)
|
||||
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
|
||||
for (const tick of candidates) {
|
||||
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
|
||||
let left =
|
||||
tick.label.align === 'start'
|
||||
? tick.x
|
||||
: tick.label.align === 'end'
|
||||
? tick.x - width
|
||||
: tick.x - width / 2;
|
||||
left = Math.min(Math.max(0, left), widthPx - width);
|
||||
const right = left + width;
|
||||
if (
|
||||
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
|
||||
)
|
||||
continue;
|
||||
boxes.push({
|
||||
x: tick.x,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
align: tick.label.align,
|
||||
text: tick.label.text,
|
||||
detail: tick.label.detail,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
});
|
||||
}
|
||||
boxes.sort((a, b) => a.left - b.left);
|
||||
const marker = progress.marker
|
||||
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
|
||||
: null;
|
||||
return {
|
||||
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
|
||||
x,
|
||||
tone,
|
||||
count,
|
||||
labelled: isLabelled,
|
||||
})),
|
||||
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
|
||||
x,
|
||||
left,
|
||||
width,
|
||||
align,
|
||||
text,
|
||||
detail,
|
||||
tone,
|
||||
})),
|
||||
marker,
|
||||
travelledX: marker ? marker.x : 0,
|
||||
tag: progress.tag || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
|
||||
// width: close bare ticks merge, labelled ticks keep their place, labels never
|
||||
// overlap (the lower priority gives way) and the part before the marker is
|
||||
// the travelled part.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cockpitProgressLayout } from './cockpitProgress.js';
|
||||
|
||||
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
|
||||
const label = (text, priority, align = 'center', detail = '') => ({
|
||||
text,
|
||||
detail,
|
||||
priority,
|
||||
align,
|
||||
});
|
||||
/** Every label is 50 px wide. */
|
||||
const measure = () => 50;
|
||||
|
||||
test('nothing is drawn without two ticks or a width', () => {
|
||||
assert.equal(cockpitProgressLayout(null, 500), null);
|
||||
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
|
||||
assert.equal(
|
||||
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.1),
|
||||
tick(0.101, null, 'warning'),
|
||||
tick(0.102),
|
||||
tick(0.5),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.3, dwell: false },
|
||||
tag: 'TIMETABLE',
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.ticks.map(({ x, count, tone, labelled }) => [
|
||||
Math.round(x),
|
||||
count,
|
||||
tone,
|
||||
labelled,
|
||||
]),
|
||||
[
|
||||
[0, 1, 'normal', true],
|
||||
[101, 3, 'warning', false],
|
||||
[500, 1, 'normal', false],
|
||||
[1000, 1, 'normal', true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(layout.marker, { x: 300, dwell: false });
|
||||
assert.equal(layout.travelledX, 300);
|
||||
assert.equal(layout.tag, 'TIMETABLE');
|
||||
});
|
||||
|
||||
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.498),
|
||||
tick(0.5, label('Next', 3), 'alert'),
|
||||
tick(0.503, null, 'warning'),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: null,
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
|
||||
assert.equal(next.count, 3);
|
||||
assert.equal(next.labelled, true);
|
||||
assert.equal(next.tone, 'alert');
|
||||
assert.equal(layout.ticks.length, 3);
|
||||
assert.equal(layout.marker, null);
|
||||
assert.equal(layout.travelledX, 0);
|
||||
});
|
||||
|
||||
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('First', 1, 'start')),
|
||||
tick(0.04, label('Previous', 2)),
|
||||
tick(0.5, label('Next', 3)),
|
||||
tick(0.52, label('Close', 0)),
|
||||
tick(1, label('Last', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.2, dwell: true },
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
|
||||
[
|
||||
['Previous', 15],
|
||||
['Next', 475],
|
||||
['Last', 950],
|
||||
],
|
||||
);
|
||||
// The first stop's label would cover the previous stop's: it gives way.
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
|
||||
assert.deepEqual(layout.marker, { x: 200, dwell: true });
|
||||
});
|
||||
|
||||
test('a centred label at an edge is clamped inside the line', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(layout.labels[0].left, 0);
|
||||
const right = cockpitProgressLayout(
|
||||
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(right.labels[0].left, 250);
|
||||
});
|
||||
|
||||
test('a long run of calls still lays out, every one a tick or part of one', () => {
|
||||
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
|
||||
ticks[0].label = label('First', 1, 'start');
|
||||
ticks[59].label = label('Last', 1, 'end');
|
||||
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
|
||||
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
|
||||
const count = (layout) =>
|
||||
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
|
||||
assert.equal(count(narrow), 60);
|
||||
assert.equal(count(wide), 60);
|
||||
assert.ok(narrow.ticks.length < wide.ticks.length);
|
||||
assert.equal(wide.ticks.length, 60);
|
||||
});
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* @module cockpitSubjectProviders
|
||||
* @description Subject providers: kinds of subject an application adds to the
|
||||
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
|
||||
* subject entry would take, holds it while the view rides it, hands the camera
|
||||
* a frame each update and the HUD a view model, and releases it on exit. Core
|
||||
* knows nothing else about it.
|
||||
*
|
||||
* Everything a provider returns is normalized here before the Cockpit uses
|
||||
* it: a malformed provider is left out, a malformed frame ends the view, and a
|
||||
* malformed HUD view is read field by field, never thrown on.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/** The kinds the Cockpit rides itself; no provider may take one. */
|
||||
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
|
||||
'aircraft',
|
||||
'satellite',
|
||||
'vessel',
|
||||
]);
|
||||
|
||||
/** The tones a provider's HUD texts may take. */
|
||||
export const COCKPIT_SUBJECT_TONES = Object.freeze([
|
||||
'normal',
|
||||
'muted',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The heights a frame may give (m, ellipsoidal): below the deepest dry
|
||||
* ground, above anything ridden near it; outside, the frame stands on the
|
||||
* floor.
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
|
||||
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
|
||||
|
||||
/** The most readouts, sources and signals a HUD view shows. */
|
||||
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
|
||||
/** The most ticks a progress line takes. */
|
||||
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
|
||||
|
||||
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
||||
const REQUIRED_METHODS = Object.freeze([
|
||||
'candidate',
|
||||
'hold',
|
||||
'read',
|
||||
'hud',
|
||||
'release',
|
||||
]);
|
||||
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
|
||||
const TONES = new Set(COCKPIT_SUBJECT_TONES);
|
||||
|
||||
const isObject = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
||||
|
||||
/**
|
||||
* `value` as display text: trimmed, at most `max` characters (the last one
|
||||
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
|
||||
* kept whole or left out); '' for anything that is not a string or a number.
|
||||
*/
|
||||
export function cockpitSubjectText(value, max) {
|
||||
if (typeof value !== 'string' && !finite(value)) return '';
|
||||
const text = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= max) return text;
|
||||
let end = max - 1;
|
||||
const last = text.charCodeAt(end - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
||||
return `${text.slice(0, end)}…`;
|
||||
}
|
||||
|
||||
const tone = (value) => (TONES.has(value) ? value : 'normal');
|
||||
|
||||
/** A Material Symbols entry face, or null. */
|
||||
function faceOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const label = cockpitSubjectText(raw.label, 16);
|
||||
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
|
||||
return null;
|
||||
return Object.freeze({ icon: raw.icon, label });
|
||||
}
|
||||
|
||||
/** The labels a provider gives, normalized, or null without the required. */
|
||||
function labelsOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const labels = {
|
||||
view: cockpitSubjectText(raw.view, 48),
|
||||
exit: cockpitSubjectText(raw.exit, 48),
|
||||
exitText: cockpitSubjectText(raw.exitText, 24),
|
||||
kicker: cockpitSubjectText(raw.kicker, 48),
|
||||
};
|
||||
return labels.view && labels.exit && labels.exitText
|
||||
? Object.freeze(labels)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A provider's look, normalized, or null without an aheadLabel. */
|
||||
function lookOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
|
||||
if (!aheadLabel) return null;
|
||||
return Object.freeze({
|
||||
restPitchDeg: finite(raw.restPitchDeg)
|
||||
? clamp(raw.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's worded member `key` as it reads now, normalized: the value,
|
||||
* or what the function it is returns when called on `provider` (so it can
|
||||
* follow the page's language); null when it is malformed or a read throws.
|
||||
* Reading the member, and reading what it gives, runs provider code too (a
|
||||
* getter), so both are guarded as the call is.
|
||||
*/
|
||||
function wordedNow(provider, key, normalize) {
|
||||
try {
|
||||
const value = provider[key];
|
||||
return (
|
||||
normalize(typeof value === 'function' ? value.call(provider) : value) ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider's name in a warning: its kind, cut, or its place in the list. */
|
||||
function providerName(provider, order) {
|
||||
try {
|
||||
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
|
||||
} catch {
|
||||
// A kind getter that throws gives no name.
|
||||
}
|
||||
return `#${order}`;
|
||||
}
|
||||
|
||||
/** Why `provider` cannot be kept, or null. */
|
||||
function refusal(provider, kinds) {
|
||||
if (!isObject(provider)) return 'not an object';
|
||||
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
|
||||
return 'malformed kind';
|
||||
if (RESERVED.has(provider.kind)) return 'reserved kind';
|
||||
if (kinds.has(provider.kind)) return 'repeated kind';
|
||||
if (
|
||||
typeof provider.layerId !== 'string' ||
|
||||
!LAYER_ID_PATTERN.test(provider.layerId)
|
||||
)
|
||||
return 'malformed layer id';
|
||||
for (const method of REQUIRED_METHODS)
|
||||
if (typeof provider[method] !== 'function') return `no ${method}()`;
|
||||
if (
|
||||
provider.isEntryAllowed !== undefined &&
|
||||
typeof provider.isEntryAllowed !== 'function'
|
||||
)
|
||||
return 'isEntryAllowed is not a function';
|
||||
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
|
||||
if (!wordedNow(provider, 'labels', labelsOf))
|
||||
return 'labels need view, exit and exitText';
|
||||
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
|
||||
return 'look needs an aheadLabel';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's subject providers, checked once and frozen, in the order
|
||||
* entry asks them: ascending `priority`, ties in list order. Each has
|
||||
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
|
||||
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
|
||||
* - `priority`: a finite number (default 0);
|
||||
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
|
||||
* most 16 characters, in the page's language;
|
||||
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
|
||||
* button's accessible names, the exit button's text and the default kicker,
|
||||
* in the page's language;
|
||||
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
|
||||
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
|
||||
* ahead button named `aheadLabel`;
|
||||
* `entryFace`, `labels` and `look` may each be a function returning the
|
||||
* object: it is called on the provider again whenever they are shown and
|
||||
* when the page's language changes, so its words can follow it
|
||||
* (`./i18n`). A read that throws or is malformed keeps the last
|
||||
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
|
||||
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
|
||||
* updates the camera every rendered frame;
|
||||
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
|
||||
* positive or Infinity, null for the Cockpit's default;
|
||||
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
||||
* synchronous (createCockpitSubjectCaller) and called on the provider, so
|
||||
* a class instance's methods keep their `this`. `isEntryAllowed` allows
|
||||
* entry only by returning `true`. `hold` returns `true` once it holds the
|
||||
* subject, or `false` to refuse entry; any other answer refuses it too,
|
||||
* with one warning. A refused hold, and one that throws or returns a
|
||||
* promise, is followed by `release(id, {restore: false})`, and by another
|
||||
* once that promise settles; `release` must therefore be harmless for a
|
||||
* subject it does not hold or already gave back.
|
||||
*
|
||||
* A provider that is not so, or whose members throw when read (a getter), is
|
||||
* left out with one warning naming it; the providers after it are kept.
|
||||
* @param {unknown} list
|
||||
* @param {{warn?: (message: string) => void}} [options]
|
||||
* @returns {ReadonlyArray<object>}
|
||||
*/
|
||||
export function cockpitSubjectProviders(
|
||||
list = [],
|
||||
{ warn = (message) => console.warn(message) } = {},
|
||||
) {
|
||||
const kept = [];
|
||||
const kinds = new Set();
|
||||
(Array.isArray(list) ? list : []).forEach((provider, order) => {
|
||||
let reason;
|
||||
let copy = null;
|
||||
try {
|
||||
reason = refusal(provider, kinds);
|
||||
if (!reason) copy = keptProvider(provider, order);
|
||||
} catch {
|
||||
reason = 'a member threw when read';
|
||||
}
|
||||
if (reason) {
|
||||
warn(
|
||||
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
kinds.add(copy.kind);
|
||||
kept.push(copy);
|
||||
});
|
||||
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
|
||||
return Object.freeze(kept);
|
||||
}
|
||||
|
||||
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
|
||||
function keptProvider(provider, order) {
|
||||
// Worded members are read again on each use (see above); the last good
|
||||
// read stands in for one that is malformed or throws.
|
||||
let face = wordedNow(provider, 'entryFace', faceOf);
|
||||
let labels = wordedNow(provider, 'labels', labelsOf);
|
||||
// On or off, and its rest pitch, as first read.
|
||||
let look = wordedNow(provider, 'look', lookOf);
|
||||
const restPitchDeg = look?.restPitchDeg ?? 0;
|
||||
return Object.freeze({
|
||||
kind: provider.kind,
|
||||
layerId: provider.layerId,
|
||||
priority: finite(provider.priority) ? provider.priority : 0,
|
||||
order,
|
||||
get entryFace() {
|
||||
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
|
||||
return face;
|
||||
},
|
||||
get labels() {
|
||||
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
|
||||
return labels;
|
||||
},
|
||||
get look() {
|
||||
if (!look) return null;
|
||||
const now = wordedNow(provider, 'look', lookOf);
|
||||
if (now)
|
||||
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
|
||||
return look;
|
||||
},
|
||||
cameraIntervalMs:
|
||||
finite(provider.cameraIntervalMs) &&
|
||||
provider.cameraIntervalMs >= 0 &&
|
||||
provider.cameraIntervalMs <= 1000
|
||||
? provider.cameraIntervalMs
|
||||
: null,
|
||||
headingSlewDps:
|
||||
provider.headingSlewDps === Infinity ||
|
||||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
|
||||
? provider.headingSlewDps
|
||||
: null,
|
||||
// Bound, so a method that uses `this` (a class instance's, say) still
|
||||
// reads its own provider when called on this copy.
|
||||
candidate: provider.candidate.bind(provider),
|
||||
isEntryAllowed: provider.isEntryAllowed
|
||||
? provider.isEntryAllowed.bind(provider)
|
||||
: () => true,
|
||||
hold: provider.hold.bind(provider),
|
||||
read: provider.read.bind(provider),
|
||||
hud: provider.hud.bind(provider),
|
||||
release: provider.release.bind(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
|
||||
* usable id. `name` falls back to the id; `face` is null unless well formed;
|
||||
* `selectedAt` (a number or a short string, else null) is a token the
|
||||
* provider changes each time its subject is selected, even again: a
|
||||
* subject selected after the built-in ones takes entry (entryCandidates).
|
||||
* Null too when reading `raw` throws (a getter runs provider code).
|
||||
* @param {unknown} raw What `candidate()` returned.
|
||||
*/
|
||||
export function cockpitSubjectCandidate(raw) {
|
||||
try {
|
||||
return candidateOf(raw);
|
||||
} catch {
|
||||
// A getter on what candidate() returned threw: no candidate.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateOf(raw) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string') return null;
|
||||
const id = raw.id.trim();
|
||||
if (!id || id.length > 128) return null;
|
||||
const selectedAt =
|
||||
finite(raw.selectedAt) ||
|
||||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
|
||||
? raw.selectedAt
|
||||
: null;
|
||||
return Object.freeze({
|
||||
id,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
|
||||
face: faceOf(raw.face),
|
||||
selectedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera frame from a provider, normalized, or null when it cannot place
|
||||
* the camera (the view then ends): not an object, an id other than `heldId`,
|
||||
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
|
||||
* read of it that throws (a getter runs provider code).
|
||||
* A `heightM` that is not finite or lies outside
|
||||
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
|
||||
* the eye then stands on the shared floor, or where it last stood while that
|
||||
* is cold. The anchor is `'inertial'` only when asked for and the frame
|
||||
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
|
||||
* trimmed, as `cockpitSubjectCandidate` trims it.
|
||||
* @param {unknown} raw What `read()` returned.
|
||||
* @param {string} heldId The subject the view holds.
|
||||
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
|
||||
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
|
||||
* pitchDeg, minClearanceM}}`.
|
||||
*/
|
||||
export function cockpitSubjectFrame(raw, heldId) {
|
||||
try {
|
||||
return frameOf(raw, heldId);
|
||||
} catch {
|
||||
// A getter on what read() returned threw: as a malformed frame.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function frameOf(raw, heldId) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
|
||||
return null;
|
||||
const latitude = raw.latitude;
|
||||
const longitude = raw.longitude;
|
||||
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
|
||||
return null;
|
||||
const eye = isObject(raw.eye) ? raw.eye : null;
|
||||
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
|
||||
const heading = (value) =>
|
||||
finite(value) ? ((value % 360) + 360) % 360 : null;
|
||||
const headingDeg = heading(raw.headingDeg);
|
||||
const speedMps =
|
||||
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
|
||||
return {
|
||||
id: heldId,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
|
||||
latitude,
|
||||
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
|
||||
heightM:
|
||||
finite(raw.heightM) &&
|
||||
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
|
||||
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
|
||||
? raw.heightM
|
||||
: null,
|
||||
headingDeg,
|
||||
courseDeg: heading(raw.courseDeg) ?? headingDeg,
|
||||
speedMps,
|
||||
stale: raw.stale === true,
|
||||
groundClamp: raw.groundClamp !== false,
|
||||
// An inertial anchor runs on at the frame's speed; with none it would
|
||||
// close on a moving subject at the correction's floor and fall behind.
|
||||
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
|
||||
eye: {
|
||||
upM: eye.upM,
|
||||
forwardM:
|
||||
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
|
||||
? eye.forwardM
|
||||
: 0,
|
||||
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
|
||||
minClearanceM:
|
||||
finite(eye.minClearanceM) && eye.minClearanceM >= 0
|
||||
? eye.minClearanceM
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
|
||||
function rowsOf(raw, max, row) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const entry of raw) {
|
||||
if (rows.length >= max) break;
|
||||
if (!isObject(entry)) continue;
|
||||
const id = typeof entry.id === 'string' ? entry.id : '';
|
||||
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
|
||||
const read = row(entry);
|
||||
if (!read) continue;
|
||||
ids.add(id);
|
||||
rows.push({ id, ...read });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A progress line, or null without at least two ticks. */
|
||||
function progressOf(raw) {
|
||||
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
|
||||
const ticks = [];
|
||||
for (const tick of raw.ticks) {
|
||||
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
|
||||
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
|
||||
continue;
|
||||
let label = null;
|
||||
if (isObject(tick.label)) {
|
||||
const text = cockpitSubjectText(tick.label.text, 40);
|
||||
if (text)
|
||||
label = {
|
||||
text,
|
||||
detail: cockpitSubjectText(tick.label.detail, 64),
|
||||
priority: finite(tick.label.priority)
|
||||
? clamp(Math.round(tick.label.priority), 0, 9)
|
||||
: 0,
|
||||
align: ['start', 'center', 'end'].includes(tick.label.align)
|
||||
? tick.label.align
|
||||
: 'center',
|
||||
};
|
||||
}
|
||||
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
|
||||
}
|
||||
if (ticks.length < 2) return null;
|
||||
ticks.sort((a, b) => a.at - b.at);
|
||||
const marker =
|
||||
isObject(raw.marker) && finite(raw.marker.at)
|
||||
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
|
||||
: null;
|
||||
return {
|
||||
ticks,
|
||||
marker,
|
||||
tag: cockpitSubjectText(raw.tag, 16),
|
||||
summary: cockpitSubjectText(raw.summary, 160),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's HUD view model, normalized; never throws. Texts are coerced
|
||||
* and cut to their caps, lists to their lengths, tones to
|
||||
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
|
||||
* Null when reading `raw` throws (a getter runs provider code), so the caller
|
||||
* keeps the view it has, as when `hud()` itself throws.
|
||||
* All text is shown as given: the provider words it in the page's language.
|
||||
* @param {unknown} raw What `hud()` returned.
|
||||
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
|
||||
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
|
||||
* sources: [{id, label, text, tone}],
|
||||
* route: {title, from, to, status}|null,
|
||||
* progress: {ticks: [{at, tone, label: {text, detail, priority,
|
||||
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
|
||||
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
|
||||
* lookReadout, signals: [{id, tone, title, detail}]}`.
|
||||
*/
|
||||
export function cockpitSubjectHudView(raw) {
|
||||
try {
|
||||
return hudViewOf(raw);
|
||||
} catch {
|
||||
// A getter on what hud() returned threw: the caller keeps its last view.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hudViewOf(raw) {
|
||||
const view = isObject(raw) ? raw : {};
|
||||
const meta = isObject(view.meta) ? view.meta : {};
|
||||
const speed =
|
||||
isObject(view.speed) &&
|
||||
(view.speed.value === null ||
|
||||
(finite(view.speed.value) && view.speed.value >= 0))
|
||||
? {
|
||||
value:
|
||||
view.speed.value === null
|
||||
? null
|
||||
: Math.min(view.speed.value, 99_999),
|
||||
unit: cockpitSubjectText(view.speed.unit, 12),
|
||||
tag: cockpitSubjectText(view.speed.tag, 8),
|
||||
tone: tone(view.speed.tone),
|
||||
}
|
||||
: null;
|
||||
const route = isObject(view.route)
|
||||
? {
|
||||
title: cockpitSubjectText(view.route.title, 32),
|
||||
from: cockpitSubjectText(view.route.from, 48),
|
||||
to: cockpitSubjectText(view.route.to, 48),
|
||||
status: cockpitSubjectText(view.route.status, 64),
|
||||
}
|
||||
: null;
|
||||
const bannerTitle = isObject(view.banner)
|
||||
? cockpitSubjectText(view.banner.title, 48)
|
||||
: '';
|
||||
const veil =
|
||||
isObject(view.veil) && finite(view.veil.level)
|
||||
? {
|
||||
level: clamp(view.veil.level, 0, 1),
|
||||
streaks: view.veil.streaks === true,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
kicker: cockpitSubjectText(view.kicker, 48),
|
||||
title: cockpitSubjectText(view.title, 64),
|
||||
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
|
||||
speed,
|
||||
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
|
||||
const label = cockpitSubjectText(row.label, 24);
|
||||
const value = cockpitSubjectText(row.value, 48);
|
||||
return label || value
|
||||
? {
|
||||
label,
|
||||
value,
|
||||
detail: cockpitSubjectText(row.detail, 80),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
|
||||
const text = cockpitSubjectText(row.text, 120);
|
||||
return text
|
||||
? {
|
||||
label: cockpitSubjectText(row.label, 16),
|
||||
text,
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
route: route && (route.from || route.to) ? route : null,
|
||||
progress: progressOf(view.progress),
|
||||
banner: bannerTitle
|
||||
? {
|
||||
title: bannerTitle,
|
||||
detail: cockpitSubjectText(view.banner.detail, 96),
|
||||
tone: tone(view.banner.tone),
|
||||
}
|
||||
: null,
|
||||
veil: veil && veil.level > 0 ? veil : null,
|
||||
lookReadout: cockpitSubjectText(view.lookReadout, 48),
|
||||
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
|
||||
const title = cockpitSubjectText(row.title, 48);
|
||||
return title
|
||||
? {
|
||||
title,
|
||||
detail: cockpitSubjectText(row.detail, 96),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A guarded caller for provider methods: `call(provider, method, args,
|
||||
* fallback)` returns what the method returns, or `fallback` when it throws,
|
||||
* warning once per provider and method.
|
||||
*
|
||||
* Provider methods are synchronous: the Cockpit calls them on its render
|
||||
* cadence and uses the answer at once. One that returns a promise (an async
|
||||
* function, say) breaks that contract: the promise is refused as a throw
|
||||
* would be (its rejection, if any, is swallowed), with one warning, and
|
||||
* `onSettled`, when given, runs once the promise has settled, so a caller
|
||||
* can undo work that lands late.
|
||||
*
|
||||
* `call.warnOnce(provider, method, message, error)` warns on the same
|
||||
* once-per-provider-and-method footing, for an answer the caller finds
|
||||
* malformed.
|
||||
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||
*/
|
||||
export function createCockpitSubjectCaller({
|
||||
warn = (message, error) => console.warn(message, error),
|
||||
} = {}) {
|
||||
const warned = new Set();
|
||||
const warnOnce = (provider, method, message, error) => {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
|
||||
error,
|
||||
);
|
||||
};
|
||||
function call(
|
||||
provider,
|
||||
method,
|
||||
args = [],
|
||||
fallback = null,
|
||||
onSettled = null,
|
||||
) {
|
||||
let result;
|
||||
let thenable;
|
||||
try {
|
||||
result = provider[method](...args);
|
||||
// Reading `then` runs provider code too (a getter may throw), so it is
|
||||
// guarded as the call is.
|
||||
thenable =
|
||||
result !== null &&
|
||||
(typeof result === 'object' || typeof result === 'function') &&
|
||||
typeof result.then === 'function';
|
||||
} catch (error) {
|
||||
warnOnce(provider, method, 'failed', error);
|
||||
return fallback;
|
||||
}
|
||||
if (thenable) {
|
||||
// Its work may still land: `onSettled` runs once it has, either way.
|
||||
const settled = () => {
|
||||
if (typeof onSettled === 'function') onSettled();
|
||||
};
|
||||
Promise.resolve(result).then(settled, settled);
|
||||
warnOnce(
|
||||
provider,
|
||||
method,
|
||||
'returned a promise; provider methods are synchronous',
|
||||
null,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
call.warnOnce = warnOnce;
|
||||
return call;
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
|
||||
// are checked once and what they return is normalized before the Cockpit
|
||||
// uses it: a malformed provider is left out, a malformed frame ends the view,
|
||||
// and a malformed HUD view is read field by field and never thrown on.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
COCKPIT_BUILT_IN_KINDS,
|
||||
COCKPIT_SUBJECT_MAX_READOUTS,
|
||||
COCKPIT_SUBJECT_MAX_TICKS,
|
||||
cockpitSubjectCandidate,
|
||||
cockpitSubjectFrame,
|
||||
cockpitSubjectHudView,
|
||||
cockpitSubjectProviders,
|
||||
cockpitSubjectText,
|
||||
createCockpitSubjectCaller,
|
||||
} from './cockpitSubjectProviders.js';
|
||||
|
||||
const noop = () => null;
|
||||
/** A well-formed provider, with `overrides`. */
|
||||
const provider = (overrides = {}) => ({
|
||||
kind: 'example-kind',
|
||||
layerId: 'example-layer',
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
labels: {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
},
|
||||
candidate: noop,
|
||||
hold: () => true,
|
||||
read: noop,
|
||||
hud: noop,
|
||||
release: noop,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const keep = (list) => {
|
||||
const warnings = [];
|
||||
const kept = cockpitSubjectProviders(list, {
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
return { kept, warnings };
|
||||
};
|
||||
|
||||
test('well-formed providers are kept, frozen, in priority then list order', () => {
|
||||
const { kept, warnings } = keep([
|
||||
provider({ kind: 'late', priority: 5 }),
|
||||
provider({ kind: 'first-of-two', priority: 1 }),
|
||||
provider({ kind: 'second-of-two', priority: 1 }),
|
||||
provider({ kind: 'default-priority' }),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['default-priority', 'first-of-two', 'second-of-two', 'late'],
|
||||
);
|
||||
assert.ok(Object.isFrozen(kept));
|
||||
assert.ok(Object.isFrozen(kept[0]));
|
||||
const [first] = kept;
|
||||
assert.equal(first.priority, 0);
|
||||
assert.equal(first.look, null);
|
||||
assert.equal(first.cameraIntervalMs, null);
|
||||
assert.equal(first.headingSlewDps, null);
|
||||
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
|
||||
assert.deepEqual(first.labels, {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
kicker: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed, reserved and repeated providers are left out with one warning each', () => {
|
||||
const cases = [
|
||||
[null, /not an object/],
|
||||
[provider({ kind: 'X' }), /malformed kind/],
|
||||
[provider({ kind: 'a' }), /malformed kind/],
|
||||
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
|
||||
[provider({ hold: undefined }), /no hold\(\)/],
|
||||
[provider({ read: 'read' }), /no read\(\)/],
|
||||
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
|
||||
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
|
||||
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
|
||||
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
|
||||
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
|
||||
];
|
||||
for (const [entry, reason] of cases) {
|
||||
const { kept, warnings } = keep([entry]);
|
||||
assert.equal(kept.length, 0, String(reason));
|
||||
assert.equal(warnings.length, 1, String(reason));
|
||||
assert.match(warnings[0], reason);
|
||||
}
|
||||
for (const kind of COCKPIT_BUILT_IN_KINDS) {
|
||||
const { kept, warnings } = keep([provider({ kind })]);
|
||||
assert.equal(kept.length, 0, kind);
|
||||
assert.match(warnings[0], /reserved kind/);
|
||||
}
|
||||
const { kept, warnings } = keep([provider(), provider()]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.match(warnings[0], /repeated kind/);
|
||||
assert.deepEqual(keep('not a list').kept, []);
|
||||
});
|
||||
|
||||
test('optional settings are kept when in range and defaulted otherwise', () => {
|
||||
const [ranged] = keep([
|
||||
provider({
|
||||
priority: -2,
|
||||
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
|
||||
cameraIntervalMs: 0,
|
||||
headingSlewDps: Infinity,
|
||||
labels: {
|
||||
view: 'v',
|
||||
exit: 'e',
|
||||
exitText: 'x',
|
||||
kicker: 'K'.repeat(80),
|
||||
},
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(ranged.priority, -2);
|
||||
assert.deepEqual(ranged.look, {
|
||||
restPitchDeg: -90,
|
||||
aheadLabel: 'Look ahead',
|
||||
});
|
||||
assert.equal(ranged.cameraIntervalMs, 0);
|
||||
assert.equal(ranged.headingSlewDps, Infinity);
|
||||
assert.equal(ranged.labels.kicker.length, 48);
|
||||
assert.ok(ranged.labels.kicker.endsWith('…'));
|
||||
const [defaulted] = keep([
|
||||
provider({
|
||||
priority: NaN,
|
||||
cameraIntervalMs: 5000,
|
||||
headingSlewDps: -1,
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(defaulted.priority, 0);
|
||||
assert.equal(defaulted.cameraIntervalMs, null);
|
||||
assert.equal(defaulted.headingSlewDps, null);
|
||||
});
|
||||
|
||||
test('a candidate needs an id; its name and face are read with care', () => {
|
||||
assert.equal(cockpitSubjectCandidate(null), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
|
||||
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
face: null,
|
||||
selectedAt: null,
|
||||
});
|
||||
// A selection token is a number or a short string.
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
|
||||
'click-2',
|
||||
);
|
||||
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
|
||||
null,
|
||||
);
|
||||
const read = cockpitSubjectCandidate({
|
||||
id: 'a1',
|
||||
name: ' Example 12 ',
|
||||
face: { icon: 'tram', label: 'RIDE' },
|
||||
});
|
||||
assert.equal(read.name, 'Example 12');
|
||||
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a frame is the held subject at a finite place with an eye height, else null', () => {
|
||||
const frame = (overrides = {}) => ({
|
||||
id: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
eye: { upM: 3.5 },
|
||||
...overrides,
|
||||
});
|
||||
const read = cockpitSubjectFrame(frame(), 'a1');
|
||||
assert.deepEqual(read, {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
heightM: null,
|
||||
headingDeg: null,
|
||||
courseDeg: null,
|
||||
speedMps: null,
|
||||
stale: false,
|
||||
groundClamp: true,
|
||||
// No speed to run an inertial anchor on: it sits on the subject.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
|
||||
});
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
|
||||
'inertial',
|
||||
'with a speed it runs on',
|
||||
);
|
||||
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
|
||||
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
|
||||
for (const bad of [
|
||||
null,
|
||||
frame({ id: 'other' }),
|
||||
frame({ latitude: 91 }),
|
||||
frame({ latitude: NaN }),
|
||||
frame({ longitude: Infinity }),
|
||||
frame({ eye: null }),
|
||||
frame({ eye: { upM: -1 } }),
|
||||
frame({ eye: { upM: 2000 } }),
|
||||
frame({ eye: { upM: '3' } }),
|
||||
])
|
||||
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
|
||||
const full = cockpitSubjectFrame(
|
||||
frame({
|
||||
name: 'Example',
|
||||
longitude: 190,
|
||||
heightM: 412.5,
|
||||
headingDeg: -10,
|
||||
courseDeg: 725,
|
||||
speedMps: 22,
|
||||
stale: true,
|
||||
groundClamp: false,
|
||||
anchor: 'exact',
|
||||
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
|
||||
}),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(full.longitude, -170);
|
||||
assert.equal(full.heightM, 412.5);
|
||||
// A height outside any plausible ground reads as none.
|
||||
for (const heightM of [-6_400_000, 1e9, Infinity])
|
||||
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
|
||||
-400,
|
||||
);
|
||||
assert.equal(full.headingDeg, 350);
|
||||
assert.equal(full.courseDeg, 5);
|
||||
assert.equal(full.speedMps, 22);
|
||||
assert.equal(full.stale, true);
|
||||
assert.equal(full.groundClamp, false);
|
||||
assert.equal(full.anchor, 'exact');
|
||||
assert.deepEqual(full.eye, {
|
||||
upM: 2,
|
||||
forwardM: 0,
|
||||
pitchDeg: -90,
|
||||
minClearanceM: 1.5,
|
||||
});
|
||||
// Only exactly false turns the ground clamp off; course falls back to heading.
|
||||
const loose = cockpitSubjectFrame(
|
||||
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(loose.groundClamp, true);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
|
||||
'inertial',
|
||||
);
|
||||
assert.equal(loose.courseDeg, 90);
|
||||
assert.equal(loose.speedMps, null);
|
||||
});
|
||||
|
||||
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
|
||||
const empty = cockpitSubjectHudView(null);
|
||||
assert.deepEqual(empty, {
|
||||
kicker: '',
|
||||
title: '',
|
||||
meta: { text: '', tone: 'normal' },
|
||||
speed: null,
|
||||
readouts: [],
|
||||
sources: [],
|
||||
route: null,
|
||||
progress: null,
|
||||
banner: null,
|
||||
veil: null,
|
||||
lookReadout: '',
|
||||
signals: [],
|
||||
});
|
||||
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
|
||||
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
|
||||
const view = cockpitSubjectHudView({
|
||||
kicker: 'LINE · OPERATOR',
|
||||
title: 'Name → Destination',
|
||||
meta: { text: 'LIVE', tone: 'bogus' },
|
||||
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
|
||||
readouts: [
|
||||
...Array.from({ length: 6 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
label: 'NEXT',
|
||||
value: `Stop ${i}`,
|
||||
tone: 'warning',
|
||||
})),
|
||||
],
|
||||
sources: [
|
||||
{ id: 'track', label: 'TRACK', text: 'operator shape' },
|
||||
{ id: 'track', label: 'TRACK', text: 'repeated id' },
|
||||
{ id: 'Bad Id', text: 'refused' },
|
||||
{ id: 'empty', label: 'X', text: '' },
|
||||
],
|
||||
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
|
||||
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
|
||||
veil: { level: 4, streaks: 1 },
|
||||
lookReadout: 'LOOK RIGHT',
|
||||
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
|
||||
});
|
||||
assert.equal(view.meta.tone, 'normal');
|
||||
assert.deepEqual(view.speed, {
|
||||
value: 84.4,
|
||||
unit: 'km/h',
|
||||
tag: 'LIVE',
|
||||
tone: 'live',
|
||||
});
|
||||
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
|
||||
assert.equal(view.readouts[0].tone, 'warning');
|
||||
assert.deepEqual(
|
||||
view.sources.map(({ id }) => id),
|
||||
['track'],
|
||||
);
|
||||
assert.deepEqual(view.route, {
|
||||
title: 'ROUTE',
|
||||
from: 'A',
|
||||
to: 'B',
|
||||
status: '3 stops',
|
||||
});
|
||||
assert.equal(view.banner.tone, 'alert');
|
||||
assert.deepEqual(view.veil, { level: 1, streaks: false });
|
||||
assert.equal(view.lookReadout, 'LOOK RIGHT');
|
||||
assert.equal(view.signals[0].tone, 'warning');
|
||||
// A veil at 0, a speed below 0 and a route with no ends are absent.
|
||||
const absent = cockpitSubjectHudView({
|
||||
veil: { level: 0 },
|
||||
speed: { value: -3 },
|
||||
route: { from: '', to: '' },
|
||||
});
|
||||
assert.equal(absent.veil, null);
|
||||
assert.equal(absent.speed, null);
|
||||
assert.equal(absent.route, null);
|
||||
// A speed of null is a speed not yet known.
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
|
||||
const read = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: [
|
||||
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
|
||||
{ at: 0.5, tone: 'warning' },
|
||||
{ at: 2 },
|
||||
{ at: -0.1 },
|
||||
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
|
||||
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
|
||||
],
|
||||
marker: { at: 1.4, dwell: true },
|
||||
tag: 'TIMETABLE',
|
||||
summary: 'From Start to End',
|
||||
},
|
||||
}).progress;
|
||||
assert.deepEqual(
|
||||
read.ticks.map(({ at }) => at),
|
||||
[0, 0.25, 0.5, 1],
|
||||
);
|
||||
assert.equal(read.ticks[0].label.priority, 9);
|
||||
assert.equal(read.ticks[0].label.align, 'start');
|
||||
assert.equal(read.ticks[1].label, null);
|
||||
assert.equal(read.ticks[2].tone, 'warning');
|
||||
assert.deepEqual(read.marker, { at: 1, dwell: true });
|
||||
assert.equal(read.tag, 'TIMETABLE');
|
||||
assert.equal(read.summary, 'From Start to End');
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
|
||||
null,
|
||||
);
|
||||
const many = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
|
||||
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
|
||||
})),
|
||||
},
|
||||
}).progress;
|
||||
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
|
||||
});
|
||||
|
||||
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
|
||||
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
|
||||
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
|
||||
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
|
||||
assert.equal(cockpitSubjectText({}, 10), '');
|
||||
assert.equal(cockpitSubjectText(NaN, 10), '');
|
||||
});
|
||||
|
||||
test('a cut never splits a surrogate pair', () => {
|
||||
// The cut would fall between the emoji's halves: it is left out whole.
|
||||
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
|
||||
// One that fits before the cut is kept whole.
|
||||
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
|
||||
const lone =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
for (let max = 2; max <= 24; max += 1)
|
||||
assert.equal(
|
||||
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
|
||||
false,
|
||||
`cut at ${max}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a provider method that throws returns the fallback and warns once', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const failing = {
|
||||
kind: 'example-kind',
|
||||
read() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
hud: (value) => value * 2,
|
||||
};
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'hud', [21]), 42);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind read\(\) failed/);
|
||||
});
|
||||
|
||||
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const asynchronous = {
|
||||
kind: 'example-kind',
|
||||
hold: async () => true,
|
||||
read: () => Promise.reject(new Error('late')),
|
||||
};
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'read', [], null), null);
|
||||
assert.equal(warnings.length, 2, 'once per method');
|
||||
assert.match(warnings[0], /hold\(\) returned a promise/);
|
||||
// The rejected promise is not left unhandled.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('an answer whose `then` throws when read is handled as a throw', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const tricky = {
|
||||
kind: 'example-kind',
|
||||
hold: () => ({
|
||||
get then() {
|
||||
throw new Error('no then');
|
||||
},
|
||||
}),
|
||||
};
|
||||
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind hold\(\) failed/);
|
||||
});
|
||||
|
||||
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const ride = {
|
||||
kind: 'example-kind',
|
||||
hold() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
call.warnOnce(ride, 'read', 'is odd');
|
||||
call.warnOnce(ride, 'read', 'is odd again');
|
||||
call(ride, 'hold', ['a1']);
|
||||
call.warnOnce(ride, 'hold', 'is odd');
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider example-kind read() is odd',
|
||||
'[Cockpit] subject provider example-kind hold() failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
|
||||
class Ride {
|
||||
#word = 'RIDE';
|
||||
#held = null;
|
||||
kind = 'example-kind';
|
||||
layerId = 'example-layer';
|
||||
speak(word) {
|
||||
this.#word = word;
|
||||
}
|
||||
entryFace() {
|
||||
return { icon: 'commute', label: this.#word };
|
||||
}
|
||||
get labels() {
|
||||
return {
|
||||
view: `${this.#word} view`,
|
||||
exit: 'Exit ride view',
|
||||
exitText: 'EXIT RIDE',
|
||||
};
|
||||
}
|
||||
look() {
|
||||
return { aheadLabel: `Look ahead, ${this.#word}` };
|
||||
}
|
||||
candidate() {
|
||||
return { id: 'a1', name: this.#word };
|
||||
}
|
||||
isEntryAllowed(candidate) {
|
||||
return candidate.id === 'a1' && this.#held === null;
|
||||
}
|
||||
hold(id) {
|
||||
this.#held = id;
|
||||
return true;
|
||||
}
|
||||
read() {
|
||||
return this.#held;
|
||||
}
|
||||
hud(frame) {
|
||||
return { title: `${this.#word} ${frame}` };
|
||||
}
|
||||
release() {
|
||||
this.#held = null;
|
||||
return 'released';
|
||||
}
|
||||
}
|
||||
const instance = new Ride();
|
||||
const { kept, warnings } = keep([instance]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.labels.view, 'RIDE view');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
|
||||
instance.speak('FAHRT');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
|
||||
// Called on the kept copy, as the Cockpit calls them.
|
||||
const failures = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => failures.push(message),
|
||||
});
|
||||
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
|
||||
assert.equal(call(ride, 'hold', ['a1']), true);
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
|
||||
assert.equal(call(ride, 'read', [0, {}]), 'a1');
|
||||
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
|
||||
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
|
||||
assert.equal(call(ride, 'read', [0, {}]), null);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('labels, entry face and look given as functions are read again on each use', () => {
|
||||
let lang = 'en';
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
|
||||
};
|
||||
const { kept, warnings } = keep([
|
||||
provider({
|
||||
labels: () => words[lang],
|
||||
entryFace: () => ({
|
||||
icon: 'commute',
|
||||
label: { en: 'RIDE', de: 'FAHRT' }[lang],
|
||||
}),
|
||||
look: () => ({
|
||||
restPitchDeg: lang === 'de' ? 40 : -3,
|
||||
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.labels.exitText, 'EXIT RIDE');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead');
|
||||
lang = 'de';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
assert.equal(ride.look.aheadLabel, 'Nach vorn');
|
||||
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
|
||||
// A read that fails or is malformed keeps the last good one.
|
||||
lang = 'xx';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
// Functions that are malformed from the start leave the provider out.
|
||||
const bad = keep([
|
||||
provider({ kind: 'bad-labels', labels: () => null }),
|
||||
provider({
|
||||
kind: 'thrown-face',
|
||||
entryFace: () => {
|
||||
throw new Error('no face');
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(bad.kept, []);
|
||||
assert.equal(bad.warnings.length, 2);
|
||||
});
|
||||
|
||||
/** `target` with a getter for `key` that throws (provider code runs on a read). */
|
||||
const throwingGetter = (target, key) =>
|
||||
Object.defineProperty(target, key, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error(`${key} unavailable`);
|
||||
},
|
||||
});
|
||||
|
||||
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
|
||||
const { kept, warnings } = keep([
|
||||
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
|
||||
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
|
||||
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
|
||||
throwingGetter(provider(), 'kind'),
|
||||
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
|
||||
// Read only once the provider is accepted, so its kind must stay free.
|
||||
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
|
||||
provider({ kind: 'example-kind' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['example-kind'],
|
||||
);
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider thrown-face left out: malformed entry face',
|
||||
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
|
||||
'[Cockpit] subject provider thrown-look left out: a member threw when read',
|
||||
'[Cockpit] subject provider #3 left out: a member threw when read',
|
||||
'[Cockpit] subject provider thrown-method left out: a member threw when read',
|
||||
'[Cockpit] subject provider example-kind left out: a member threw when read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a worded getter that starts throwing keeps the last good words', () => {
|
||||
let failing = false;
|
||||
const words = (value) => () => {
|
||||
if (failing) throw new Error('translations unavailable');
|
||||
return value;
|
||||
};
|
||||
const definition = provider();
|
||||
for (const [key, value] of Object.entries({
|
||||
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
|
||||
}))
|
||||
Object.defineProperty(definition, key, { get: words(value) });
|
||||
const { kept, warnings } = keep([definition]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
failing = true;
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
|
||||
// So does a worded object whose own member throws when read.
|
||||
failing = false;
|
||||
Object.defineProperty(definition, 'labels', {
|
||||
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
|
||||
});
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
});
|
||||
|
||||
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
|
||||
null,
|
||||
);
|
||||
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
|
||||
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
|
||||
null,
|
||||
);
|
||||
// Nothing to read is still the empty view, never null.
|
||||
assert.equal(cockpitSubjectHudView(null).title, '');
|
||||
});
|
||||
@@ -481,6 +481,24 @@ export function orbitEntryAllowed({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an application subject provider's candidate can be ridden. Like the
|
||||
* bridge and orbit it needs only its own layer on, and waits out a Context
|
||||
* change in flight; the provider then has its own say (`isEntryAllowed`).
|
||||
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
|
||||
* providerAllows: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function subjectEntryAllowed({
|
||||
layerEnabled,
|
||||
contextModeChanging,
|
||||
providerAllows,
|
||||
}) {
|
||||
return (
|
||||
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context-mode id → the word the voice tools accept for it.
|
||||
*
|
||||
|
||||
+88
-18
@@ -85,16 +85,24 @@ const TEXT_SHIP_TYPES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
|
||||
* emitter category `category`: the type's, else the category's, else
|
||||
* `default_aircraft`.
|
||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
||||
* emitter category `category` and callsign `callsign`: the first of the
|
||||
* type's callsign rules (`aircraft_callsign`) the callsign matches, else the
|
||||
* type's, else the category's, else `default_aircraft`. An airport vehicle
|
||||
* (type ZVEH) is a fire truck, tug or van by its callsign, else a car.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string} [type]
|
||||
* @param {string} [category]
|
||||
* @param {string} [callsign]
|
||||
* @returns {{model: string, length: number|null}}
|
||||
*/
|
||||
export function resolveAircraft(types, type, category) {
|
||||
export function resolveAircraft(types, type, category, callsign) {
|
||||
const t = type?.trim().toUpperCase();
|
||||
const index = aircraftCallsignRule(types, t, callsign);
|
||||
if (index >= 0) {
|
||||
const rule = types.aircraft_callsign[t][index];
|
||||
return { model: rule.model, length: rule.length };
|
||||
}
|
||||
const [model, length] =
|
||||
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
|
||||
(category && types.categories[category]) ||
|
||||
@@ -102,6 +110,51 @@ export function resolveAircraft(types, type, category) {
|
||||
return { model, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A callsign as the rules read it: upper case, spaces removed.
|
||||
* @param {string} callsign
|
||||
* @returns {string}
|
||||
*/
|
||||
function callsignKey(callsign) {
|
||||
return String(callsign).toUpperCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of type `type`'s callsign rules (`types.json`'s
|
||||
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
|
||||
* wins) `callsign` matches: its index, -1 when none does or the type has
|
||||
* none. A callsign matches a rule when, upper-cased with spaces removed, it
|
||||
* starts with one of the rule's prefixes.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type] ICAO type.
|
||||
* @param {string|null} [callsign]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function aircraftCallsignRule(types, type, callsign) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const rules = t ? types.aircraft_callsign?.[t] : undefined;
|
||||
if (!rules || !callsign) return -1;
|
||||
const c = callsignKey(callsign);
|
||||
return rules.findIndex((rule) =>
|
||||
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft of ICAO type `type` picks its model by callsign.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function aircraftModelByCallsign(types, type) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return Boolean(t && types.aircraft_callsign?.[t]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reported length as one a hull can have, else undefined (drawn as built).
|
||||
* @param {number} [length]
|
||||
@@ -133,8 +186,9 @@ export function hullBeam(beam) {
|
||||
/**
|
||||
* The model for a vessel of AIS ship type `shipType` and the reported length
|
||||
* and beam (metres) and name: the first rule that matches in the first type
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length, as
|
||||
* built when it is unknown.
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length;
|
||||
* when it is unknown (most live feeds send none), chosen and drawn at the
|
||||
* range's `typical_len`, else as built.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {number} [shipType]
|
||||
* @param {number} [length]
|
||||
@@ -145,13 +199,14 @@ export function hullBeam(beam) {
|
||||
export function resolveVessel(types, shipType, length, beam, name) {
|
||||
const L = hullLength(length) ?? 0;
|
||||
const B = hullBeam(beam) ?? 0;
|
||||
const drawn = L > 0 ? L : null;
|
||||
if (shipType !== undefined && shipType !== null) {
|
||||
for (const group of types.vessels) {
|
||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
||||
const len = L > 0 ? L : (group.typical_len ?? 0);
|
||||
const drawn = len > 0 ? len : null;
|
||||
for (const rule of group.rules) {
|
||||
if (rule.min_len && L < rule.min_len) continue;
|
||||
if (rule.max_len && L > rule.max_len) continue;
|
||||
if (rule.min_len && len < rule.min_len) continue;
|
||||
if (rule.max_len && len > rule.max_len) continue;
|
||||
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
||||
continue;
|
||||
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
||||
@@ -159,7 +214,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { model: types.default_vessel, length: drawn };
|
||||
return { model: types.default_vessel, length: L > 0 ? L : null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,24 +359,32 @@ function specOf(set, resolved, fallback, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The model an aircraft is drawn with. The type's entry first; then, for a
|
||||
* The model an aircraft is drawn with. The type's callsign rules (an airport
|
||||
* vehicle by its callsign), then the type's entry; then, for a
|
||||
* type the map lacks, the model of its class (a fast jet, a helicopter, a
|
||||
* glider) when the type names one; then the emitter category's; then
|
||||
* `default_aircraft`.
|
||||
* @param {{catalog: object, types: object}} set
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null}} aircraft
|
||||
* category?: string|number|null, callsign?: string|null}} aircraft
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
export function aircraftModelSpec(
|
||||
set,
|
||||
{ typeCode, type, category, callsign } = {},
|
||||
) {
|
||||
const { types, catalog } = set;
|
||||
const code = String(typeCode ?? type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const emitter = emitterCategory(category);
|
||||
let resolved = resolveAircraft(types, code, emitter);
|
||||
const sign = callsign ? String(callsign) : undefined;
|
||||
let resolved = resolveAircraft(types, code, emitter, sign);
|
||||
// A callsign rule names the model outright; a type's class only stands in
|
||||
// for a type the map lacks.
|
||||
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
|
||||
const typed = code && (types.aircraft[code] || types.helicopters[code]);
|
||||
if (!typed && code) {
|
||||
if (!ruled && !typed && code) {
|
||||
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
|
||||
if (model && catalog[model])
|
||||
resolved = { model, length: catalog[model].length };
|
||||
@@ -341,18 +404,22 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||
// typical length keeps the model's proportions.
|
||||
const reported = hullLength(length ?? undefined);
|
||||
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
|
||||
const resolved = resolveVessel(
|
||||
set.types,
|
||||
aisShipTypeCode(type),
|
||||
length ?? undefined,
|
||||
beam ?? undefined,
|
||||
reported,
|
||||
hullWidth,
|
||||
name ?? undefined,
|
||||
);
|
||||
return specOf(
|
||||
set,
|
||||
resolved,
|
||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||
{ beam: beam ?? undefined },
|
||||
{ beam: hullWidth },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,6 +448,9 @@ export function namedFleetModels(types) {
|
||||
...pairs(types.aircraft),
|
||||
...pairs(types.helicopters),
|
||||
...pairs(types.categories),
|
||||
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
|
||||
rules.map((rule) => rule.model),
|
||||
),
|
||||
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
|
||||
types.default_aircraft[0],
|
||||
types.default_vessel,
|
||||
|
||||
@@ -51,12 +51,22 @@ const sliceTypes = {
|
||||
A21N: ['a320', 44.51],
|
||||
A319: ['a320', 33.84],
|
||||
B38M: ['b738', 39.52],
|
||||
ZVEH: ['car', 4.61],
|
||||
GRND: ['radio_mast', 1.9],
|
||||
},
|
||||
helicopters: { EC35: ['ec35', 10.2] },
|
||||
aircraft_callsign: {
|
||||
ZVEH: [
|
||||
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
|
||||
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
|
||||
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
|
||||
],
|
||||
},
|
||||
categories: { A1: ['c172', 8.3] },
|
||||
vessels: [
|
||||
{
|
||||
types: [70, 79],
|
||||
typical_len: 300,
|
||||
rules: [
|
||||
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
||||
{
|
||||
@@ -111,6 +121,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
|
||||
});
|
||||
});
|
||||
|
||||
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
|
||||
const zveh = (callsign) =>
|
||||
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
|
||||
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
|
||||
model: 'fire_truck',
|
||||
length: 11.01,
|
||||
});
|
||||
assert.equal(zveh('PUSH 14').model, 'pushback');
|
||||
assert.equal(zveh('TUG3').model, 'pushback');
|
||||
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
|
||||
assert.equal(zveh('SQUID20').model, 'van');
|
||||
assert.equal(zveh('SQB9').model, 'van');
|
||||
assert.equal(zveh('S Q B 9').model, 'van');
|
||||
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
|
||||
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
|
||||
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
|
||||
assert.equal(
|
||||
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
|
||||
'a320',
|
||||
);
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
|
||||
model: 'radio_mast',
|
||||
length: 1.9,
|
||||
});
|
||||
});
|
||||
|
||||
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
|
||||
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
|
||||
assert.equal(
|
||||
@@ -133,6 +170,29 @@ test('vessels: the first matching rule of the first type range with one, else th
|
||||
});
|
||||
});
|
||||
|
||||
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
|
||||
model: 'general_cargo',
|
||||
length: 90,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 31), {
|
||||
model: 'tug',
|
||||
length: null,
|
||||
});
|
||||
// Every passenger, cargo and tanker range of the set has one.
|
||||
for (const code of [60, 70, 80]) {
|
||||
assert.ok(resolveVessel(types, code).length > 100, String(code));
|
||||
}
|
||||
});
|
||||
|
||||
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
|
||||
model: 'tug',
|
||||
@@ -201,6 +261,46 @@ test('transit bodies mark the share of the route colour they take; other models
|
||||
}
|
||||
});
|
||||
|
||||
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
|
||||
const zveh = (callsign) =>
|
||||
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
|
||||
.model;
|
||||
assert.equal(zveh('FIRE 2'), 'fire_truck');
|
||||
assert.equal(zveh('PUSHBACK1'), 'pushback');
|
||||
assert.equal(zveh('TUG 4'), 'pushback');
|
||||
assert.equal(zveh('DEICER 4'), 'van');
|
||||
assert.equal(zveh('SQUID20'), 'van');
|
||||
assert.equal(zveh('SQB9'), 'van');
|
||||
assert.equal(zveh('LEADER 7'), 'car');
|
||||
assert.equal(zveh('AOPS'), 'car');
|
||||
assert.equal(zveh(null), 'car');
|
||||
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
|
||||
assert.equal(mast.model, 'radio_mast');
|
||||
assert.equal(mast.scale, 1);
|
||||
});
|
||||
|
||||
test('a callsign rule outranks the class model of a type the map lacks', () => {
|
||||
const ruled = {
|
||||
...set,
|
||||
types: {
|
||||
...set.types,
|
||||
aircraft_callsign: {
|
||||
...set.types.aircraft_callsign,
|
||||
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
|
||||
'fire_truck',
|
||||
);
|
||||
// No rule matched: the class model, as before.
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
|
||||
'generic_heli',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
|
||||
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
|
||||
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
|
||||
@@ -267,6 +367,12 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
||||
const [sx, sy, sz] = box.scaleXYZ;
|
||||
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
||||
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
|
||||
// A beam with no reported length: the type's typical length, in the model's
|
||||
// own proportions, not a typical-length hull squeezed to that beam.
|
||||
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
|
||||
assert.equal(unsized.model, 'container_ship');
|
||||
const [ux, uy, uz] = unsized.scaleXYZ;
|
||||
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
|
||||
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
|
||||
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
||||
// No type, no size: the generic vessel as built.
|
||||
|
||||
@@ -55,6 +55,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
pc12: Object.freeze([6.6416, 0.5288, 0]),
|
||||
pushback: Object.freeze([3, 0.2499, 0]),
|
||||
r44: Object.freeze([4.4219, -0.3001, 0]),
|
||||
radio_mast: Object.freeze([1.2, 0, 0]),
|
||||
s76: Object.freeze([6.6001, 0.1915, 0]),
|
||||
sr22: Object.freeze([3.6762, 0.2376, 0]),
|
||||
t38: Object.freeze([7.0246, -0.3791, 0]),
|
||||
|
||||
@@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf
|
||||
);
|
||||
// A grounded aircraft stands on its wheels: its origin the scaled
|
||||
// clearance above the ground, never below it.
|
||||
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
// A model built standing on its origin (a ground vehicle, a ground
|
||||
// station's mast: its lowest point at z 0) has no clearance.
|
||||
if (set.catalog[model].min[2] > -0.05)
|
||||
assert.ok(
|
||||
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
|
||||
`${type}: built standing, clearance ${spec.clearanceM}`,
|
||||
);
|
||||
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
|
||||
}
|
||||
}
|
||||
@@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy
|
||||
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
|
||||
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
|
||||
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
|
||||
'../layers/flights/modelSpec.js'
|
||||
);
|
||||
const tender = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
|
||||
set,
|
||||
);
|
||||
const car = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
|
||||
set,
|
||||
);
|
||||
assert.equal(tender.model, 'fire_truck');
|
||||
assert.equal(car.model, 'car');
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
|
||||
);
|
||||
assert.notEqual(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
);
|
||||
// Keyed by the rule matched, not the callsign: at most one entry per rule
|
||||
// and one for the type's own model.
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
tender,
|
||||
);
|
||||
});
|
||||
@@ -3953,5 +3953,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
|
||||
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
|
||||
"Reload": "إعادة التحميل",
|
||||
"Retry": "إعادة المحاولة",
|
||||
"Reset view": "إعادة ضبط العرض"
|
||||
}
|
||||
@@ -3605,5 +3605,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
|
||||
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
|
||||
"Reload": "Neu laden",
|
||||
"Retry": "Erneut versuchen",
|
||||
"Reset view": "Ansicht zurücksetzen"
|
||||
}
|
||||
@@ -3692,5 +3692,12 @@
|
||||
"500–1,000 m": "500–1000 m",
|
||||
"1,000–2,000 m": "1000–2000 m",
|
||||
"2,000–3,000 m": "2000–3000 m",
|
||||
"3,000 m and above": "3000 m o más"
|
||||
"3,000 m and above": "3000 m o más",
|
||||
"The globe stopped drawing": "El globo dejó de dibujarse",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
|
||||
"Reload": "Recargar",
|
||||
"Retry": "Reintentar",
|
||||
"Reset view": "Restablecer la vista"
|
||||
}
|
||||
@@ -3692,5 +3692,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
|
||||
"The globe stopped drawing": "Le globe ne s’affiche plus",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
|
||||
"Reload": "Recharger",
|
||||
"Retry": "Réessayer",
|
||||
"Reset view": "Réinitialiser la vue"
|
||||
}
|
||||
@@ -3692,5 +3692,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
|
||||
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
|
||||
"Reload": "טען מחדש",
|
||||
"Retry": "נסה שוב",
|
||||
"Reset view": "איפוס תצוגה"
|
||||
}
|
||||
@@ -3692,5 +3692,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
|
||||
"The globe stopped drawing": "Il globo ha smesso di disegnare",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
|
||||
"Reload": "Ricarica",
|
||||
"Retry": "Riprova",
|
||||
"Reset view": "Ripristina vista"
|
||||
}
|
||||
@@ -3518,5 +3518,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る"
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る",
|
||||
"The globe stopped drawing": "地球儀の描画が止まりました",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
|
||||
"Reload": "再読み込み",
|
||||
"Retry": "再試行",
|
||||
"Reset view": "ビューをリセット"
|
||||
}
|
||||
@@ -3692,5 +3692,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
|
||||
"The globe stopped drawing": "O globo parou de desenhar",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
|
||||
"Reload": "Recarregar",
|
||||
"Retry": "Tentar novamente",
|
||||
"Reset view": "Redefinir vista"
|
||||
}
|
||||
@@ -3779,5 +3779,12 @@
|
||||
"500–1,000 m": "500–1000 м",
|
||||
"1,000–2,000 m": "1000–2000 м",
|
||||
"2,000–3,000 m": "2000–3000 м",
|
||||
"3,000 m and above": "3000 м и выше"
|
||||
"3,000 m and above": "3000 м и выше",
|
||||
"The globe stopped drawing": "Глобус перестал отрисовываться",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
|
||||
"Reload": "Перезагрузить",
|
||||
"Retry": "Повторить",
|
||||
"Reset view": "Сбросить вид"
|
||||
}
|
||||
@@ -3605,5 +3605,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
|
||||
"The globe stopped drawing": "Globen ritas inte längre",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
|
||||
"Reload": "Ladda om",
|
||||
"Retry": "Försök igen",
|
||||
"Reset view": "Återställ vy"
|
||||
}
|
||||
@@ -3779,5 +3779,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
|
||||
"The globe stopped drawing": "Глобус перестав відмальовуватися",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
|
||||
"Reload": "Перезавантажити",
|
||||
"Retry": "Повторити",
|
||||
"Reset view": "Скинути вигляд"
|
||||
}
|
||||
@@ -3518,5 +3518,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库"
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库",
|
||||
"The globe stopped drawing": "地球已停止绘制",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
|
||||
"Reload": "重新加载",
|
||||
"Retry": "重试",
|
||||
"Reset view": "重置视图"
|
||||
}
|
||||
@@ -3518,5 +3518,12 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫",
|
||||
"The globe stopped drawing": "地球已停止繪製",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
|
||||
"Reload": "重新加載",
|
||||
"Retry": "重試",
|
||||
"Reset view": "重置視圖"
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js';
|
||||
import {
|
||||
aircraftCallsignRule,
|
||||
aircraftModelByCallsign,
|
||||
aircraftModelSpec,
|
||||
fleetModelSet,
|
||||
} from '../../data/fleetModels.js';
|
||||
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js';
|
||||
|
||||
/**
|
||||
@@ -25,12 +30,14 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
||||
|
||||
/**
|
||||
* The fleet model, life-size scale and measured geometry an aircraft draws
|
||||
* with, chosen by its ICAO type and emitter category (`fleetModels.js`).
|
||||
* with, chosen by its ICAO type and emitter category, and by its callsign
|
||||
* for the types that pick by callsign (`fleetModels.js`).
|
||||
* Shared by the Flights, Military Flights and Local ADS-B layers so all three
|
||||
* draw the same model for the same aircraft. `bellyM` is the model's scaled
|
||||
* clearance: how far a grounded aircraft's origin stands above the ground.
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null}|null|undefined} aircraft
|
||||
* category?: string|number|null,
|
||||
* callsign?: string|null}|null|undefined} aircraft
|
||||
* @param {object|null} [set] The loaded fleet model set.
|
||||
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
|
||||
* the set has loaded.
|
||||
@@ -40,12 +47,13 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
// Layers ask on every tick; each type and category is resolved once per set.
|
||||
let cache = specCaches.get(set);
|
||||
if (!cache) specCaches.set(set, (cache = new Map()));
|
||||
const key = aircraftModelSpecCacheKey(aircraft);
|
||||
const key = aircraftModelSpecCacheKey(aircraft, set);
|
||||
let layerSpec = cache.get(key);
|
||||
if (!layerSpec) {
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
callsign: aircraft?.callsign ?? null,
|
||||
});
|
||||
layerSpec = Object.freeze({
|
||||
...spec,
|
||||
@@ -61,10 +69,18 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
const specCaches = new WeakMap();
|
||||
|
||||
/**
|
||||
* Cache key of an aircraft's model spec: what selects its model.
|
||||
* Cache key of an aircraft's model spec: what selects its model. For a type
|
||||
* that picks its model by callsign (an airport vehicle) the key adds the
|
||||
* rule its callsign matches, or `type` for none, so such a type caches at
|
||||
* most one entry per rule and one for its own model.
|
||||
* @param {object|null|undefined} aircraft
|
||||
* @param {object|null} [set] The loaded fleet model set.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function aircraftModelSpecCacheKey(aircraft) {
|
||||
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`;
|
||||
export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) {
|
||||
const type = aircraft?.typeCode ?? aircraft?.type ?? '';
|
||||
const key = `${type}|${aircraft?.category ?? ''}`;
|
||||
if (!set || !aircraftModelByCallsign(set.types, type)) return key;
|
||||
const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign);
|
||||
return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
aircraftCallsignRule,
|
||||
aircraftModelSpec,
|
||||
fleetModelSet,
|
||||
} from '../../data/fleetModels.js';
|
||||
import {
|
||||
approxDistanceKm,
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
@@ -21,6 +26,7 @@ export class FlightRecords {
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
fleetModels = fleetModelSet,
|
||||
}) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
@@ -29,6 +35,8 @@ export class FlightRecords {
|
||||
};
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
/** The loaded fleet model set, or null before it loads. */
|
||||
this.fleetModels = fleetModels;
|
||||
this.data = new Map();
|
||||
// Facts about an airframe, not a moment, kept across a reset.
|
||||
this.airframes = new Map();
|
||||
@@ -92,7 +100,6 @@ export class FlightRecords {
|
||||
verticalRateMps: vertical_rate,
|
||||
} = observation;
|
||||
|
||||
const onGround = on_ground === true;
|
||||
this.missingPolls.delete(icao24);
|
||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||
// aircraft it still positions — hold last-known-good instead of
|
||||
@@ -102,6 +109,27 @@ export class FlightRecords {
|
||||
// The airframe's facts: this record's, or those kept across a reset.
|
||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||
this.airframes.delete(icao24);
|
||||
// A ground vehicle or ground station (a feed's ZVEH or GRND: a model
|
||||
// built standing, catalog category `other`) takes the feed's own type,
|
||||
// which no airframe lookup knows; every other aircraft keeps waiting for
|
||||
// its looked-up type. With no altitude at all it stands on the ground,
|
||||
// where it is, instead of at the airborne default.
|
||||
const groundModel = (typeCode) =>
|
||||
drawnAsGroundModel(
|
||||
this.fleetModels(),
|
||||
typeCode,
|
||||
category,
|
||||
stickyText(callsign, prevMeta?.callsign),
|
||||
);
|
||||
const feedType = observation.typeCode || null;
|
||||
const typeCode =
|
||||
airframe?.typeCode ??
|
||||
(feedType && groundModel(feedType) ? feedType : null);
|
||||
const onGround =
|
||||
on_ground === true ||
|
||||
(!Number.isFinite(baro_alt) &&
|
||||
!Number.isFinite(geo_alt) &&
|
||||
groundModel(typeCode));
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
@@ -319,8 +347,9 @@ export class FlightRecords {
|
||||
prevMeta?.lastContactEpochMs,
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried
|
||||
// across polls; a ground vehicle's or station's from the feed:
|
||||
typeCode,
|
||||
typeName: airframe?.typeName ?? null,
|
||||
registration: airframe?.registration ?? null,
|
||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||
@@ -408,3 +437,33 @@ export class FlightRecords {
|
||||
this.geoidNCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */
|
||||
const groundModelCaches = new WeakMap();
|
||||
|
||||
/**
|
||||
* Whether an aircraft of this type, emitter category and callsign is drawn
|
||||
* with a model built standing on the ground (catalog category `other`: an
|
||||
* airport vehicle, a ground station's mast). False before the set loads.
|
||||
* Answered once per type, category and callsign rule matched.
|
||||
* @param {object|null} set The loaded fleet model set.
|
||||
* @param {string|null} typeCode
|
||||
* @param {string|number|null} category
|
||||
* @param {string|null} callsign
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function drawnAsGroundModel(set, typeCode, category, callsign) {
|
||||
if (!set || !typeCode) return false;
|
||||
let cache = groundModelCaches.get(set);
|
||||
if (!cache) groundModelCaches.set(set, (cache = new Map()));
|
||||
const rule = aircraftCallsignRule(set.types, typeCode, callsign);
|
||||
const key = `${typeCode}|${category ?? ''}|${rule}`;
|
||||
let ground = cache.get(key);
|
||||
if (ground === undefined) {
|
||||
ground =
|
||||
aircraftModelSpec(set, { typeCode, category, callsign }).category ===
|
||||
'other';
|
||||
cache.set(key, ground);
|
||||
}
|
||||
return ground;
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FlightRecords } from './records.js';
|
||||
|
||||
function records() {
|
||||
const FLEET = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../public/models/fleet',
|
||||
);
|
||||
const fleetSet = {
|
||||
catalog: JSON.parse(
|
||||
fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'),
|
||||
),
|
||||
types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')),
|
||||
};
|
||||
|
||||
function records(fleetModels = () => null) {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 40,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
fleetModels,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
@@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged
|
||||
store.forget('abc123');
|
||||
assert.equal(store.fixRepeated.has('abc123'), false);
|
||||
});
|
||||
|
||||
test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => {
|
||||
const store = records(() => fleetSet);
|
||||
const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null };
|
||||
const vehicle = store.receive(
|
||||
observation({
|
||||
id: 'v1',
|
||||
typeCode: 'ZVEH',
|
||||
callsign: 'FIRE 2',
|
||||
...noAltitude,
|
||||
}),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(vehicle.onGround, true);
|
||||
assert.equal(vehicle.typeCode, 'ZVEH');
|
||||
assert.equal(vehicle.altitude, 0);
|
||||
const mast = store.receive(
|
||||
observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(mast.onGround, true);
|
||||
assert.equal(mast.typeCode, 'GRND');
|
||||
// An airliner with no altitude keeps the airborne default, and its feed
|
||||
// type still waits for the airframe lookup.
|
||||
const plane = store.receive(
|
||||
observation({ id: 'p1', typeCode: 'A320', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(plane.onGround, false);
|
||||
assert.equal(plane.altitude, 10000);
|
||||
assert.equal(plane.typeCode, null);
|
||||
// A vehicle reporting an altitude is placed by it.
|
||||
const reported = store.receive(
|
||||
observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(reported.onGround, false);
|
||||
// Before the model set loads nothing is grounded by its model.
|
||||
const unloaded = records().receive(
|
||||
observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(unloaded.onGround, false);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const specOf = (marker) =>
|
||||
aircraftLayerModelSpec({
|
||||
typeCode: marker?.typeCode,
|
||||
category: marker?.category,
|
||||
callsign: marker?.record?.callsign,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import { displayMotion } from '../transit/movement.js';
|
||||
|
||||
/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */
|
||||
const lines = new WeakMap();
|
||||
|
||||
/**
|
||||
* A line as a snapshot hands it out: a frozen copy of the layer's path, its
|
||||
* number arrays frozen arrays, built once per line. `pointAtS` and
|
||||
* `projectOnPath` read it as they read the path, and nothing written to it
|
||||
* reaches the layer.
|
||||
*/
|
||||
function lineOf(path) {
|
||||
let line = lines.get(path);
|
||||
if (!line) {
|
||||
const copy = {};
|
||||
for (const [name, value] of Object.entries(path))
|
||||
copy[name] = ArrayBuffer.isView(value)
|
||||
? Object.freeze(Array.from(value))
|
||||
: value;
|
||||
line = Object.freeze(copy);
|
||||
lines.set(path, line);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
const NO_STOPS = Object.freeze([]);
|
||||
/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */
|
||||
const stopLists = new WeakMap();
|
||||
|
||||
/** Whether `copy` still holds each of `list`'s stops, field by field. */
|
||||
function sameStops(copy, list) {
|
||||
if (copy.length !== list.length) return false;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const stop = list[i];
|
||||
const kept = copy[i];
|
||||
if (
|
||||
!stop ||
|
||||
typeof stop !== 'object' ||
|
||||
!kept ||
|
||||
typeof kept !== 'object'
|
||||
) {
|
||||
if (!Object.is(stop, kept)) return false;
|
||||
continue;
|
||||
}
|
||||
const names = Object.keys(stop);
|
||||
if (names.length !== Object.keys(kept).length) return false;
|
||||
for (const name of names)
|
||||
if (!Object.is(stop[name], kept[name])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A trip's stops as a snapshot hands them out: a frozen array of frozen
|
||||
* copies, kept per list the stops provider returns while that list still
|
||||
* holds the same stops, so a provider that updates its list in place is
|
||||
* copied again.
|
||||
*/
|
||||
function stopsOf(list) {
|
||||
if (!Array.isArray(list)) return NO_STOPS;
|
||||
let stops = stopLists.get(list);
|
||||
if (!stops || !sameStops(stops, list)) {
|
||||
stops = Object.freeze(
|
||||
list.map((stop) =>
|
||||
stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop,
|
||||
),
|
||||
);
|
||||
stopLists.set(list, stops);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
/**
|
||||
* `previous` when it holds each of `next`'s values, else a frozen copy of
|
||||
* `next`: a part of a snapshot that did not change is the one read before.
|
||||
* `next` is a draft, filled again on every read.
|
||||
*/
|
||||
function reuse(previous, next) {
|
||||
if (!previous) return Object.freeze({ ...next });
|
||||
for (const name in next)
|
||||
if (!Object.is(previous[name], next[name]))
|
||||
return Object.freeze({ ...next });
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read access to one train, and a hold on it for a first-person view.
|
||||
*
|
||||
* `getSelectedTrain()` and `getCockpitSubject()` describe the selected train
|
||||
* and the held one as a frozen snapshot, read again on each call: where it is
|
||||
* along its line, how fast it moves and how that is known, and its stops. A
|
||||
* call while nothing in it changed returns the snapshot it returned before,
|
||||
* and a part that did not change (its feed, its line, its stops) is the same
|
||||
* object in the next one, so a view can read it on every frame. `path.line`
|
||||
* is a frozen copy of the train's line for `pointAtS` and `projectOnPath`.
|
||||
* `setCockpitSubject(key)` holds a train: its marker, 3D model, card and
|
||||
* selected line are hidden, it stays selected (so its stops stay fresh and its feed stays
|
||||
* polled at any distance) and clicks and Escape no longer change the
|
||||
* selection. A held train keeps moving, hidden, and keeps the selection when
|
||||
* its category is turned off, and is the copy of its train drawn though
|
||||
* another feed's copy would outrank it (dedupe.js). Released, it is a train
|
||||
* like any other: one in a category turned off is deselected and stops, and
|
||||
* a better copy takes over at the next poll. The hold ends with
|
||||
* `setCockpitSubject(null)`, and on its own when the train is removed or the
|
||||
* layer is turned off. A train removed while held leaves its feed polled
|
||||
* until the next `setCockpitSubject` call, so a view that waits for it holds
|
||||
* it again if the feed lists it again.
|
||||
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
|
||||
* A snapshot's `selectedAtMs` is when the user last selected the selected
|
||||
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
|
||||
* re-selection. It is null for any other train, after a deselection, and for
|
||||
* a train selected only by a hold: a hold is not a user's selection.
|
||||
*
|
||||
* @param {object} context The layer's state, parts, services and timetable.
|
||||
* @returns {{methods: object}}
|
||||
*/
|
||||
export function createRailCockpitSubject({
|
||||
state,
|
||||
parts,
|
||||
services,
|
||||
timetable,
|
||||
}) {
|
||||
const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M)
|
||||
? services.ground.GROUND_FLOOR_LIFT_M
|
||||
: 0;
|
||||
const finiteOrNull = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const scratch = {};
|
||||
/** Each train's last snapshot, returned again while nothing in it changed. */
|
||||
const views = new WeakMap();
|
||||
/** The drafts trainView fills on every read (reuse). */
|
||||
const draft = {
|
||||
view: {},
|
||||
feed: {},
|
||||
position: {},
|
||||
path: {},
|
||||
motion: {},
|
||||
provenance: {},
|
||||
};
|
||||
|
||||
/** The catalog's public entry for a feed, else the registry's. */
|
||||
function feedOf(feedId, previous) {
|
||||
const listed = state._catalog?.find((feed) => feed.id === feedId);
|
||||
const feed = listed ?? getRegisteredRailFeed(feedId);
|
||||
if (!feed) return null;
|
||||
const next = draft.feed;
|
||||
next.id = feed.id;
|
||||
next.name = feed.name ?? null;
|
||||
next.operator = feed.operator ?? null;
|
||||
next.region = feed.region ?? null;
|
||||
next.positions = listed?.positions ?? feed.positions ?? 'gps';
|
||||
next.tripUpdates =
|
||||
typeof listed?.tripUpdates === 'boolean'
|
||||
? listed.tripUpdates
|
||||
: typeof feed.tripUpdatesUrl === 'string';
|
||||
next.attribution = feed.attribution ?? null;
|
||||
next.license = feed.license ?? null;
|
||||
next.licenseUrl = feed.licenseUrl ?? null;
|
||||
return reuse(previous, next);
|
||||
}
|
||||
|
||||
/** The route's short (else long) name in the feed's timetable, or null. */
|
||||
function routeNameOf(entry) {
|
||||
const routeId = entry.record?.routeId;
|
||||
if (!routeId) return null;
|
||||
const schedule = timetable?.scheduleFor?.(entry.feedId);
|
||||
const route = schedule?.routes?.[routeId];
|
||||
const name = route?.short || route?.long;
|
||||
return typeof name === 'string' && name ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way the train runs along its shape: as its binding learnt it from
|
||||
* successive reports, else from its drawn course against the line's
|
||||
* bearing there; 0 while neither tells.
|
||||
*/
|
||||
function directionOf(entry) {
|
||||
const binding = entry.path;
|
||||
if (binding.direction === 1 || binding.direction === -1)
|
||||
return binding.direction;
|
||||
const course = finiteOrNull(entry.courseDeg);
|
||||
const point = pointAtS(binding.path, binding.drawS, scratch);
|
||||
if (course === null || !Number.isFinite(point?.bearingDeg)) return 0;
|
||||
const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180);
|
||||
return turn > 90 ? -1 : 1;
|
||||
}
|
||||
|
||||
/** The train as a frozen snapshot (see the module comment), or null. */
|
||||
function trainView(entry) {
|
||||
if (!entry?.record) return null;
|
||||
const previous = views.get(entry);
|
||||
const record = entry.record;
|
||||
const sample = entry.sample ?? null;
|
||||
const drawn = entry.drawnHeight ?? null;
|
||||
const binding = entry.path ?? null;
|
||||
const reported = finiteOrNull(record.speedMps);
|
||||
const derived = finiteOrNull(sample?.segmentSpeedMps);
|
||||
const trip = state._selectedKey === entry.key ? state._trip : null;
|
||||
const status = state._feedStatus.get(entry.feedId);
|
||||
|
||||
const position = draft.position;
|
||||
position.latitude =
|
||||
finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat;
|
||||
position.longitude =
|
||||
finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon;
|
||||
position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null;
|
||||
position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0;
|
||||
position.atMs = finiteOrNull(drawn?.at);
|
||||
|
||||
let path = null;
|
||||
if (binding && binding.path) {
|
||||
path = draft.path;
|
||||
path.shapeIndex = binding.shapeIndex;
|
||||
path.s = binding.drawS;
|
||||
path.direction = directionOf(entry);
|
||||
path.lengthM = binding.path.length;
|
||||
path.via = binding.via;
|
||||
path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null;
|
||||
path.line = lineOf(binding.path);
|
||||
}
|
||||
|
||||
const motion = draft.motion;
|
||||
motion.reportedSpeedMps = reported;
|
||||
motion.derivedSpeedMps = derived;
|
||||
motion.speedSource =
|
||||
reported !== null ? 'reported' : derived !== null ? 'derived' : null;
|
||||
motion.courseDeg = finiteOrNull(entry.courseDeg);
|
||||
motion.phase = sample?.phase ?? null;
|
||||
motion.word = displayMotion(entry, Date.now()).word;
|
||||
motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs);
|
||||
motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs);
|
||||
motion.feedStale = status?.stale === true;
|
||||
|
||||
const provenance = draft.provenance;
|
||||
provenance.positionSource = record.positionSource ?? 'gps';
|
||||
provenance.timestampSource = record.timestampSource ?? null;
|
||||
|
||||
const view = draft.view;
|
||||
view.key = entry.key;
|
||||
view.feedId = entry.feedId;
|
||||
view.selectedAtMs =
|
||||
state._selectedKey === entry.key ? state._selectedAtMs : null;
|
||||
view.id = String(record.id ?? '');
|
||||
view.tripId = record.tripId || null;
|
||||
view.routeId = record.routeId || null;
|
||||
view.routeName = routeNameOf(entry);
|
||||
view.label = record.label || null;
|
||||
view.trainNumber = record.trainNumber || null;
|
||||
view.headsign = trip?.headsign || null;
|
||||
view.category = entry.category ?? null;
|
||||
view.mode = entry.railMode ?? null;
|
||||
view.feed = feedOf(entry.feedId, previous?.feed);
|
||||
view.position = reuse(previous?.position, position);
|
||||
view.path = path && reuse(previous?.path, path);
|
||||
view.motion = reuse(previous?.motion, motion);
|
||||
view.provenance = reuse(previous?.provenance, provenance);
|
||||
view.stops = stopsOf(trip?.stops);
|
||||
view.match = trip?.match ?? null;
|
||||
const snapshot = reuse(previous, view);
|
||||
views.set(entry, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** A train's marker and playback again, after a hold on it began or ended. */
|
||||
function replay(entry) {
|
||||
parts.rendering.paint(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.rendering.syncRenderHold();
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** The selected train (see createRailCockpitSubject), or null. */
|
||||
getSelectedTrain() {
|
||||
return trainView(state._trains.get(state._selectedKey));
|
||||
},
|
||||
/** The held train, or null when none is held. */
|
||||
getCockpitSubject() {
|
||||
return trainView(state._trains.get(state._cockpitSubjectKey));
|
||||
},
|
||||
/**
|
||||
* Hold the train `key` for a first-person view, or release the hold with
|
||||
* null. A train it holds is selected, if it was not already.
|
||||
* @param {string|null} key
|
||||
* @returns {boolean} False for a key the layer does not list. A listed
|
||||
* train is held even when its category is off or another feed's copy
|
||||
* is drawn: the held copy is then the one drawn. Releasing returns true.
|
||||
*/
|
||||
setCockpitSubject(key) {
|
||||
const previous = state._trains.get(state._cockpitSubjectKey);
|
||||
if (key === null || key === undefined) {
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = null;
|
||||
if (previous) {
|
||||
// Its category turned off meanwhile: deselected, as the toggle
|
||||
// does to any other train (controls.js).
|
||||
if (
|
||||
previous.key === state._selectedKey &&
|
||||
state._categories[previous.category] === false
|
||||
)
|
||||
parts.selection.clearSelection();
|
||||
replay(previous);
|
||||
}
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
parts.routes.applyHold?.();
|
||||
return true;
|
||||
}
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry) return false;
|
||||
if (state._selectedKey !== key) {
|
||||
parts.selection.selectTrain(key);
|
||||
// A hold is not a selection the user made (selectedAtMs).
|
||||
state._selectedAtMs = null;
|
||||
}
|
||||
state._cockpitSubjectKey = key;
|
||||
state._cockpitLostFeedId = null;
|
||||
// Another feed's copy drew it: the held copy is drawn now (dedupe.js).
|
||||
if (entry.duplicate === true) parts.ingestion.duplicates?.refresh();
|
||||
if (previous && previous !== entry) replay(previous);
|
||||
replay(entry);
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
parts.routes.applyHold?.();
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Select the train `key` and hand it the camera, as a click on it does.
|
||||
* @param {string} key
|
||||
* @returns {boolean} False for a train the layer does not show: gone, in
|
||||
* a category turned off, or a copy another feed draws (dedupe.js).
|
||||
*/
|
||||
focusTrain(key) {
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry || !parts.rendering.categoryShown(entry)) return false;
|
||||
parts.selection.selectAndFocusTrain(key);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { methods, trainView };
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// The Rail layer's read API for a first-person view: the selected or held
|
||||
// train as a snapshot (where it is along its line and which way it runs, its
|
||||
// speed and how that is known, its feed's credit, its stops), and a hold that
|
||||
// hides its marker and card, keeps it selected and ends when it goes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_POLL_MS,
|
||||
STOP_REFRESH_MS,
|
||||
railTrainFollowable,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
import {
|
||||
BOSTON,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
|
||||
import { publicRailCatalog } from '../../data/railFeeds.js';
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
||||
|
||||
const M_PER_DEG = 111_320;
|
||||
const LAT = 42.36;
|
||||
const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180);
|
||||
const LON0 = -71.08;
|
||||
|
||||
/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */
|
||||
function eastLine(lengthM) {
|
||||
const out = [];
|
||||
for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX);
|
||||
return out;
|
||||
}
|
||||
|
||||
const PACK = Object.freeze({
|
||||
version: 1,
|
||||
feedId: 'mbta-rail',
|
||||
builtAt: '2026-09-09T00:00:00.000Z',
|
||||
shapes: [eastLine(6000)],
|
||||
dist: [[]],
|
||||
shapeSources: ['operator'],
|
||||
trips: { 'trip-T1': 0, 'trip-T2': 0 },
|
||||
routes: { R1: [0] },
|
||||
});
|
||||
|
||||
/** Two trains on the line, T1 running east and T2 west, a step a poll. */
|
||||
async function onTheLine(t, options = {}) {
|
||||
const app = harness(t, {
|
||||
paths: { 'mbta-rail': PACK },
|
||||
at: BOSTON,
|
||||
...options,
|
||||
});
|
||||
let step = 0;
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }),
|
||||
train('T2', LAT, LON0 + (5000 - step * 150) / KX),
|
||||
]);
|
||||
await app.enable();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
const T1 = railTrainKey('mbta-rail', 'T1');
|
||||
const T2 = railTrainKey('mbta-rail', 'T2');
|
||||
|
||||
test('the selected train reads as a snapshot of its place, motion and sources', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected');
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const view = app.layer.getSelectedTrain();
|
||||
assert.ok(Object.isFrozen(view));
|
||||
assert.equal(view.key, T1);
|
||||
assert.equal(view.feedId, 'mbta-rail');
|
||||
assert.equal(view.id, 'T1');
|
||||
assert.equal(view.tripId, 'trip-T1');
|
||||
assert.equal(view.routeId, 'R1');
|
||||
assert.equal(view.category, 'regional');
|
||||
assert.equal(view.mode, 'rail');
|
||||
const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail');
|
||||
assert.equal(view.feed.attribution, listed.attribution);
|
||||
assert.equal(view.feed.license, listed.license);
|
||||
assert.equal(view.feed.licenseUrl, listed.licenseUrl);
|
||||
assert.equal(view.feed.positions, 'gps');
|
||||
assert.equal(view.path.shapeIndex, 0);
|
||||
assert.equal(view.path.builtAt, PACK.builtAt);
|
||||
assert.equal(view.path.via, 'trip');
|
||||
assert.ok(Math.abs(view.path.lengthM - 6000) < 1);
|
||||
assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`);
|
||||
assert.equal(view.path.direction, 1, 'runs along the shape');
|
||||
assert.equal(typeof view.path.line.length, 'number');
|
||||
assert.equal(view.motion.reportedSpeedMps, 15);
|
||||
assert.equal(view.motion.speedSource, 'reported');
|
||||
assert.equal(view.provenance.positionSource, 'gps');
|
||||
assert.equal(view.provenance.timestampSource, 'vehicle');
|
||||
const drawn = app.entry(T1).drawnHeight;
|
||||
assert.equal(view.position.railHeightM, drawn.h - 1.5);
|
||||
assert.ok(Number.isFinite(view.position.latitude));
|
||||
assert.ok(Array.isArray(view.stops));
|
||||
|
||||
app.parts().selection.selectTrain(T2);
|
||||
const west = app.layer.getSelectedTrain();
|
||||
assert.equal(west.path.direction, -1, 'runs against the shape');
|
||||
assert.equal(west.motion.reportedSpeedMps, null);
|
||||
assert.equal(
|
||||
west.motion.speedSource,
|
||||
west.motion.derivedSpeedMps === null ? null : 'derived',
|
||||
);
|
||||
});
|
||||
|
||||
/** A trip's stops as a stops provider lists them. */
|
||||
const stopList = (...names) =>
|
||||
names.map((name, i) => ({
|
||||
stopId: name,
|
||||
name,
|
||||
lat: LAT,
|
||||
lon: LON0 + (i * 2000) / KX,
|
||||
state: 'upcoming',
|
||||
}));
|
||||
|
||||
test('a snapshot read again while nothing changed is the one read before', async (t) => {
|
||||
let list = stopList('Alpha', 'Bravo', 'Charlie');
|
||||
const app = await onTheLine(t, {
|
||||
stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) },
|
||||
});
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const first = app.layer.getSelectedTrain();
|
||||
assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot');
|
||||
// Its stops are frozen copies of the provider's.
|
||||
assert.equal(first.stops.length, 3);
|
||||
assert.ok(Object.isFrozen(first.stops));
|
||||
assert.ok(Object.isFrozen(first.stops[0]));
|
||||
assert.notEqual(first.stops[0], list[0]);
|
||||
assert.equal(first.stops[0].name, 'Alpha');
|
||||
|
||||
// It moves on: a new snapshot at its new place, what did not change kept.
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
app.run(RAIL_POLL_MS);
|
||||
const moved = app.layer.getSelectedTrain();
|
||||
assert.notEqual(moved, first, 'a new snapshot');
|
||||
assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path));
|
||||
assert.ok(moved.path.s > first.path.s, 'at its new place');
|
||||
assert.equal(moved.stops, first.stops, 'the same stops');
|
||||
assert.equal(moved.feed, first.feed);
|
||||
assert.equal(moved.provenance, first.provenance);
|
||||
assert.equal(moved.path.line, first.path.line);
|
||||
assert.equal(app.layer.getSelectedTrain(), moved);
|
||||
|
||||
// The provider lists other stops: new ones.
|
||||
list = stopList('Delta', 'Echo');
|
||||
app.run(STOP_REFRESH_MS);
|
||||
const later = app.layer.getSelectedTrain();
|
||||
assert.notEqual(later.stops, first.stops);
|
||||
assert.deepEqual(
|
||||
later.stops.map((stop) => stop.name),
|
||||
['Delta', 'Echo'],
|
||||
);
|
||||
// A provider that updates its list in place: the stops are copied again.
|
||||
list[0].state = 'passed';
|
||||
app.run(STOP_REFRESH_MS);
|
||||
const updated = app.layer.getSelectedTrain();
|
||||
assert.notEqual(updated.stops, later.stops);
|
||||
assert.equal(updated.stops[0].state, 'passed');
|
||||
assert.equal(app.layer.getSelectedTrain().stops, updated.stops);
|
||||
});
|
||||
|
||||
test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const { line, s } = app.layer.getSelectedTrain().path;
|
||||
const own = app.entry(T1).path.path;
|
||||
assert.notEqual(line, own, 'not the layer’s own');
|
||||
assert.ok(Object.isFrozen(line));
|
||||
for (const name of ['lat', 'lon', 'x', 'y', 's', 'box'])
|
||||
assert.ok(Object.isFrozen(line[name]), name);
|
||||
const lat = own.lat[1];
|
||||
assert.throws(() => {
|
||||
line.lat[1] = 0;
|
||||
}, TypeError);
|
||||
assert.equal(own.lat[1], lat, 'the layer’s line is as it was');
|
||||
assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same');
|
||||
// Copied once per line: T2 runs on the same one.
|
||||
app.parts().selection.selectTrain(T2);
|
||||
assert.equal(app.layer.getSelectedTrain().path.line, line);
|
||||
});
|
||||
|
||||
test('a held train hides its marker and card and keeps the selection', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.equal(app.state()._selectedKey, T1, 'holding selects it');
|
||||
assert.equal(app.layer.getCockpitSubject().key, T1);
|
||||
app.run(1000);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
assert.equal(app.entry(T2).marker.show, true);
|
||||
// From inside the train its selected line would run under the eye.
|
||||
assert.equal(app.state()._route.primitive.show, false, 'no line');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// Escape is the view's while it holds the train.
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
// It still moves.
|
||||
const before = app.layer.getCockpitSubject().path.s;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
app.run(RAIL_POLL_MS);
|
||||
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
app.run(1000);
|
||||
assert.equal(app.entry(T1).marker.show, true);
|
||||
assert.equal(app.state()._route.primitive.show, true, 'the line is back');
|
||||
assert.equal(
|
||||
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
|
||||
1,
|
||||
'the card is back',
|
||||
);
|
||||
});
|
||||
|
||||
test('a held train draws no 3D model either; let go, its model draws again', async (t) => {
|
||||
const models = await fleetModelFakes(t);
|
||||
const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 });
|
||||
// A model's load answers off the frame; the next frames place it.
|
||||
const frames = async () => {
|
||||
app.run(500);
|
||||
await app.settle();
|
||||
app.run(500);
|
||||
};
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 1, 'near the camera: its model');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, true);
|
||||
assert.equal(app.entry(T1).marker.show, false, 'its icon hands over');
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
app.run(250);
|
||||
// The view's eye is inside the train.
|
||||
assert.equal(models.drawn(T1).length, 0, 'no model');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, false);
|
||||
assert.equal(app.entry(T1).marker.show, false, 'and no icon');
|
||||
assert.equal(models.drawn(T2).length, 1, 'another train keeps its model');
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 0, 'not on a later frame either');
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 1, 'let go: its model again');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, true);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
});
|
||||
|
||||
const MADRID = { lat: 40.4168, lon: -3.7038 };
|
||||
|
||||
test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => {
|
||||
const app = harness(t, { at: MADRID });
|
||||
// One Alvia in two feeds: a station-event copy, held, and a poll later a
|
||||
// GPS copy that would outrank it.
|
||||
let step = 0;
|
||||
let gpsCopy = false;
|
||||
const lat = (offset) => MADRID.lat + offset + step * 0.003;
|
||||
app.feeds.set('renfe-cercanias', () => [
|
||||
train('A1', lat(0), MADRID.lon, {
|
||||
label: 'ALVIA 4073',
|
||||
positionSource: 'event',
|
||||
}),
|
||||
]);
|
||||
app.feeds.set('renfe-larga-distancia', () =>
|
||||
gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [],
|
||||
);
|
||||
await app.enable();
|
||||
const a = railTrainKey('renfe-cercanias', 'A1');
|
||||
const b = railTrainKey('renfe-larga-distancia', 'B1');
|
||||
for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) {
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving');
|
||||
assert.equal(app.layer.setCockpitSubject(a), true);
|
||||
|
||||
gpsCopy = true;
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
// The view knows the train by its key, and the copies stand apart: the
|
||||
// held copy stays drawn and selected, the other is the one hidden.
|
||||
assert.equal(app.layer.getCockpitSubject()?.key, a);
|
||||
assert.equal(app.state()._selectedKey, a);
|
||||
assert.equal(app.entry(a).duplicate, false);
|
||||
assert.equal(app.entry(b).duplicate, true);
|
||||
assert.equal(app.entry(a).marker.show, false);
|
||||
assert.equal(app.entry(b).marker.show, false, 'no other marker on it');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// It moves between polls, frame by frame.
|
||||
assert.ok(app.state()._moving.has(app.entry(a)), 'still playing');
|
||||
const before = app.layer.getCockpitSubject().position.latitude;
|
||||
app.run(2000);
|
||||
assert.ok(
|
||||
app.layer.getCockpitSubject().position.latitude > before,
|
||||
'still moving',
|
||||
);
|
||||
|
||||
// Let go: the better copy takes over at the next poll, with the selection.
|
||||
app.layer.setCockpitSubject(null);
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.equal(app.entry(a).duplicate, true);
|
||||
assert.equal(app.entry(b).duplicate, false);
|
||||
assert.equal(app.state()._selectedKey, b);
|
||||
assert.equal(app.entry(b).marker.show, true);
|
||||
assert.equal(
|
||||
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
|
||||
1,
|
||||
'its card',
|
||||
);
|
||||
// The copy hidden is not focused: the selection stays on the one drawn.
|
||||
assert.equal(app.layer.focusTrain(a), false);
|
||||
assert.equal(app.state()._selectedKey, b);
|
||||
|
||||
// A hold of the copy hidden draws it at once, in place of the other.
|
||||
assert.equal(app.layer.setCockpitSubject(a), true);
|
||||
assert.equal(app.state()._selectedKey, a);
|
||||
assert.equal(app.entry(a).duplicate, false);
|
||||
assert.equal(app.entry(b).duplicate, true);
|
||||
assert.equal(app.entry(b).marker.show, false);
|
||||
assert.ok(app.state()._moving.has(app.entry(a)), 'playing');
|
||||
});
|
||||
|
||||
test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.run(1000);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing');
|
||||
app.layer.setParams({ regional: false });
|
||||
assert.equal(app.state()._selectedKey, T1, 'still selected');
|
||||
assert.equal(app.layer.getCockpitSubject()?.key, T1);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
assert.equal(app.entry(T2).marker.show, false, 'its category is hidden');
|
||||
assert.equal(app.state()._route.primitive.show, false, 'no line');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// It moves between polls, frame by frame.
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing');
|
||||
const before = app.layer.getCockpitSubject().path.s;
|
||||
app.run(2000);
|
||||
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
|
||||
assert.equal(app.entry(T1).marker.show, false, 'and still hidden');
|
||||
|
||||
// Let go: a train of a hidden category, deselected and stopped as the
|
||||
// toggle would have left it.
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.state()._moving.has(app.entry(T1)), false);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
const parked = app.entry(T1).path.drawS;
|
||||
app.run(2000);
|
||||
assert.equal(app.entry(T1).path.drawS, parked, 'stopped');
|
||||
// Held again, hidden, it plays at once; let go, it stops again.
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once');
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.state()._moving.has(app.entry(T1)), false);
|
||||
// Shown again, it is drawn and moves as any other.
|
||||
app.layer.setParams({ regional: true });
|
||||
assert.equal(app.entry(T1).marker.show, true);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again');
|
||||
});
|
||||
|
||||
test('a ride let go with restore does not focus a train its category hides', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
// The documented provider: hold on entry; on exit, release, then focus
|
||||
// the train again when asked to restore.
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
app.layer.setParams({ regional: false });
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.layer.focusTrain(T1), false, 'not drawn');
|
||||
assert.equal(app.state()._selectedKey, null, 'not selected');
|
||||
assert.equal(app.parts().follow.stats(), null, 'not followed');
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.focusRequests.length, 0, 'not flown to');
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
|
||||
// Shown again, it focuses as before.
|
||||
app.layer.setParams({ regional: true });
|
||||
assert.equal(app.layer.focusTrain(T1), true);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it');
|
||||
});
|
||||
|
||||
test('a held train that goes takes the hold with it; focusTrain selects', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.equal(app.entry(T1), undefined, 'the train is gone');
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.equal(app.state()._cockpitSubjectKey, null);
|
||||
assert.equal(app.layer.focusTrain(T1), false);
|
||||
assert.equal(app.layer.focusTrain(T2), true);
|
||||
assert.equal(app.state()._selectedKey, T2);
|
||||
});
|
||||
|
||||
test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
// From the cab the camera looks along the ground, here past the feed's range.
|
||||
await app.moveCamera({ lat: 40.71, lon: -74.0 });
|
||||
assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train');
|
||||
const others = () => [train('T2', LAT, LON0 + 4000 / KX)];
|
||||
app.feeds.set('mbta-rail', others);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
await app.recheck();
|
||||
assert.equal(app.entry(T1), undefined, 'the train is gone');
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled');
|
||||
await app.moveCamera({ lat: 40.72, lon: -74.0 });
|
||||
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera');
|
||||
|
||||
// Listed again: the view holds it again.
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }),
|
||||
...others(),
|
||||
]);
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.equal(app.state()._cockpitLostFeedId, null);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
|
||||
// Let go, and the feed is the viewport's again: out of range, released.
|
||||
app.parts().ingestion.removeTrain(T1);
|
||||
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
|
||||
app.layer.setCockpitSubject(null);
|
||||
await app.moveCamera({ lat: 40.71, lon: -74.0 });
|
||||
assert.equal(app.state()._activeFeeds.has('mbta-rail'), false);
|
||||
assert.equal(app.state()._trains.size, 0);
|
||||
});
|
||||
|
||||
test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.focusTrain(T1), true);
|
||||
const first = app.layer.getSelectedTrain().selectedAtMs;
|
||||
assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it');
|
||||
// Selected again: a later stamp.
|
||||
app.run(1000);
|
||||
app.layer.focusTrain(T1);
|
||||
assert.ok(app.layer.getSelectedTrain().selectedAtMs > first);
|
||||
// A hold of another train is not the user's selection.
|
||||
app.layer.setCockpitSubject(T2);
|
||||
assert.equal(app.layer.getCockpitSubject().key, T2);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
app.layer.setCockpitSubject(null);
|
||||
// Nor after a deselection (Escape), then a hold.
|
||||
app.layer.focusTrain(T1);
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.state()._selectedAtMs, null, 'deselection clears it');
|
||||
app.layer.setCockpitSubject(T2);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
// Releasing twice is harmless: the selection stays.
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.state()._selectedKey, T2);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
});
|
||||
|
||||
test('turning the layer off ends the hold', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.parts().ingestion.removeTrain(T1);
|
||||
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
|
||||
app.layer.disable(app.viewer);
|
||||
assert.equal(app.state()._cockpitSubjectKey, null);
|
||||
assert.equal(app.state()._cockpitLostFeedId, null);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
});
|
||||
Vendored
+7
-1
@@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) {
|
||||
return true;
|
||||
state._categories = next;
|
||||
const selected = state._trains.get(state._selectedKey);
|
||||
if (selected && next[selected.category] === false)
|
||||
// A held train keeps the selection until its view lets it go
|
||||
// (cockpitSubject.js).
|
||||
if (
|
||||
selected &&
|
||||
next[selected.category] === false &&
|
||||
selected.key !== state._cockpitSubjectKey
|
||||
)
|
||||
parts.selection.clearSelection();
|
||||
parts.rendering.applyCategoryVisibility();
|
||||
state._revision += 1;
|
||||
|
||||
@@ -25,9 +25,13 @@ import { greatCircleKm } from '../../geoDistance.js';
|
||||
* 2. the line: a copy bound to its operator's shape (a built pack), then
|
||||
* one without.
|
||||
* The copy drawn stays drawn until another scores strictly higher, so equal
|
||||
* copies never trade places. A losing copy keeps its playback and is only
|
||||
* hidden; when a feed leaves the viewport its copies' partners come back.
|
||||
* The comparison runs once per poll, never per frame.
|
||||
* copies never trade places. A copy a first-person view holds
|
||||
* (cockpitSubject.js) stays drawn whatever the scores until the view lets
|
||||
* go: the view knows the train by that copy's key and rides it where that
|
||||
* copy's feed puts it, tens of kilometres from another feed's copy at
|
||||
* times. A losing copy keeps its playback and is only hidden; when a feed
|
||||
* leaves the viewport its copies' partners come back. The comparison runs
|
||||
* once per poll, never per frame.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -288,10 +292,12 @@ function better(a, b) {
|
||||
* scores strictly higher.
|
||||
* @param {object[][]} groups From matchCopies.
|
||||
* @param {Map<string, string>} [previous] copy key → key drawn last time.
|
||||
* @param {string|null} [held] A copy drawn whatever the scores: the train a
|
||||
* first-person view holds.
|
||||
* @returns {{winners: Map<string, string>, hidden: Set<string>}}
|
||||
* `winners`: every grouped copy's key → the key drawn for its train.
|
||||
*/
|
||||
export function chooseCopies(groups, previous = new Map()) {
|
||||
export function chooseCopies(groups, previous = new Map(), held = null) {
|
||||
const winners = new Map();
|
||||
const hidden = new Set();
|
||||
for (const group of groups) {
|
||||
@@ -305,6 +311,7 @@ export function chooseCopies(groups, previous = new Map()) {
|
||||
scored.reduce((a, b) => (better(b.score, a.score) ? b : a));
|
||||
for (const candidate of scored)
|
||||
if (better(candidate.score, best.score)) best = candidate;
|
||||
best = scored.find(({ copy }) => copy.key === held) ?? best;
|
||||
for (const { copy } of scored) {
|
||||
winners.set(copy.key, best.copy.key);
|
||||
if (copy.key !== best.copy.key) hidden.add(copy.key);
|
||||
@@ -318,7 +325,8 @@ export function chooseCopies(groups, previous = new Map()) {
|
||||
* loaded feed carries, marks the copies not drawn (`entry.duplicate`), and
|
||||
* moves a selection that sat on a copy now hidden to the copy drawn, so the
|
||||
* card follows the same physical train (its feed line names the new
|
||||
* source).
|
||||
* source). A held copy is never the one hidden, so the hold and the
|
||||
* selection stay on it.
|
||||
* @param {{state: object, parts: object,
|
||||
* defer?: (fn: Function) => unknown}} context
|
||||
*/
|
||||
@@ -355,6 +363,7 @@ export function createDuplicateFilter({
|
||||
const { winners, hidden } = chooseCopies(
|
||||
matchCopies(entries.map(copyOf)),
|
||||
previous,
|
||||
state._cockpitSubjectKey ?? null,
|
||||
);
|
||||
previous = winners;
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -252,6 +252,32 @@ test('the filter hides the weaker copy and the selection follows its train', ()
|
||||
assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 });
|
||||
});
|
||||
|
||||
test('a copy a first-person view holds stays drawn, with the selection', () => {
|
||||
const pair = [
|
||||
copy('a', 'f1', { number: '160', position: 'event' }),
|
||||
copy('b', 'f2', { number: '160', position: 'gps' }),
|
||||
];
|
||||
assert.equal(chooseCopies([pair]).winners.get('a'), 'b');
|
||||
const held = chooseCopies([pair], new Map(), 'a');
|
||||
assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores');
|
||||
assert.deepEqual([...held.hidden], ['b']);
|
||||
// Let go, the better copy takes over.
|
||||
assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b');
|
||||
|
||||
const layer = fakeLayer({ selected: 'feed-a:v160' });
|
||||
layer.state._cockpitSubjectKey = 'feed-a:v160';
|
||||
const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() });
|
||||
filter.refresh();
|
||||
const [unbound, operator] = layer.trains;
|
||||
assert.equal(unbound.duplicate, false, 'the held copy');
|
||||
assert.equal(operator.duplicate, true);
|
||||
assert.deepEqual(layer.calls.selected, [], 'the selection stays on it');
|
||||
layer.state._cockpitSubjectKey = null;
|
||||
filter.refresh();
|
||||
assert.equal(unbound.duplicate, true);
|
||||
assert.deepEqual(layer.calls.selected, [operator.key]);
|
||||
});
|
||||
|
||||
test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => {
|
||||
for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5'])
|
||||
assert.equal(recordTrainNumber({ label }), null, label);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createRailCockpitSubject } from './cockpitSubject.js';
|
||||
import { createRouteDisplay } from './routeDisplay.js';
|
||||
import { createRailSchedules } from './schedules.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
@@ -78,12 +79,24 @@ function createState({ services }) {
|
||||
_stopAnchors: new Map(),
|
||||
_categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY },
|
||||
_selectedKey: null,
|
||||
/** When the user last selected a train, even the selected one (ms). */
|
||||
_selectedAtMs: null,
|
||||
_selectedCardAt: 0,
|
||||
_selectedCardText: null,
|
||||
/** The camera's follow of the selected train (follow.js), or null. */
|
||||
_follow: null,
|
||||
/** A feed polled only for the selected train (viewport.js), or null. */
|
||||
_heldFeedId: null,
|
||||
/**
|
||||
* The train a first-person view holds (cockpitSubject.js), or null: its
|
||||
* marker, 3D model and card are hidden and the selection stays on it.
|
||||
*/
|
||||
_cockpitSubjectKey: null,
|
||||
/**
|
||||
* The feed of a held train the feed dropped (ingestion.js), polled until
|
||||
* the view takes a train again or lets go (cockpitSubject.js), or null.
|
||||
*/
|
||||
_cockpitLostFeedId: null,
|
||||
_revision: 0,
|
||||
_controlsListener: null,
|
||||
_dataManager: null,
|
||||
@@ -175,11 +188,13 @@ export function createRailLayer({
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.cockpit = createRailCockpitSubject(context);
|
||||
|
||||
return Object.assign(
|
||||
{ updateInterval: RAIL_POLL_MS },
|
||||
parts.lifecycle.methods,
|
||||
parts.controls.methods,
|
||||
parts.cockpit.methods,
|
||||
{
|
||||
/** Test seams: this instance's own state and parts. */
|
||||
_railStateForTest: () => state,
|
||||
|
||||
@@ -69,6 +69,13 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
function removeTrain(key) {
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry) return;
|
||||
// A first-person view on it loses its subject (cockpitSubject.js); its
|
||||
// feed stays polled until the view lets go, so the train is found again
|
||||
// if the feed lists it again.
|
||||
if (state._cockpitSubjectKey === key) {
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = entry.feedId;
|
||||
}
|
||||
if (state._selectedKey === key) parts.selection.clearSelection();
|
||||
if (entry.marker) state._markers?.remove(entry.marker);
|
||||
entry.marker = null;
|
||||
|
||||
@@ -126,6 +126,8 @@ export function createLifecycle({ state, services, parts, timetable }) {
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
state._heldFeedId = null;
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = null;
|
||||
state._feedStatus.clear();
|
||||
dropAllTrains();
|
||||
parts.rendering.stopModels();
|
||||
|
||||
@@ -47,11 +47,31 @@ export function pathWindowM(dtMs) {
|
||||
return window > PATH_WINDOW_MAX_M ? NaN : window;
|
||||
}
|
||||
|
||||
/** Arc moved between two reports before it says which way a train runs (m). */
|
||||
const DIRECTION_MIN_M = 1;
|
||||
|
||||
/** +1 or -1 for an arc step longer than DIRECTION_MIN_M, else `known`. */
|
||||
function directionOf(step, known) {
|
||||
return Number.isFinite(step) && Math.abs(step) > DIRECTION_MIN_M
|
||||
? Math.sign(step)
|
||||
: known;
|
||||
}
|
||||
|
||||
function createBinding(shapeIndex, path, via, s, fix) {
|
||||
return {
|
||||
shapeIndex,
|
||||
path,
|
||||
via,
|
||||
/**
|
||||
* Which way the drawn train runs along the shape: +1 along it, -1
|
||||
* against it, 0 until two reports or a playback segment tell. Kept until
|
||||
* a rebind. Once playback has placed the train, only playback changes
|
||||
* it: the newest reports run ahead of the drawn train, so a reversal
|
||||
* they show turns it when playback reaches it.
|
||||
*/
|
||||
direction: 0,
|
||||
/** The direction the last placed playback segment ran, 0 before one. */
|
||||
drawDirection: 0,
|
||||
/** Arc and time of the newest report. */
|
||||
fixS: s,
|
||||
fixT: fix.t,
|
||||
@@ -139,6 +159,8 @@ export function followFix(binding, fix) {
|
||||
pathWindowM(fix.t - binding.fixT),
|
||||
);
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
|
||||
if (!binding.drawDirection)
|
||||
binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
|
||||
binding.fixS = hit.s;
|
||||
binding.fixT = fix.t;
|
||||
binding.fixLat = fix.lat;
|
||||
@@ -230,6 +252,13 @@ export function placeOnPath(binding, sample, segment, out) {
|
||||
cache.sFrom = sFrom;
|
||||
cache.sTo = sTo;
|
||||
cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo);
|
||||
if (cache.ok) {
|
||||
binding.drawDirection = directionOf(
|
||||
sTo - sFrom,
|
||||
binding.drawDirection || binding.direction || 0,
|
||||
);
|
||||
binding.direction = binding.drawDirection;
|
||||
}
|
||||
}
|
||||
if (!cache.ok) return false;
|
||||
const f = Number.isFinite(sample.fraction)
|
||||
|
||||
@@ -201,3 +201,64 @@ test('a segment whose reports do not fit the line keeps the straight line', () =
|
||||
assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false);
|
||||
assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false);
|
||||
});
|
||||
|
||||
test('a binding learns which way its train runs, and keeps it through a stop', () => {
|
||||
const p = pack();
|
||||
const ids = { tripId: 'full', routeId: 'main' };
|
||||
const along = bindTrainPath(p, ids, fix(0, 500, 0));
|
||||
assert.equal(along.direction, 0, 'one report tells no direction');
|
||||
assert.ok(followFix(along, fix(10_000, 700, 0)));
|
||||
assert.equal(along.direction, 1);
|
||||
// Standing still (under a metre) keeps what it learnt.
|
||||
assert.ok(followFix(along, fix(20_000, 700.4, 0)));
|
||||
assert.equal(along.direction, 1);
|
||||
const against = bindTrainPath(p, ids, fix(0, 1500, 0));
|
||||
assert.ok(followFix(against, fix(10_000, 1200, 0)));
|
||||
assert.equal(against.direction, -1);
|
||||
// A playback segment between two reports tells it too.
|
||||
const played = bindTrainPath(p, ids, fix(0, 300, 0));
|
||||
const segment = {
|
||||
from: { seq: 1, ...fix(0, 300, 0) },
|
||||
to: { seq: 2, ...fix(10_000, 100, 0) },
|
||||
};
|
||||
assert.ok(
|
||||
placeOnPath(played, { fromSeq: 1, toSeq: 2, fraction: 0.5 }, segment, {}),
|
||||
);
|
||||
assert.equal(played.direction, -1);
|
||||
// A rebind starts again.
|
||||
const rebound = rebindTrainPath(
|
||||
p,
|
||||
{ routeId: 'main' },
|
||||
along,
|
||||
fix(30_000, 2000, -300),
|
||||
);
|
||||
assert.equal(rebound?.direction ?? 0, 0);
|
||||
});
|
||||
|
||||
test('a report that runs back turns the drawn train only when playback does', () => {
|
||||
const p = pack();
|
||||
const ids = { tripId: 'full', routeId: 'main' };
|
||||
const binding = bindTrainPath(p, ids, fix(0, 300, 0));
|
||||
const forward = {
|
||||
from: { seq: 1, ...fix(0, 300, 0) },
|
||||
to: { seq: 2, ...fix(10_000, 500, 0) },
|
||||
};
|
||||
const sample = (fraction) => ({ fromSeq: 1, toSeq: 2, fraction });
|
||||
assert.ok(placeOnPath(binding, sample(0.2), forward, {}));
|
||||
assert.equal(binding.direction, 1);
|
||||
// The newest report lies behind the last one (a reversal, or GPS jitter)
|
||||
// while playback still draws the forward segment.
|
||||
assert.ok(followFix(binding, fix(10_000, 500, 0)));
|
||||
assert.ok(followFix(binding, fix(20_000, 450, 0)));
|
||||
assert.ok(placeOnPath(binding, sample(0.6), forward, {}));
|
||||
assert.equal(binding.direction, 1, 'the drawn train still runs forward');
|
||||
// Playback reaches the reversed segment: now it turns.
|
||||
const back = {
|
||||
from: { seq: 2, ...fix(10_000, 500, 0) },
|
||||
to: { seq: 3, ...fix(20_000, 450, 0) },
|
||||
};
|
||||
assert.ok(
|
||||
placeOnPath(binding, { fromSeq: 2, toSeq: 3, fraction: 0.5 }, back, {}),
|
||||
);
|
||||
assert.equal(binding.direction, -1);
|
||||
});
|
||||
@@ -523,9 +523,25 @@ export function createRendering({ state, services, parts }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a train's icon is drawn: shown, and not drawn by its model. */
|
||||
/**
|
||||
* Whether a train's icon is drawn: shown, not drawn by its model, and no
|
||||
* first-person view rides it (cockpitSubject.js). A held train keeps its
|
||||
* playback; only its icon and model hide.
|
||||
*/
|
||||
function markerShown(entry) {
|
||||
return categoryShown(entry) && !entry.modelOwnsVisual;
|
||||
return (
|
||||
categoryShown(entry) &&
|
||||
!entry.modelOwnsVisual &&
|
||||
entry.key !== state._cockpitSubjectKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the train's playback runs: it is drawn, or a first-person view
|
||||
* rides it, which keeps it moving, hidden, in a category turned off.
|
||||
*/
|
||||
function playing(entry) {
|
||||
return categoryShown(entry) || entry.key === state._cockpitSubjectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,7 +630,7 @@ export function createRendering({ state, services, parts }) {
|
||||
place(entry);
|
||||
const sample = entry.sample;
|
||||
const moving =
|
||||
categoryShown(entry) &&
|
||||
playing(entry) &&
|
||||
sample?.phase === 'playing' &&
|
||||
sample.segmentSpeedMps > 0;
|
||||
if (moving) {
|
||||
@@ -622,7 +638,7 @@ export function createRendering({ state, services, parts }) {
|
||||
return;
|
||||
}
|
||||
state._moving.delete(entry);
|
||||
if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) {
|
||||
if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) {
|
||||
entry.wakeTimer = setTimeout(
|
||||
() => {
|
||||
entry.wakeTimer = null;
|
||||
@@ -744,7 +760,9 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
item.position = entry.marker.position;
|
||||
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
|
||||
item.show = categoryShown(entry);
|
||||
// A train a first-person view rides draws no model: the camera is inside
|
||||
// it (cockpitSubject.js).
|
||||
item.show = categoryShown(entry) && entry.key !== state._cockpitSubjectKey;
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
@@ -427,10 +427,29 @@ export function createRouteDisplay({
|
||||
updateSplit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the selected line unless a first-person view rides its train
|
||||
* (cockpitSubject.js): from inside the train it would run up the middle of
|
||||
* the view and under the eye.
|
||||
*/
|
||||
function applyHold() {
|
||||
const route = state._route;
|
||||
if (!route?.primitive) return;
|
||||
const shown = !(
|
||||
state._cockpitSubjectKey !== null &&
|
||||
state._cockpitSubjectKey === state._selectedKey
|
||||
);
|
||||
if (route.primitive.show !== shown) {
|
||||
route.primitive.show = shown;
|
||||
governorRequestRender('rail-route');
|
||||
}
|
||||
}
|
||||
|
||||
/** Move the travelled/ahead split to where the train is drawn now. */
|
||||
function updateSplit() {
|
||||
const route = state._route;
|
||||
if (!route?.primitive) return;
|
||||
applyHold();
|
||||
const binding = route.binding;
|
||||
const s = binding ? binding.drawS : NaN;
|
||||
if (route.direction === 0 && Number.isFinite(s)) {
|
||||
@@ -777,6 +796,7 @@ export function createRouteDisplay({
|
||||
applyNetwork,
|
||||
onCamera,
|
||||
syncRoute,
|
||||
applyHold,
|
||||
refreshStops,
|
||||
cullStops,
|
||||
onSelectionChanged,
|
||||
|
||||
@@ -35,6 +35,16 @@ export function createSelection({ state, services, parts }) {
|
||||
function refreshSelectedCard(force) {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
if (!entry) return;
|
||||
// A train a first-person view rides shows no card.
|
||||
if (entry.key === state._cockpitSubjectKey) {
|
||||
state._selectedCardText = null;
|
||||
state._overlayHost.setEntries(
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS)
|
||||
return;
|
||||
@@ -103,6 +113,8 @@ export function createSelection({ state, services, parts }) {
|
||||
*/
|
||||
function selectAndFocusTrain(key) {
|
||||
if (!state._trains.has(key)) return;
|
||||
// A selection by the user, even of the selected train (cockpitSubject.js).
|
||||
state._selectedAtMs = Date.now();
|
||||
const following = parts.follow.isFollowing(key);
|
||||
// Another train's follow lets go first, so this is never taken for a
|
||||
// move between two copies of one train.
|
||||
@@ -120,6 +132,7 @@ export function createSelection({ state, services, parts }) {
|
||||
const hadSelection = state._selectedKey !== null;
|
||||
parts.follow.release({ untrack });
|
||||
state._selectedKey = null;
|
||||
state._selectedAtMs = null;
|
||||
state._selectedCardText = null;
|
||||
if (hadSelection) {
|
||||
parts.rendering.paintAll();
|
||||
@@ -136,6 +149,8 @@ export function createSelection({ state, services, parts }) {
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
// While a first-person view rides the train, Escape is the view's.
|
||||
if (state._cockpitSubjectKey) return;
|
||||
if (event.key === 'Escape' && state._selectedKey) clearSelection();
|
||||
}
|
||||
|
||||
@@ -146,6 +161,8 @@ export function createSelection({ state, services, parts }) {
|
||||
);
|
||||
state._clickHandler.setInputAction((click) => {
|
||||
if (!state._enabled || !isPointerFree()) return;
|
||||
// A held train keeps the selection until its view lets it go.
|
||||
if (state._cockpitSubjectKey) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = typeof picked?.id === 'string' ? picked.id : null;
|
||||
if (id && state._trains.has(id)) selectAndFocusTrain(id);
|
||||
|
||||
@@ -148,6 +148,12 @@ export function createViewport({ state, services, parts, source }) {
|
||||
))
|
||||
if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed);
|
||||
}
|
||||
// A first-person view whose train its feed dropped keeps the feed polled
|
||||
// until it lets go (cockpitSubject.js): from the cab the camera looks
|
||||
// along the ground, often at a point beyond the feed's range.
|
||||
const lost = state._cockpitLostFeedId;
|
||||
if (lost && !desired.has(lost) && state._activeFeeds.has(lost))
|
||||
desired.set(lost, state._activeFeeds.get(lost));
|
||||
// The selected train's feed stays polled however far out or away the
|
||||
// camera goes, so the train and its line stay on the map; while nothing
|
||||
// else would keep the feed, it carries the selected train alone and its
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
getRegisteredTransitFeed,
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion } from './movement.js';
|
||||
|
||||
/**
|
||||
* `previous` when it holds each of `next`'s values, else a frozen copy of
|
||||
* `next`: a part of a snapshot that did not change is the one read before.
|
||||
* `next` is a draft, filled again on every read.
|
||||
*/
|
||||
function reuse(previous, next) {
|
||||
if (!previous) return Object.freeze({ ...next });
|
||||
for (const name in next)
|
||||
if (!Object.is(previous[name], next[name]))
|
||||
return Object.freeze({ ...next });
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read access to one vehicle, and a hold on it for a first-person view.
|
||||
*
|
||||
* `getSelectedVehicle()` and `getCockpitSubject()` describe the selected
|
||||
* vehicle and the held one as a frozen snapshot, read again on each call:
|
||||
* where it is drawn, how fast it moves and how that is known. A call while
|
||||
* nothing in it changed returns the snapshot it returned before, and a part
|
||||
* that did not change (its feed, its stop) is the same object in the next
|
||||
* one, so a view can read it on every frame. Transit has no route shapes or
|
||||
* stops, so a snapshot names only the vehicle's last reported stop.
|
||||
* `setCockpitSubject(key)` holds a vehicle: its sprite, 3D model, card,
|
||||
* trail and detection box are hidden, it keeps moving when it is out of the camera's
|
||||
* view, its feed stays polled, and clicks and Escape no longer change the
|
||||
* selection. The hold ends with `setCockpitSubject(null)`, and on
|
||||
* its own when the vehicle is removed or the layer is turned off.
|
||||
* `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's
|
||||
* `selectedAtMs` is when the user last selected the selected vehicle (a
|
||||
* click, or `focusVehicle`), even again. It is null for any other vehicle,
|
||||
* after a deselection, and for a vehicle selected only by a hold: a hold is
|
||||
* not a user's selection.
|
||||
*
|
||||
* @param {object} context The layer's state, parts and services.
|
||||
* @returns {{methods: object}}
|
||||
*/
|
||||
export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M)
|
||||
? services.ground.GROUND_FLOOR_LIFT_M
|
||||
: 0;
|
||||
const finiteOrNull = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
/** Each vehicle's last snapshot, returned again while nothing changed. */
|
||||
const views = new WeakMap();
|
||||
/** The drafts vehicleView fills on every read (reuse). */
|
||||
const draft = {
|
||||
view: {},
|
||||
feed: {},
|
||||
position: {},
|
||||
motion: {},
|
||||
stop: {},
|
||||
provenance: {},
|
||||
};
|
||||
|
||||
/** The feed in use, else the registry's entry for it. */
|
||||
function feedEntry(feedId) {
|
||||
return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
|
||||
}
|
||||
|
||||
function feedOf(feed, previous) {
|
||||
if (!feed) return null;
|
||||
const next = draft.feed;
|
||||
next.id = feed.id;
|
||||
next.name = feed.name ?? null;
|
||||
next.operator = feed.operator ?? null;
|
||||
next.region = feed.region ?? null;
|
||||
next.attribution = feed.attribution ?? null;
|
||||
next.license = feed.license ?? null;
|
||||
next.licenseUrl = feed.licenseUrl ?? null;
|
||||
return reuse(previous, next);
|
||||
}
|
||||
|
||||
const scratchCartographic = new Cesium.Cartographic();
|
||||
/**
|
||||
* The floor under the vehicle where playback draws it, without the shared
|
||||
* lift: the drawn marker's height while the frame pass places it (in view,
|
||||
* or held), which near the ground follows the terrain between reports;
|
||||
* else its segment's two reports' floors at the playback fraction. Null
|
||||
* while either floor is unknown, as on cold terrain, so a view waits for
|
||||
* the surface. Not `entry.heightM`, the floor at the newest report, which
|
||||
* playback has not reached yet.
|
||||
*/
|
||||
function drawnFloorOf(entry) {
|
||||
const segment = entry.segment;
|
||||
if (!segment || entry.heightPending) return null;
|
||||
const placed =
|
||||
entry.surfaceReady === true &&
|
||||
entry.hasRendered &&
|
||||
entry.marker?.position &&
|
||||
(state._visible.has(entry) || entry.key === state._cockpitSubjectKey);
|
||||
if (placed) {
|
||||
const drawn = Cesium.Cartographic.fromCartesian(
|
||||
entry.marker.position,
|
||||
undefined,
|
||||
scratchCartographic,
|
||||
);
|
||||
if (drawn) return drawn.height - liftM;
|
||||
}
|
||||
const from = segment.from?.h;
|
||||
const to = segment.to?.h;
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
|
||||
const f = Number.isFinite(segment.fraction)
|
||||
? Math.max(0, Math.min(1, segment.fraction))
|
||||
: 1;
|
||||
return from + (to - from) * f - liftM;
|
||||
}
|
||||
|
||||
/** The vehicle as a frozen snapshot (see the module comment), or null. */
|
||||
function vehicleView(entry) {
|
||||
if (!entry?.record) return null;
|
||||
const previous = views.get(entry);
|
||||
const record = entry.record;
|
||||
const sample = entry.sample ?? null;
|
||||
const feed = feedEntry(entry.feedId);
|
||||
const reported = finiteOrNull(record.speedMps);
|
||||
const derived = finiteOrNull(sample?.segmentSpeedMps);
|
||||
const status = state._feedStatus.get(entry.feedId);
|
||||
|
||||
const position = draft.position;
|
||||
position.latitude = finiteOrNull(sample?.lat) ?? record.lat;
|
||||
position.longitude = finiteOrNull(sample?.lon) ?? record.lon;
|
||||
position.groundHeightM = drawnFloorOf(entry);
|
||||
position.atMs = finiteOrNull(sample?.displayT);
|
||||
|
||||
const motion = draft.motion;
|
||||
motion.reportedSpeedMps = reported;
|
||||
motion.derivedSpeedMps = derived;
|
||||
motion.speedSource =
|
||||
reported !== null ? 'reported' : derived !== null ? 'derived' : null;
|
||||
motion.courseDeg = finiteOrNull(entry.courseDeg);
|
||||
motion.reportedBearingDeg = finiteOrNull(record.bearing);
|
||||
motion.segmentCourseDeg = finiteOrNull(sample?.segmentCourseDeg);
|
||||
motion.phase = sample?.phase ?? null;
|
||||
motion.word = displayMotion(entry, Date.now()).word;
|
||||
motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs);
|
||||
motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs);
|
||||
motion.feedStale = status?.stale === true;
|
||||
|
||||
const stop = draft.stop;
|
||||
stop.stopId = record.stopId || null;
|
||||
stop.status = record.status || null;
|
||||
|
||||
const provenance = draft.provenance;
|
||||
provenance.positionSource = 'gps';
|
||||
provenance.timestampSource = record.timestampSource ?? null;
|
||||
|
||||
const view = draft.view;
|
||||
view.key = entry.key;
|
||||
view.feedId = entry.feedId;
|
||||
view.selectedAtMs =
|
||||
state._selectedKey === entry.key ? state._selectedAtMs : null;
|
||||
view.id = String(record.id ?? '');
|
||||
view.routeId = record.routeId || null;
|
||||
view.routeLabel =
|
||||
(feed && record.routeId
|
||||
? transitRouteLabel(feed, record.routeId)
|
||||
: null) || null;
|
||||
view.tripId = record.tripId || null;
|
||||
view.directionId = record.directionId ?? null;
|
||||
view.label = record.label || null;
|
||||
view.mode = entry.mode ?? null;
|
||||
view.modeInferred = entry.modeInferred === true;
|
||||
view.feed = feedOf(feed, previous?.feed);
|
||||
view.position = reuse(previous?.position, position);
|
||||
view.motion = reuse(previous?.motion, motion);
|
||||
view.stop = reuse(previous?.stop, stop);
|
||||
view.provenance = reuse(previous?.provenance, provenance);
|
||||
const snapshot = reuse(previous, view);
|
||||
views.set(entry, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Show or hide a vehicle's sprite and trail for the hold. */
|
||||
function repaint(entry) {
|
||||
if (!entry?.marker) return;
|
||||
if (entry.key === state._cockpitSubjectKey) entry.marker.show = false;
|
||||
else parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.trails.update();
|
||||
state._detectRevision += 1;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** The selected vehicle (see createTransitCockpitSubject), or null. */
|
||||
getSelectedVehicle() {
|
||||
return vehicleView(state._vehicles.get(state._selectedKey));
|
||||
},
|
||||
/** The held vehicle, or null when none is held. */
|
||||
getCockpitSubject() {
|
||||
return vehicleView(state._vehicles.get(state._cockpitSubjectKey));
|
||||
},
|
||||
/**
|
||||
* Hold the vehicle `key` for a first-person view, or release the hold
|
||||
* with null. A vehicle it holds is selected, if it was not already.
|
||||
* @param {string|null} key
|
||||
* @returns {boolean} False for a vehicle the layer does not show.
|
||||
*/
|
||||
setCockpitSubject(key) {
|
||||
const previous = state._vehicles.get(state._cockpitSubjectKey);
|
||||
if (key === null || key === undefined) {
|
||||
state._cockpitSubjectKey = null;
|
||||
// The hold kept it in play out of view: it leaves until the next
|
||||
// visibility sweep finds it in view again.
|
||||
if (previous) {
|
||||
state._visible.delete(previous);
|
||||
parts.rendering.requestVisibility();
|
||||
}
|
||||
repaint(previous);
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
return true;
|
||||
}
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry?.marker) return false;
|
||||
if (state._selectedKey !== key) parts.selection.selectVehicle(key);
|
||||
state._cockpitSubjectKey = key;
|
||||
state._visible.add(entry);
|
||||
if (previous && previous !== entry) repaint(previous);
|
||||
repaint(entry);
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Select the vehicle `key`, as a click on it does.
|
||||
* @param {string} key
|
||||
* @returns {boolean} False for a vehicle the layer does not show.
|
||||
*/
|
||||
focusVehicle(key) {
|
||||
if (!state._vehicles.get(key)?.marker) return false;
|
||||
parts.selection.selectVehicle(key, { byUser: true });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { methods, vehicleView };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createQueries } from './queries.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createTransitCockpitSubject } from './cockpitSubject.js';
|
||||
import { TRANSIT_POLL_MS } from './policy.js';
|
||||
|
||||
/**
|
||||
@@ -54,12 +55,14 @@ export function createTransitLayer({
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.cockpit = createTransitCockpitSubject(context);
|
||||
|
||||
return Object.assign(
|
||||
{ updateInterval: TRANSIT_POLL_MS },
|
||||
parts.lifecycle.methods,
|
||||
parts.queries.methods,
|
||||
parts.controls.methods,
|
||||
parts.cockpit.methods,
|
||||
parts.testing,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
function removeVehicle(key) {
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry) return;
|
||||
// A first-person view on it loses its subject (cockpitSubject.js).
|
||||
if (state._cockpitSubjectKey === key) state._cockpitSubjectKey = null;
|
||||
if (state._selectedKey === key) parts.selection.clearSelection();
|
||||
if (entry.marker) entry.markerCollection?.remove(entry.marker);
|
||||
entry.marker = null;
|
||||
|
||||
@@ -151,6 +151,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
state._vehicles.clear();
|
||||
parts.height.clear();
|
||||
state._selectedKey = null;
|
||||
state._cockpitSubjectKey = null;
|
||||
state._lastUpdate = null;
|
||||
state._error = null;
|
||||
state._limitWarned = false;
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
FEED_STALE_AFTER_MS,
|
||||
MISSED_POLLS_TO_DROP,
|
||||
TRANSIT_POLL_MS,
|
||||
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
|
||||
VEHICLE_MAX_FIX_AGE_MS,
|
||||
} from './policy.js';
|
||||
import { FLOOR_WARM_PER_POLL } from './policy.js';
|
||||
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
||||
import {
|
||||
FUTURE_STAMP_TOLERANCE_MS,
|
||||
ORDER_REJECTS_BEFORE_RESET,
|
||||
@@ -65,6 +67,25 @@ function snapshot(
|
||||
};
|
||||
}
|
||||
|
||||
/** A camera's place and frame looking straight down from `altitude` over `at`. */
|
||||
function lookingDown(at, altitude) {
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude);
|
||||
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return {
|
||||
positionWC,
|
||||
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
|
||||
upWC: Cesium.Cartesian3.UNIT_Z,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A vehicle record as the proxy emits it, with its timestamp source resolved. */
|
||||
function vehicle(id, lat, lon, atSeconds, extra = {}) {
|
||||
return {
|
||||
@@ -159,6 +180,9 @@ function harness(
|
||||
east: at.lon + 0.1,
|
||||
north: at.lat + 0.1,
|
||||
},
|
||||
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
|
||||
// looking straight down for them; without them vehicles stay icons.
|
||||
fleet = null,
|
||||
} = {},
|
||||
) {
|
||||
const viewRect = { ...view };
|
||||
@@ -219,6 +243,7 @@ function harness(
|
||||
viewRect.north,
|
||||
);
|
||||
},
|
||||
...(fleet ? lookingDown(at, altitude) : {}),
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
@@ -265,6 +290,7 @@ function harness(
|
||||
const layer = createTransitLayer({
|
||||
source,
|
||||
services: {
|
||||
...(fleet ? { fleet } : {}),
|
||||
ground,
|
||||
mesh: {
|
||||
sampleMeshFloorCells(scene, points, options) {
|
||||
@@ -727,6 +753,27 @@ test('disable aborts in-flight work and a response that lands late is ignored',
|
||||
assert.equal(app.vehicles().length, 1);
|
||||
});
|
||||
|
||||
test('a held vehicle with no floor yet tells its view no height', async (t) => {
|
||||
// Not the ellipsoid: a first-person view waits for the surface instead.
|
||||
const app = harness(t, { floorAt: () => undefined });
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot(
|
||||
'mbta',
|
||||
'MBTA',
|
||||
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
|
||||
{ fetchedAt: Date.now() },
|
||||
),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
const entry = app.vehicles()[0];
|
||||
assert.equal(entry.heightPending, true, 'it has nothing to stand on');
|
||||
assert.ok(app.layer.setCockpitSubject(entry.key) !== false);
|
||||
assert.equal(app.layer.getCockpitSubject().position.groundHeightM, null);
|
||||
});
|
||||
|
||||
test('a vehicle waits for its floor rather than standing on the ellipsoid', async (t) => {
|
||||
// The shared ground-floor system answers from the rendered mesh cell or the
|
||||
// real DEM cell; until one of them is warm there is no honest height to draw
|
||||
@@ -1774,11 +1821,19 @@ test('a segment climbs from the ground it left to the ground it reaches', async
|
||||
assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill');
|
||||
const height = () =>
|
||||
Cesium.Cartographic.fromCartesian(entry.marker.position).height;
|
||||
// A first-person view reads the floor where the vehicle is drawn, not the
|
||||
// newest report's floor uphill.
|
||||
app.layer.setCockpitSubject(entry.key);
|
||||
const floor = () => app.layer.getCockpitSubject().position.groundHeightM;
|
||||
app.run(400);
|
||||
assert.ok(
|
||||
height() < 30,
|
||||
`still on the ground it is drawn on, got ${height().toFixed(1)} m`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(floor() + 1.5 - height()) < 1,
|
||||
`its snapshot stands where it is drawn, got ${floor().toFixed(1)} m`,
|
||||
);
|
||||
|
||||
// Halfway along the segment it is halfway up; at the end, on the high ground.
|
||||
const firstFixAt = start - AGE_MS;
|
||||
@@ -1792,6 +1847,7 @@ test('a segment climbs from the ground it left to the ground it reaches', async
|
||||
Math.abs(height() - 101.5) < 1.5,
|
||||
`and arrives on the high ground, got ${height().toFixed(1)} m`,
|
||||
);
|
||||
assert.ok(Math.abs(floor() - 100) < 1.5, 'and so does its snapshot');
|
||||
});
|
||||
test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => {
|
||||
const app = harness(t, { floorAt: () => 5 });
|
||||
@@ -4142,3 +4198,209 @@ test('the prompt names the altitude the gate opens at, not 3,000 km', async (t)
|
||||
'Fly below 2,850 km to a covered region',
|
||||
);
|
||||
});
|
||||
|
||||
/** A bus running north a step a poll, polled `polls` times; its key. */
|
||||
async function busRunningNorth(app, polls = 4, extra = {}) {
|
||||
let step = 0;
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot(
|
||||
'mbta',
|
||||
'MBTA',
|
||||
[
|
||||
vehicle('bus-1', 42.36 + step * 0.001, -71.06, reported(), {
|
||||
stopId: 'place-1',
|
||||
status: 'IN_TRANSIT_TO',
|
||||
...extra,
|
||||
}),
|
||||
],
|
||||
{ fetchedAt: Date.now() },
|
||||
),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
for (let i = 0; i < polls; i += 1) {
|
||||
step += 1;
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
}
|
||||
return 'mbta:bus-1';
|
||||
}
|
||||
|
||||
test('the selected vehicle reads as a snapshot of its place, motion and source', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await busRunningNorth(app, 4, { speedMps: 9 });
|
||||
assert.equal(app.layer.getSelectedVehicle(), null);
|
||||
app.layer._transitPartsForTest().selection.selectVehicle(key);
|
||||
const view = app.layer.getSelectedVehicle();
|
||||
assert.ok(Object.isFrozen(view));
|
||||
assert.equal(view.key, key);
|
||||
assert.equal(view.feedId, 'mbta');
|
||||
assert.equal(view.id, 'bus-1');
|
||||
assert.equal(view.routeId, '1');
|
||||
assert.equal(typeof view.mode, 'string');
|
||||
assert.ok(view.feed.attribution, 'the feed names its credit');
|
||||
assert.equal(view.motion.reportedSpeedMps, 9);
|
||||
assert.equal(view.motion.speedSource, 'reported');
|
||||
assert.equal(view.provenance.positionSource, 'gps');
|
||||
assert.deepEqual(view.stop, { stopId: 'place-1', status: 'IN_TRANSIT_TO' });
|
||||
const entry = app.vehicles()[0];
|
||||
// Where it is drawn, without the shared lift (read back from the marker).
|
||||
assert.ok(
|
||||
Math.abs(view.position.groundHeightM - (entry.heightM - 1.5)) < 1e-3,
|
||||
);
|
||||
assert.ok(Number.isFinite(view.position.latitude));
|
||||
});
|
||||
|
||||
test('a vehicle read again while nothing changed is the snapshot read before', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await busRunningNorth(app);
|
||||
app.layer._transitPartsForTest().selection.selectVehicle(key);
|
||||
const first = app.layer.getSelectedVehicle();
|
||||
assert.equal(app.layer.getSelectedVehicle(), first, 'the same snapshot');
|
||||
// It moves on: a new snapshot at its new place, what did not change kept.
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
const moved = app.layer.getSelectedVehicle();
|
||||
assert.notEqual(moved, first, 'a new snapshot');
|
||||
assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.position));
|
||||
assert.ok(moved.position.latitude > first.position.latitude, 'moved');
|
||||
assert.equal(moved.feed, first.feed);
|
||||
assert.equal(moved.stop, first.stop);
|
||||
assert.equal(moved.provenance, first.provenance);
|
||||
assert.equal(app.layer.getSelectedVehicle(), moved);
|
||||
});
|
||||
|
||||
test('a held vehicle draws no 3D model either; let go, its model draws again', async (t) => {
|
||||
const models = await fleetModelFakes(t);
|
||||
const app = harness(t, { fleet: models.fleet, altitude: 1_000 });
|
||||
const key = await busRunningNorth(app);
|
||||
const [entry] = app.vehicles();
|
||||
assert.notEqual(entry.mode, 'unknown', 'a mode its model is chosen by');
|
||||
// The throttled sweep runs; a model's load answers off the frame and the
|
||||
// next frames place it.
|
||||
const frames = async () => {
|
||||
app.advance(250);
|
||||
app.settle();
|
||||
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||
app.settle();
|
||||
};
|
||||
await frames();
|
||||
assert.equal(models.drawn(key).length, 1, 'near the camera: its model');
|
||||
assert.equal(entry.modelOwnsVisual, true);
|
||||
assert.equal(entry.marker.show, false, 'its icon hands over');
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(key), true);
|
||||
app.frame();
|
||||
// The view's eye is inside the vehicle.
|
||||
assert.equal(models.drawn(key).length, 0, 'no model');
|
||||
assert.equal(entry.modelOwnsVisual, false);
|
||||
assert.equal(entry.marker.show, false, 'and no icon');
|
||||
await frames();
|
||||
assert.equal(models.drawn(key).length, 0, 'not on a later frame either');
|
||||
assert.equal(entry.marker.show, false);
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
await frames();
|
||||
assert.equal(models.drawn(key).length, 1, 'let go: its model again');
|
||||
assert.equal(entry.modelOwnsVisual, true);
|
||||
assert.equal(entry.marker.show, false);
|
||||
});
|
||||
|
||||
test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await busRunningNorth(app);
|
||||
// A vehicle only a hold selected reports none.
|
||||
assert.equal(app.layer.setCockpitSubject(key), true);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless');
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
// The user's selection stamps it, again on a re-selection.
|
||||
assert.equal(app.layer.focusVehicle(key), true);
|
||||
const first = app.layer.getSelectedVehicle().selectedAtMs;
|
||||
assert.ok(Number.isFinite(first));
|
||||
app.run(1000);
|
||||
app.layer.focusVehicle(key);
|
||||
assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first);
|
||||
// Deselected, then held: none.
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.state()._selectedAtMs, null);
|
||||
app.layer.setCockpitSubject(key);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
});
|
||||
|
||||
test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => {
|
||||
const app = harness(t);
|
||||
const key = await busRunningNorth(app);
|
||||
assert.equal(app.layer.setCockpitSubject('mbta:nope'), false);
|
||||
assert.equal(app.layer.setCockpitSubject(key), true);
|
||||
assert.equal(app.state()._selectedKey, key, 'holding selects it');
|
||||
assert.equal(app.layer.getCockpitSubject().key, key);
|
||||
assert.equal(
|
||||
app.overlaySources.has(TRANSIT_SELECTED_OVERLAY_SOURCE_ID),
|
||||
false,
|
||||
'no card',
|
||||
);
|
||||
const [entry] = app.vehicles();
|
||||
app.frame();
|
||||
assert.equal(entry.marker.show, false);
|
||||
// The camera's view leaves it, as a view from inside it does: it plays on.
|
||||
app.lookAt({ west: 10, south: 10, east: 11, north: 11 });
|
||||
app.settle();
|
||||
assert.equal(
|
||||
app.layer._transitPartsForTest().rendering.vehicleInView(entry),
|
||||
true,
|
||||
);
|
||||
const before = app.layer.getCockpitSubject().position.latitude;
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
assert.ok(
|
||||
app.layer.getCockpitSubject().position.latitude > before,
|
||||
'still moving',
|
||||
);
|
||||
// Escape and clicks are the view's while it holds the vehicle.
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
// Let go, it is out of play until a sweep finds it in view.
|
||||
assert.equal(app.state()._visible.has(entry), false, 'out of play');
|
||||
app.lookAt({
|
||||
west: -71.2,
|
||||
south: 42.2,
|
||||
east: -70.9,
|
||||
north: 42.5,
|
||||
});
|
||||
app.advance(250);
|
||||
app.settle();
|
||||
assert.equal(entry.marker.show, true, 'shown again');
|
||||
assert.equal(app.layer.focusVehicle('mbta:nope'), false);
|
||||
assert.equal(app.layer.focusVehicle(key), true);
|
||||
|
||||
app.layer.setCockpitSubject(key);
|
||||
app.silence('mbta');
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot('mbta', 'MBTA', [], { fetchedAt: Date.now() }),
|
||||
}));
|
||||
for (let i = 0; i < MISSED_POLLS_TO_DROP + 1; i += 1) {
|
||||
app.run(TRANSIT_POLL_MS);
|
||||
await app.layer.update();
|
||||
}
|
||||
assert.equal(app.vehicles().length, 0, 'the vehicle is gone');
|
||||
assert.equal(app.state()._cockpitSubjectKey, null);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
});
|
||||
@@ -106,8 +106,12 @@ export function createRendering({ state, services, parts }) {
|
||||
);
|
||||
entry.surfaceReady = !!known;
|
||||
entry.heightPending = !known;
|
||||
// A vehicle a first-person view rides keeps moving, unseen.
|
||||
entry.marker.show =
|
||||
!!known && vehicleInView(entry) && !entry.modelOwnsVisual;
|
||||
!!known &&
|
||||
vehicleInView(entry) &&
|
||||
!entry.modelOwnsVisual &&
|
||||
entry.key !== state._cockpitSubjectKey;
|
||||
if (known) {
|
||||
entry.marker.position = state._scratchCartesian;
|
||||
entry.hasRendered = true;
|
||||
@@ -198,7 +202,7 @@ export function createRendering({ state, services, parts }) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function vehicleInView(entry) {
|
||||
return state._visible.has(entry);
|
||||
return state._visible.has(entry) || entry.key === state._cockpitSubjectKey;
|
||||
}
|
||||
|
||||
/** Cesium colour for a preset tuple, cached per profile+mode. */
|
||||
@@ -489,7 +493,7 @@ export function createRendering({ state, services, parts }) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand an entry's visual back to its icon. */
|
||||
/** Hand an entry's visual back to its icon, unless a view rides it. */
|
||||
function handBack(entry) {
|
||||
if (!entry.modelOwnsVisual) return;
|
||||
entry.modelOwnsVisual = false;
|
||||
@@ -497,7 +501,8 @@ export function createRendering({ state, services, parts }) {
|
||||
entry.marker.show =
|
||||
vehicleInView(entry) &&
|
||||
!entry.heightPending &&
|
||||
entry.surfaceReady !== false;
|
||||
entry.surfaceReady !== false &&
|
||||
entry.key !== state._cockpitSubjectKey;
|
||||
}
|
||||
|
||||
function updateModels() {
|
||||
@@ -534,7 +539,12 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
item.position = entry.marker.position;
|
||||
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
|
||||
item.show = !entry.heightPending && entry.surfaceReady !== false;
|
||||
// A vehicle a first-person view rides draws no model: the camera is
|
||||
// inside it (cockpitSubject.js).
|
||||
item.show =
|
||||
!entry.heightPending &&
|
||||
entry.surfaceReady !== false &&
|
||||
entry.key !== state._cockpitSubjectKey;
|
||||
next.set(entry.key, item);
|
||||
}
|
||||
for (const [key, item] of modelItems)
|
||||
@@ -641,7 +651,10 @@ export function createRendering({ state, services, parts }) {
|
||||
} else if (frustum)
|
||||
inFrustum =
|
||||
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
|
||||
const visible = (frustum || bounds) && horizon && inFrustum;
|
||||
// A held vehicle (cockpitSubject.js) is the camera's own: it stays in
|
||||
// play wherever the view looks.
|
||||
const held = entry.key === state._cockpitSubjectKey;
|
||||
const visible = held || ((frustum || bounds) && horizon && inFrustum);
|
||||
const visibility = (entry.visibility ||= {});
|
||||
visibility.bounds = bounds;
|
||||
visibility.boundsApplied = !frustum;
|
||||
@@ -666,6 +679,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
}
|
||||
entry.marker.show =
|
||||
!held &&
|
||||
visible &&
|
||||
!entry.heightPending &&
|
||||
entry.surfaceReady !== false &&
|
||||
|
||||
@@ -48,6 +48,12 @@ export function createSelection({ state, services, parts }) {
|
||||
? state._vehicles.get(state._selectedKey)
|
||||
: null;
|
||||
if (!entry) return;
|
||||
// A vehicle a first-person view rides shows no card.
|
||||
if (entry.key === state._cockpitSubjectKey) {
|
||||
state._selectedCardText = null;
|
||||
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS)
|
||||
return;
|
||||
@@ -93,6 +99,7 @@ export function createSelection({ state, services, parts }) {
|
||||
: null;
|
||||
parts.trails.clear();
|
||||
state._selectedKey = null;
|
||||
state._selectedAtMs = null;
|
||||
state._selectedCardText = null;
|
||||
state._detectRevision += 1;
|
||||
// Cleared first: the styling path reads the selection from state.
|
||||
@@ -100,11 +107,18 @@ export function createSelection({ state, services, parts }) {
|
||||
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
function selectVehicle(key) {
|
||||
/**
|
||||
* Select a vehicle. `byUser` marks a selection the user made (a click, or
|
||||
* focusVehicle), even of the selected vehicle again: its time is the
|
||||
* snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold
|
||||
* selects without it.
|
||||
*/
|
||||
function selectVehicle(key, { byUser = false } = {}) {
|
||||
clearSelection();
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry?.marker) return;
|
||||
state._selectedKey = key;
|
||||
if (byUser) state._selectedAtMs = Date.now();
|
||||
state._detectRevision += 1;
|
||||
parts.rendering.paintSelected(entry, true);
|
||||
parts.trails.select(entry);
|
||||
@@ -113,6 +127,8 @@ export function createSelection({ state, services, parts }) {
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
// While a first-person view rides the vehicle, Escape is the view's.
|
||||
if (state._cockpitSubjectKey) return;
|
||||
if (event.key === 'Escape' && state._selectedKey) clearSelection();
|
||||
}
|
||||
|
||||
@@ -125,6 +141,8 @@ export function createSelection({ state, services, parts }) {
|
||||
// Ambient selection never competes for a pointer a tool has claimed:
|
||||
// placing a draw vertex on top of a bus must not also select the bus.
|
||||
if (!isPointerFree()) return;
|
||||
// A held vehicle keeps the selection until its view lets it go.
|
||||
if (state._cockpitSubjectKey) return;
|
||||
const scene = viewer.scene;
|
||||
// Pick buffers precede post-processing. Map a sensor pixel back through
|
||||
// the active shader's barrel distortion and quantization before picking.
|
||||
@@ -181,11 +199,11 @@ export function createSelection({ state, services, parts }) {
|
||||
typeof primitiveId === 'string' &&
|
||||
state._vehicles.has(primitiveId)
|
||||
) {
|
||||
selectVehicle(primitiveId);
|
||||
selectVehicle(primitiveId, { byUser: true });
|
||||
return;
|
||||
}
|
||||
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
|
||||
selectVehicle(picked.id);
|
||||
selectVehicle(picked.id, { byUser: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,15 @@ export function createState({ services }) {
|
||||
|
||||
/** @type {string|null} */
|
||||
state._selectedKey = null;
|
||||
/** When the user last selected a vehicle, even the selected one (ms). */
|
||||
state._selectedAtMs = null;
|
||||
/**
|
||||
* The vehicle a first-person view holds (cockpitSubject.js), or null: its
|
||||
* sprite, 3D model, card, trail and detection box are hidden, and it keeps
|
||||
* moving out of the camera's view.
|
||||
* @type {string|null}
|
||||
*/
|
||||
state._cockpitSubjectKey = null;
|
||||
state._selectedCardAt = 0;
|
||||
/** The card text last published, so an unchanged card is not re-sent. */
|
||||
state._selectedCardText = null;
|
||||
|
||||
@@ -235,8 +235,11 @@ export function createTrails({ state, services, parts, source }) {
|
||||
}
|
||||
function update() {
|
||||
if (!selected || !renderer) return;
|
||||
// A first-person view on the vehicle rides where its trail would start.
|
||||
renderer.setVisible(
|
||||
state._enabled && state._vehicles.get(selected.key) === selected,
|
||||
state._enabled &&
|
||||
state._vehicles.get(selected.key) === selected &&
|
||||
selected.key !== state._cockpitSubjectKey,
|
||||
);
|
||||
renderer.setDisplaySample(selected.sample, selected.marker?.position);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ export function createViewport({ state, services, parts }) {
|
||||
if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed);
|
||||
}
|
||||
}
|
||||
// A vehicle a first-person view rides keeps its feed polled.
|
||||
const held = state._vehicles.get(state._cockpitSubjectKey);
|
||||
if (held && state._activeFeeds.has(held.feedId))
|
||||
desired.set(held.feedId, state._activeFeeds.get(held.feedId));
|
||||
for (const feedId of [...state._activeFeeds.keys()]) {
|
||||
if (!desired.has(feedId)) {
|
||||
state._inFlight.get(feedId)?.controller.abort();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Fleet models for a layer test (fleetModelInstances.js): the project's model
|
||||
// set loaded from public/, a DISPLAY 3D toggle that is on, and Cesium's glTF
|
||||
// loader replaced by one that answers at once with a ready model, recorded.
|
||||
import * as Cesium from 'cesium';
|
||||
import { loadFleetModelSet } from '../data/fleetModels.js';
|
||||
import { fetchPublicFile } from './fleetGlb.mjs';
|
||||
|
||||
/** A model policy whose eligibility is read again on every frame. */
|
||||
export const FLEET_TEST_POLICY = Object.freeze({
|
||||
altitudeCeilingM: 800_000,
|
||||
minimumPixelSize: 16,
|
||||
cap: 4,
|
||||
capAll: 8,
|
||||
addM: 5_000,
|
||||
addAllM: 20_000,
|
||||
keepM: 6_000,
|
||||
keepAllM: 24_000,
|
||||
intervalMs: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Load the fleet set (once a process, as the app does) and fake the loader
|
||||
* for this test. Call it before the layer's harness, which replaces `fetch`.
|
||||
* @param {import('node:test').TestContext} t
|
||||
* @returns {Promise<{fleet: object, created: object[],
|
||||
* drawn: (id: string) => object[]}>} `fleet` for the layer's services;
|
||||
* `drawn(id)` the models drawn for the vehicle `id` now.
|
||||
*/
|
||||
export async function fleetModelFakes(t) {
|
||||
const info = console.info;
|
||||
console.info = () => {};
|
||||
try {
|
||||
await loadFleetModelSet({ fetchImpl: fetchPublicFile });
|
||||
} finally {
|
||||
console.info = info;
|
||||
}
|
||||
const created = [];
|
||||
t.mock.method(Cesium.Model, 'fromGltfAsync', async (options) => {
|
||||
const model = {
|
||||
...options,
|
||||
ready: true,
|
||||
show: false,
|
||||
modelMatrix: new Cesium.Matrix4(),
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
},
|
||||
update() {},
|
||||
};
|
||||
created.push(model);
|
||||
return model;
|
||||
});
|
||||
return {
|
||||
fleet: {
|
||||
policy: FLEET_TEST_POLICY,
|
||||
display: () => ({ models3d: true, models3dMode: 'proximity' }),
|
||||
resolveAsset: (url) => url,
|
||||
},
|
||||
created,
|
||||
drawn: (id) =>
|
||||
created.filter(
|
||||
(model) => model.id === id && !model.destroyed && model.show,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,24 @@ import { publicRailCatalog } from '../data/railFeeds.js';
|
||||
export const RAIL_HARNESS_T0 = 1_789_000_000_000;
|
||||
export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 });
|
||||
|
||||
/** A camera's frame looking straight down from `altitude` over `at`. */
|
||||
function lookingDown(at, altitude) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude);
|
||||
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return {
|
||||
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
|
||||
upWC: Cesium.Cartesian3.UNIT_Z,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A train as the rail proxy sends it. */
|
||||
export function railTrain(id, lat, lon, extra = {}) {
|
||||
return {
|
||||
@@ -64,7 +82,7 @@ const headersOf = (values = {}) => ({
|
||||
* catalog?: object[]|Promise<object[]>|Function|false,
|
||||
* ground?: object,
|
||||
* stops?: object|null, scheduleStops?: boolean,
|
||||
* paths?: Record<string, object>|null}} [options]
|
||||
* paths?: Record<string, object>|null, fleet?: object|null}} [options]
|
||||
* `ground`: a floor service in place of the always-cold stub; `catalog`:
|
||||
* the server's feed catalog (it may list fewer feeds than are bundled; a
|
||||
* promise answers when it settles, a function is called per request and
|
||||
@@ -74,7 +92,9 @@ const headersOf = (values = {}) => ({
|
||||
* layer's own provider, its schedule tier, in its place;
|
||||
* `paths`: feed id → the path pack the proxy serves, or a
|
||||
* failure status it answers with (404 for any other feed; without it the
|
||||
* source has no path route at all).
|
||||
* source has no path route at all); `fleet`: the fleet-model services
|
||||
* (testSupport/fleetModelFakes.mjs), with a camera that looks straight
|
||||
* down for them (none by default: trains stay icons).
|
||||
*/
|
||||
export function railLayerHarness(
|
||||
t,
|
||||
@@ -86,6 +106,7 @@ export function railLayerHarness(
|
||||
stops = null,
|
||||
scheduleStops = false,
|
||||
paths = null,
|
||||
fleet = null,
|
||||
} = {},
|
||||
) {
|
||||
const T0 = RAIL_HARNESS_T0;
|
||||
@@ -179,6 +200,8 @@ export function railLayerHarness(
|
||||
cancelFlight() {
|
||||
this.flightsCancelled += 1;
|
||||
},
|
||||
// What the fleet models' eligibility reads: a camera looking down.
|
||||
...(fleet ? lookingDown(at, altitude) : {}),
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
@@ -308,6 +331,7 @@ export function railLayerHarness(
|
||||
cachedGroundFloor: () => null,
|
||||
warmGroundFloor() {},
|
||||
},
|
||||
...(fleet ? { fleet } : {}),
|
||||
camera: {
|
||||
framing: WORLD_FOCUS_FRAMING.train,
|
||||
requestWorldFocus(detail) {
|
||||
|
||||
@@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade {
|
||||
owner: 'cockpit-orbit',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
}) ?? null,
|
||||
// The application's own first-person subjects
|
||||
// (src/cockpitSubjectProviders.js), checked by the coordinator.
|
||||
cockpitSubjects: services.cockpitSubjects,
|
||||
},
|
||||
elements: {
|
||||
_ppToggles: this._ppToggles,
|
||||
@@ -1197,6 +1200,11 @@ export class StyleManager extends ShellFacade {
|
||||
toggleEnabled: (...args) => this._toggleCctvEnabled(...args),
|
||||
runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args),
|
||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||
// Clear selected data layers and Context isolation switch the layer
|
||||
// off without the user asking: the panel keeps its state for both.
|
||||
preservePanelState: () =>
|
||||
this._preservePanelStateDuringLayerClear ||
|
||||
this._contextSuspendsLayer('cctv'),
|
||||
showToast: (message) => this._showToast(message),
|
||||
syncViewport: () => this._syncCctvPanelViewport(),
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ export class CctvControls {
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
this._wasCctvEnabled = false;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = false;
|
||||
// The camera grid chip as last published; its label stays while hidden.
|
||||
|
||||
@@ -513,3 +513,96 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () =
|
||||
assert.match(meta(359.4), /· HDG 359° ·/);
|
||||
assert.match(meta(359.6), /· HDG 0° ·/);
|
||||
});
|
||||
|
||||
/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */
|
||||
function collapseCalls(controls) {
|
||||
const calls = [];
|
||||
controls.actions.setPanelCollapsed = (panelId, collapsed) => {
|
||||
calls.push([panelId, collapsed]);
|
||||
// The panel chrome owns the class the controls read back.
|
||||
controls._cctvPanel.classList.toggle('collapsed', collapsed);
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const activeState = {
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [],
|
||||
activeCameraId: camera.id,
|
||||
activeCamera: camera,
|
||||
};
|
||||
|
||||
test('switching the layer off collapses the panel it opened', (t) => {
|
||||
const { controls } = panel(t);
|
||||
const calls = collapseCalls(controls);
|
||||
controls._renderCctvState(activeState);
|
||||
assert.deepEqual(calls, [['cctv-panel', false]]);
|
||||
// The layer keeps its active camera while off.
|
||||
controls._renderCctvState({ ...activeState, enabled: false });
|
||||
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
|
||||
// A panel reopened by hand stays open through later notifications while
|
||||
// the layer is off: only the on → off change collapses.
|
||||
controls._cctvPanel.classList.remove('collapsed');
|
||||
controls._renderCctvState({ ...activeState, enabled: false });
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('a layer that was never on leaves an open panel alone', (t) => {
|
||||
const { controls } = panel(t);
|
||||
const calls = collapseCalls(controls);
|
||||
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
|
||||
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('the data manager switching the layer off collapses the panel too', (t) => {
|
||||
const { controls } = panel(t);
|
||||
const calls = collapseCalls(controls);
|
||||
let on = true;
|
||||
controls.actions.isEnabled = () => on;
|
||||
controls._renderCctvState(activeState);
|
||||
on = false;
|
||||
controls._renderCctvState(activeState);
|
||||
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
|
||||
});
|
||||
|
||||
test('a layer the application suspends keeps its panel through the round trip', (t) => {
|
||||
const { controls } = panel(t);
|
||||
const calls = collapseCalls(controls);
|
||||
// Clear selected data layers and Context isolation both answer true.
|
||||
let suspended = true;
|
||||
controls.actions.preservePanelState = () => suspended;
|
||||
controls._renderCctvState(activeState);
|
||||
controls._renderCctvState({ ...activeState, enabled: false });
|
||||
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
|
||||
suspended = false;
|
||||
controls._renderCctvState(activeState);
|
||||
assert.equal(controls._cctvPanel.classList.contains('collapsed'), false);
|
||||
assert.ok(calls.every(([, collapsed]) => collapsed === false));
|
||||
// The next switch-off is the user's again.
|
||||
controls._renderCctvState({ ...activeState, enabled: false });
|
||||
assert.deepEqual(calls.at(-1), ['cctv-panel', true]);
|
||||
});
|
||||
|
||||
test('a panel the accordion folded still has its saved state closed', (t) => {
|
||||
const { controls } = panel(t);
|
||||
controls._renderCctvState(activeState);
|
||||
const calls = [];
|
||||
controls.actions.setPanelCollapsed = (...args) => calls.push(args);
|
||||
// Folded for layout only: the class is there, the saved state is open. The
|
||||
// panel chrome tells the two apart, so the controls always ask it.
|
||||
controls._cctvPanel.classList.add('collapsed');
|
||||
controls._renderCctvState({ ...activeState, enabled: false });
|
||||
assert.deepEqual(calls, [['cctv-panel', true]]);
|
||||
});
|
||||
|
||||
test('the meta line of a disabled layer does not describe its last camera', () => {
|
||||
const off = { ...activeState, enabled: false, count: 3 };
|
||||
assert.equal(
|
||||
presentCctvPanel(off, false).meta,
|
||||
'3 cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /);
|
||||
});
|
||||
@@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) {
|
||||
}
|
||||
|
||||
function metaText(state, enabled, activeCamera, catalogCount) {
|
||||
if (activeCamera) {
|
||||
// The layer keeps its active camera while off: a disabled panel must not
|
||||
// describe a feed it no longer shows.
|
||||
if (enabled && activeCamera) {
|
||||
const provider = cctvSourceLabelText(
|
||||
activeCamera.sourceLabel || activeCamera.provider || 'Configured Source',
|
||||
);
|
||||
@@ -188,6 +190,20 @@ export function _renderCctvState(state) {
|
||||
});
|
||||
}
|
||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||
// Switching the layer off closes the panel it opened: nothing is left to
|
||||
// show. Only the on → off change collapses, so a panel the user opens
|
||||
// while the layer is off stays open. Clear selected data layers and
|
||||
// Context isolation keep the panel: the layer comes back with it. The
|
||||
// panel chrome decides whether anything changes: a panel the desktop
|
||||
// accordion only folded still has an open saved state to close.
|
||||
if (
|
||||
this._wasCctvEnabled &&
|
||||
!enabled &&
|
||||
!this.actions.preservePanelState?.()
|
||||
) {
|
||||
this.actions.setPanelCollapsed('cctv-panel', true);
|
||||
}
|
||||
this._wasCctvEnabled = enabled;
|
||||
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) {
|
||||
!Number.isFinite(info.longitude)
|
||||
)
|
||||
return;
|
||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`;
|
||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`;
|
||||
if (subjectId !== this.regionalBriefSubjectId) {
|
||||
this.regionalBriefAbort?.abort();
|
||||
this.regionalBriefAbort = null;
|
||||
|
||||
+168
-4
@@ -3,6 +3,8 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitGroundSafeHeight,
|
||||
easeCockpitLift,
|
||||
cockpitSubjectStepSec,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
slewHeading,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
waterlineProbePoints,
|
||||
} from '../cockpitSubjects.js';
|
||||
import { easeLook } from '../cockpitLook.js';
|
||||
import { cockpitSubjectFrame } from '../cockpitSubjectProviders.js';
|
||||
import {
|
||||
COCKPIT_HEADING_SLEW_DPS,
|
||||
COCKPIT_FORWARD_OFFSET_M,
|
||||
@@ -53,6 +56,10 @@ export function update() {
|
||||
}
|
||||
|
||||
const nowMs = performance.now();
|
||||
if (this.subjectProvider) {
|
||||
updateSubject.call(this, nowMs);
|
||||
return;
|
||||
}
|
||||
if (this.subjectKind === 'vessel') {
|
||||
updateVessel.call(this, nowMs);
|
||||
return;
|
||||
@@ -297,9 +304,128 @@ function updateVessel(nowMs) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the camera from an application provider's subject: read its frame,
|
||||
* stand the eye `eye.upM` above `heightM` (the shared floor where the frame
|
||||
* gives none) and slew toward its heading. A frame that is null, malformed or
|
||||
* for another subject ends the view, as an aircraft tracked meanwhile does.
|
||||
* The update runs on the provider's `cameraIntervalMs` (0: every frame), and
|
||||
* its rates advance by the time that interval spans (cockpitSubjectStepSec).
|
||||
*/
|
||||
function updateSubject(nowMs) {
|
||||
const provider = this.subjectProvider;
|
||||
if (this.viewer.trackedEntity?.position) {
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
const intervalMs = provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS;
|
||||
if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, intervalMs)) return;
|
||||
this.lastCameraUpdateMs = nowMs;
|
||||
const frame = cockpitSubjectFrame(
|
||||
this.callSubject(provider, 'read', [nowMs, this.subjectContext()], null),
|
||||
this.subjectId,
|
||||
);
|
||||
if (!frame) {
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
this.lastSubjectFrame = frame;
|
||||
let heightM = frame.heightM;
|
||||
if (heightM === null) {
|
||||
this.services.warmGroundFloor?.([
|
||||
{ lat: frame.latitude, lon: frame.longitude },
|
||||
]);
|
||||
const floorM = this.services.cachedGroundFloor?.(
|
||||
frame.latitude,
|
||||
frame.longitude,
|
||||
);
|
||||
// A frame with no height stands on the shared floor; while that is cold
|
||||
// it keeps the height it last stood at.
|
||||
heightM = Number.isFinite(floorM) ? floorM : this.subjectHeightM;
|
||||
}
|
||||
if (!Number.isFinite(heightM)) {
|
||||
// Nothing to stand on yet, from the first frame: the camera stays where
|
||||
// it is while the floor is acquired, its HUD saying so, rather than
|
||||
// standing on the ellipsoid (kilometres underground on a plateau). The
|
||||
// wait is bounded as the built-in views' is. Unlike a parked aircraft,
|
||||
// whose fallback is its own reported altitude, such a subject has no
|
||||
// height at all to fall back to: any number would be a guess, so the
|
||||
// view ends and the provider gets its subject back.
|
||||
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
|
||||
if (
|
||||
cockpitSurfaceWaitExpired(
|
||||
nowMs,
|
||||
this.surfaceWaitStartedMs,
|
||||
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||
)
|
||||
) {
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
this.surfaceAcquiring = true;
|
||||
if (
|
||||
cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)
|
||||
) {
|
||||
this.lastHudUpdateMs = nowMs;
|
||||
this.updateHud(this.subjectInfo(frame), nowMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.subjectHeightM = heightM;
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
frame.longitude,
|
||||
frame.latitude,
|
||||
heightM,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchTarget,
|
||||
);
|
||||
const dtSec = cockpitSubjectStepSec(nowMs - this.lastFrameMs, intervalMs);
|
||||
this.lastFrameMs = nowMs;
|
||||
if (frame.headingDeg !== null) {
|
||||
const rate = provider.headingSlewDps ?? COCKPIT_HEADING_SLEW_DPS;
|
||||
this.heading =
|
||||
rate === Infinity
|
||||
? frame.headingDeg
|
||||
: slewHeading(
|
||||
this.heading ?? frame.headingDeg,
|
||||
frame.headingDeg,
|
||||
rate * dtSec,
|
||||
);
|
||||
}
|
||||
let look = null;
|
||||
if (provider.look) {
|
||||
this.lookView = easeLook(
|
||||
this.lookView,
|
||||
this.lookTarget,
|
||||
nowMs - (this.lookLastMs || nowMs),
|
||||
);
|
||||
this.lookLastMs = nowMs;
|
||||
look = this.lookView;
|
||||
}
|
||||
placeCamera.call(
|
||||
this,
|
||||
target,
|
||||
this.subjectInfo(frame),
|
||||
nowMs,
|
||||
dtSec,
|
||||
{
|
||||
forwardM: frame.eye.forwardM,
|
||||
upM: frame.eye.upM,
|
||||
pitchDeg: frame.eye.pitchDeg,
|
||||
minClearanceM: frame.eye.minClearanceM,
|
||||
},
|
||||
look,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the camera for one update: advance the inertial anchor, clamp it and
|
||||
* the eye against the rendered surface, and look along the slewed heading.
|
||||
* A subject with `anchor: 'exact'` keeps the anchor on its target (its layer
|
||||
* already moves it smoothly), and one with `groundClamp: false` (inside a
|
||||
* structure, say) is never pushed up to the floor: a lift it had eases out.
|
||||
* @param {Cesium.Cartesian3} target Authoritative subject position.
|
||||
* @param {object} info Subject info; `motionCourse` (when present) is the
|
||||
* direction the subject moves, otherwise it moves where it faces.
|
||||
@@ -316,6 +442,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
if (!this.cockpitAnchorValid) {
|
||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||
this.cockpitAnchorValid = true;
|
||||
this.subjectLiftM = null;
|
||||
}
|
||||
|
||||
// First-person motion cannot use the delayed feed correction as a raw
|
||||
@@ -339,7 +466,8 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
? Math.max(0, info.velocityMps)
|
||||
: 0;
|
||||
|
||||
if (info.stale) {
|
||||
const groundClamp = info.groundClamp !== false;
|
||||
if (info.stale || info.anchor === 'exact') {
|
||||
// A feed backoff has no authoritative velocity epoch to advance from.
|
||||
// Hold the cockpit on the exact layer-rendered position so the camera
|
||||
// cannot coast away while the icon correctly remains fixed.
|
||||
@@ -377,10 +505,13 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
const correctionDistanceM = Cesium.Cartesian3.magnitude(
|
||||
this.scratchCorrection,
|
||||
);
|
||||
// An application subject's step is already bounded by its own update
|
||||
// interval (cockpitSubjectStepSec); the built-in views keep the 0.1 s.
|
||||
const correctionStepM = cockpitAnchorCorrectionStep(
|
||||
correctionDistanceM,
|
||||
speedMps,
|
||||
dtSec,
|
||||
info.generic === true ? dtSec : undefined,
|
||||
);
|
||||
if (correctionStepM > 0 && correctionDistanceM > 0) {
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
@@ -498,7 +629,14 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
cockpitFloorM =
|
||||
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
|
||||
}
|
||||
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
||||
// An application subject's anchor is where its provider puts it; only its
|
||||
// eye is kept out of the floor, below.
|
||||
if (
|
||||
groundClamp &&
|
||||
info.generic !== true &&
|
||||
anchorCartographic &&
|
||||
Number.isFinite(cockpitFloorM)
|
||||
) {
|
||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||
anchorCartographic.height,
|
||||
cockpitFloorM,
|
||||
@@ -579,7 +717,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchCameraCartographic,
|
||||
);
|
||||
if (cameraCartographic) {
|
||||
if (groundClamp && cameraCartographic) {
|
||||
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
|
||||
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
|
||||
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
|
||||
@@ -589,11 +727,26 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
cameraFloorM,
|
||||
);
|
||||
}
|
||||
const safeHeightM = cockpitGroundSafeHeight(
|
||||
let safeHeightM = cockpitGroundSafeHeight(
|
||||
cameraCartographic.height,
|
||||
cockpitFloorM,
|
||||
profile.minClearanceM,
|
||||
);
|
||||
// An application subject's eye rides its provider's smoothed height:
|
||||
// the clearance over the coarse, refining floor eases in and out
|
||||
// rather than jumping at each cell (easeCockpitLift).
|
||||
if (info.generic === true) {
|
||||
const hardM =
|
||||
cockpitGroundSafeHeight(cameraCartographic.height, cockpitFloorM, 0) -
|
||||
cameraCartographic.height;
|
||||
this.subjectLiftM = easeCockpitLift(
|
||||
this.subjectLiftM,
|
||||
safeHeightM - cameraCartographic.height,
|
||||
hardM,
|
||||
dtSec,
|
||||
);
|
||||
safeHeightM = cameraCartographic.height + this.subjectLiftM;
|
||||
}
|
||||
if (safeHeightM !== cameraCartographic.height) {
|
||||
cameraCartographic.height = safeHeightM;
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||
@@ -601,6 +754,17 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
this.scratchCamera,
|
||||
);
|
||||
}
|
||||
} else if (info.generic === true) {
|
||||
// Inside a structure the floor holds the eye up no more: the lift it gave
|
||||
// goes at the rate it came (easeCockpitLift), never at once.
|
||||
this.subjectLiftM = easeCockpitLift(this.subjectLiftM, 0, 0, dtSec);
|
||||
if (cameraCartographic && this.subjectLiftM > 0) {
|
||||
cameraCartographic.height += this.subjectLiftM;
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||
cameraCartographic,
|
||||
this.scratchCamera,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.viewer.camera.setView({
|
||||
|
||||
@@ -3,12 +3,18 @@ import {
|
||||
readAircraftInfo,
|
||||
readSatelliteInfo,
|
||||
readVesselInfo,
|
||||
readSubjectCandidate,
|
||||
readSubjectLayerId,
|
||||
subjectContext,
|
||||
subjectInfo,
|
||||
entryCandidates,
|
||||
resolveEntry,
|
||||
resolveEntryKind,
|
||||
dispatchCockpitModeChanged,
|
||||
toggleTrackedTr3b,
|
||||
syncTr3bToggle,
|
||||
syncEntry,
|
||||
refreshSubjectWords,
|
||||
publishPresenceView,
|
||||
navigateContext,
|
||||
_adoptTrackedEntity,
|
||||
@@ -100,6 +106,7 @@ import {
|
||||
import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js';
|
||||
import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js';
|
||||
import { stepLook } from '../cockpitLook.js';
|
||||
import { createCockpitSubjectCaller } from '../cockpitSubjectProviders.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Whether the user asked for reduced motion: look turns then jump. */
|
||||
@@ -120,6 +127,8 @@ export class CockpitViewController {
|
||||
isEntryAllowed = null,
|
||||
isBridgeEntryAllowed = null,
|
||||
isOrbitEntryAllowed = null,
|
||||
subjectProviders = [],
|
||||
isSubjectEntryAllowed = null,
|
||||
isUtilityDisclosureOpen = null,
|
||||
onEntered = null,
|
||||
onExited = null,
|
||||
@@ -134,10 +143,27 @@ export class CockpitViewController {
|
||||
this.disposed = false;
|
||||
this.active = false;
|
||||
/**
|
||||
* 'aircraft', 'vessel' or 'satellite' while active; which subject the
|
||||
* camera rides.
|
||||
* 'aircraft', 'vessel', 'satellite' or a provider's kind while active;
|
||||
* which subject the camera rides.
|
||||
*/
|
||||
this.subjectKind = null;
|
||||
/**
|
||||
* The application's subject providers (cockpitSubjectProviders.js),
|
||||
* checked by the coordinator; the one ridden while active, else null.
|
||||
*/
|
||||
this.subjectProviders = Array.isArray(subjectProviders)
|
||||
? subjectProviders
|
||||
: [];
|
||||
this.subjectProvider = null;
|
||||
/** The ridden provider subject's last frame and normalized HUD view. */
|
||||
this.lastSubjectFrame = null;
|
||||
this.subjectHud = null;
|
||||
/** The Live Signals rows the provider's HUD view pushed. */
|
||||
this.subjectSignalKeys = new Set();
|
||||
/** Provider methods run through one guard: a throw warns once. */
|
||||
this.callSubject = createCockpitSubjectCaller();
|
||||
/** The look's rest pitch for a provider's view; null on a bridge. */
|
||||
this.lookRestPitchDeg = null;
|
||||
/**
|
||||
* The vessel subject's id or the satellite's NORAD id; aircraft are
|
||||
* identified by trackedEntity.
|
||||
@@ -160,6 +186,10 @@ export class CockpitViewController {
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
/** The height an application subject's frame last stood at (m). */
|
||||
this.subjectHeightM = null;
|
||||
/** The ground clamp's eased lift under that subject's eye (m). */
|
||||
this.subjectLiftM = null;
|
||||
/**
|
||||
* Where the bridge view is turned to (`lookTarget`) and where the camera
|
||||
* looks on its way there (`lookView`): yaw off the bow, starboard
|
||||
@@ -203,6 +233,11 @@ export class CockpitViewController {
|
||||
typeof isOrbitEntryAllowed === 'function'
|
||||
? isOrbitEntryAllowed
|
||||
: () => false;
|
||||
// Nor a provider's subject without a gate for it.
|
||||
this.isSubjectEntryAllowed =
|
||||
typeof isSubjectEntryAllowed === 'function'
|
||||
? isSubjectEntryAllowed
|
||||
: () => false;
|
||||
this.isUtilityDisclosureOpen =
|
||||
typeof isUtilityDisclosureOpen === 'function'
|
||||
? isUtilityDisclosureOpen
|
||||
@@ -246,7 +281,14 @@ export class CockpitViewController {
|
||||
entryAvailable: false,
|
||||
// Which subject the entry button would enter, and the active subject.
|
||||
entryKind: null,
|
||||
// A provider's entry face, `{icon, label}`; null for the built-ins.
|
||||
entryFace: null,
|
||||
subjectKind: null,
|
||||
// While a provider's subject is ridden: its HUD and exit labels, and
|
||||
// `{aheadLabel}` when it looks around.
|
||||
subjectGeneric: false,
|
||||
subjectLabels: null,
|
||||
subjectLook: null,
|
||||
tr3b: null,
|
||||
context: false,
|
||||
// The Contact and briefing cards' collapse, which their toggles show.
|
||||
@@ -508,6 +550,21 @@ export class CockpitViewController {
|
||||
if (reset && hadFocus) this.mapViewButton?.focus({ preventScroll: true });
|
||||
return reset;
|
||||
}
|
||||
readSubjectCandidate(provider) {
|
||||
return readSubjectCandidate.call(this, provider);
|
||||
}
|
||||
subjectContext() {
|
||||
return subjectContext.call(this);
|
||||
}
|
||||
subjectInfo(frame) {
|
||||
return subjectInfo.call(this, frame);
|
||||
}
|
||||
entryCandidates(nowMs) {
|
||||
return entryCandidates.call(this, nowMs);
|
||||
}
|
||||
resolveEntry(nowMs) {
|
||||
return resolveEntry.call(this, nowMs);
|
||||
}
|
||||
readSubjectLayerId() {
|
||||
return readSubjectLayerId.call(this);
|
||||
}
|
||||
@@ -529,6 +586,9 @@ export class CockpitViewController {
|
||||
syncEntry() {
|
||||
return syncEntry.call(this);
|
||||
}
|
||||
refreshSubjectWords() {
|
||||
return refreshSubjectWords.call(this);
|
||||
}
|
||||
navigateContext(direction, options) {
|
||||
return navigateContext.call(this, direction, options);
|
||||
}
|
||||
@@ -568,6 +628,19 @@ export class CockpitViewController {
|
||||
presentLook() {
|
||||
return presentLook.call(this);
|
||||
}
|
||||
/** Whether the active view looks around: a bridge, or a provider's with `look`. */
|
||||
get looksAround() {
|
||||
return (
|
||||
this.active &&
|
||||
(this.subjectKind === 'vessel' || Boolean(this.subjectProvider?.look))
|
||||
);
|
||||
}
|
||||
/** Where the look rests: the bridge's pitch, or the provider's. */
|
||||
get lookRestPitch() {
|
||||
return Number.isFinite(this.lookRestPitchDeg)
|
||||
? this.lookRestPitchDeg
|
||||
: VESSEL_BRIDGE_PITCH_DEG;
|
||||
}
|
||||
/**
|
||||
* Turn the bridge view one arrow step (see `lookKeyStep`). The camera
|
||||
* eases there unless the user prefers reduced motion.
|
||||
@@ -575,7 +648,7 @@ export class CockpitViewController {
|
||||
* @returns {boolean} True when a bridge view was turned.
|
||||
*/
|
||||
turnLook(step) {
|
||||
if (!this.active || this.subjectKind !== 'vessel' || !step) return false;
|
||||
if (!this.looksAround || !step) return false;
|
||||
this.lookTarget = stepLook(this.lookTarget, step);
|
||||
if (prefersReducedMotion()) this.lookView = { ...this.lookTarget };
|
||||
this.presentLook();
|
||||
@@ -588,7 +661,7 @@ export class CockpitViewController {
|
||||
* as entry and exit do.
|
||||
*/
|
||||
resetLook({ immediate = false } = {}) {
|
||||
this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG };
|
||||
this.lookTarget = { yawDeg: 0, pitchDeg: this.lookRestPitch };
|
||||
if (immediate || prefersReducedMotion())
|
||||
this.lookView = { ...this.lookTarget };
|
||||
this.lookLastMs = performance.now();
|
||||
@@ -601,7 +674,7 @@ export class CockpitViewController {
|
||||
* @returns {boolean} True on a bridge.
|
||||
*/
|
||||
lookAhead() {
|
||||
if (!this.active || this.subjectKind !== 'vessel') return false;
|
||||
if (!this.looksAround) return false;
|
||||
const button = document.getElementById('cockpit-look-ahead');
|
||||
const hadFocus = !!button && button.contains(document.activeElement);
|
||||
this.resetLook();
|
||||
@@ -718,6 +791,9 @@ export class CockpitViewController {
|
||||
this.relocalizeBrief();
|
||||
this.publishVision(createVisionState(this.visionMode));
|
||||
this.routeSignature = null;
|
||||
// A provider's words, ridden or on the entry, follow the language.
|
||||
this.refreshSubjectWords();
|
||||
if (!this.active) this.syncEntry();
|
||||
if (this.active && this.lastSubjectInfo) {
|
||||
this.rewordingSignals = true;
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
bridgeEntryAllowed,
|
||||
cockpitEntryAllowed,
|
||||
orbitEntryAllowed,
|
||||
subjectEntryAllowed,
|
||||
} from '../contextModePolicy.js';
|
||||
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
|
||||
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
|
||||
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
||||
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
|
||||
@@ -60,8 +62,11 @@ export class CockpitCoordinator {
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
acquireOrbitImagery,
|
||||
cockpitSubjects,
|
||||
} = services;
|
||||
this._cockpitDisplayPortal = null;
|
||||
// The application's own subjects (services.cockpitSubjects), checked once.
|
||||
const subjectProviders = cockpitSubjectProviders(cockpitSubjects ?? []);
|
||||
this.cockpitView = new CockpitViewController(viewer, {
|
||||
services: {
|
||||
flightsLayer,
|
||||
@@ -108,6 +113,21 @@ export class CockpitCoordinator {
|
||||
vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'),
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
}),
|
||||
subjectProviders,
|
||||
// A provider's subject needs its own layer on, no Context change in
|
||||
// flight, and the provider's own say.
|
||||
isSubjectEntryAllowed: (provider, candidate) =>
|
||||
subjectEntryAllowed({
|
||||
layerEnabled: this._isLayerEnabled(provider.layerId),
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
providerAllows:
|
||||
this.cockpitView?.callSubject(
|
||||
provider,
|
||||
'isEntryAllowed',
|
||||
[candidate],
|
||||
false,
|
||||
) === true,
|
||||
}),
|
||||
isOrbitEntryAllowed: () =>
|
||||
orbitEntryAllowed({
|
||||
satellitesEnabled: !!this._dataManager?.isEnabled('satellites'),
|
||||
@@ -197,6 +217,9 @@ export class CockpitCoordinator {
|
||||
this.cockpitView.subjectKind === 'satellite'
|
||||
)
|
||||
return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit';
|
||||
// And in an application's own first-person view.
|
||||
if (this.cockpitView?.active && this.cockpitView.subjectProvider)
|
||||
return 'Cockpit is in another first-person view — exit it first, then track an aircraft to enter Cockpit';
|
||||
if (!this._aircraftLayerEnabled)
|
||||
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
|
||||
return null;
|
||||
@@ -216,6 +239,17 @@ export class CockpitCoordinator {
|
||||
active && this.cockpitView?.subjectKind === 'satellite'
|
||||
? this.cockpitView.readSatelliteInfo?.()
|
||||
: null;
|
||||
// And a provider's subject in use.
|
||||
const provided =
|
||||
active && this.cockpitView?.subjectProvider
|
||||
? {
|
||||
kind: this.cockpitView.subjectProvider.kind,
|
||||
id: this.cockpitView.subjectId,
|
||||
layerId: this.cockpitView.subjectProvider.layerId,
|
||||
callsign: null,
|
||||
name: this.cockpitView.lastSubjectFrame?.name ?? null,
|
||||
}
|
||||
: null;
|
||||
const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
|
||||
// "Could Cockpit be ENTERED right now" — so it is false while already
|
||||
// inside, unconditionally. Cockpit takes the entity off
|
||||
@@ -244,30 +278,32 @@ export class CockpitCoordinator {
|
||||
? 'subject-layer-off'
|
||||
: 'no-tracked-aircraft',
|
||||
visionMode: this.cockpitView?.visionMode || null,
|
||||
subject: satellite
|
||||
? {
|
||||
kind: 'satellite',
|
||||
id: satellite.id,
|
||||
layerId: satellite.layerId,
|
||||
callsign: null,
|
||||
name: satellite.name,
|
||||
}
|
||||
: vessel
|
||||
subject: provided
|
||||
? provided
|
||||
: satellite
|
||||
? {
|
||||
kind: 'vessel',
|
||||
id: vessel.id,
|
||||
layerId: vessel.layerId,
|
||||
kind: 'satellite',
|
||||
id: satellite.id,
|
||||
layerId: satellite.layerId,
|
||||
callsign: null,
|
||||
name: vessel.name,
|
||||
name: satellite.name,
|
||||
}
|
||||
: info
|
||||
: vessel
|
||||
? {
|
||||
kind: 'aircraft',
|
||||
id: info.icao24 || info.id || null,
|
||||
layerId: info.layerId || null,
|
||||
callsign: info.callsign || null,
|
||||
kind: 'vessel',
|
||||
id: vessel.id,
|
||||
layerId: vessel.layerId,
|
||||
callsign: null,
|
||||
name: vessel.name,
|
||||
}
|
||||
: null,
|
||||
: info
|
||||
? {
|
||||
kind: 'aircraft',
|
||||
id: info.icao24 || info.id || null,
|
||||
layerId: info.layerId || null,
|
||||
callsign: info.callsign || null,
|
||||
}
|
||||
: null,
|
||||
navigation: snapshot
|
||||
? {
|
||||
canPrevious: Boolean(snapshot.navigation?.canPrevious),
|
||||
|
||||
@@ -55,8 +55,11 @@ function fixture(
|
||||
changing = false,
|
||||
dataManager: suppliedManager = null,
|
||||
readContext = null,
|
||||
subjects = [],
|
||||
vessel = null,
|
||||
} = {},
|
||||
) {
|
||||
let selectedVessel = vessel;
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
@@ -101,7 +104,13 @@ function fixture(
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
|
||||
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
|
||||
aisLiveVesselsLayer: { getCockpitSubject: () => null },
|
||||
// Like the layer, no subject while Live AIS Vessels is off.
|
||||
aisLiveVesselsLayer: {
|
||||
getCockpitSubject: () =>
|
||||
selectedVessel && layers.has('ais-live-vessels')
|
||||
? { ...selectedVessel }
|
||||
: null,
|
||||
},
|
||||
// Like the layer, no subject while Satellites is off.
|
||||
satellitesLayer: {
|
||||
getCockpitSubject: () =>
|
||||
@@ -113,6 +122,7 @@ function fixture(
|
||||
isTr3b: () => false,
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
cockpitSubjects: subjects,
|
||||
},
|
||||
elements: {},
|
||||
operations: {
|
||||
@@ -147,6 +157,7 @@ function fixture(
|
||||
'cockpit-mode',
|
||||
'cockpit-bridge',
|
||||
'cockpit-orbit',
|
||||
'cockpit-subject',
|
||||
);
|
||||
});
|
||||
/** Turn a layer off and tell the Context owner, as the data manager does. */
|
||||
@@ -171,10 +182,35 @@ function fixture(
|
||||
viewer.trackedEntity = entityFor(subject);
|
||||
entities.add(viewer.trackedEntity);
|
||||
};
|
||||
/** Select a vessel on the map, or none. */
|
||||
const selectVessel = (info) => {
|
||||
selectedVessel = info;
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
|
||||
return {
|
||||
$,
|
||||
coordinator,
|
||||
cockpit,
|
||||
store,
|
||||
layers,
|
||||
disable,
|
||||
retrack,
|
||||
selectVessel,
|
||||
pinia,
|
||||
};
|
||||
}
|
||||
|
||||
/** A selected vessel as the AIS layer gives it to the bridge view. */
|
||||
const VESSEL = Object.freeze({
|
||||
id: '211000001',
|
||||
name: 'EXAMPLE',
|
||||
latitude: 54,
|
||||
longitude: 10,
|
||||
headingDeg: 90,
|
||||
courseDeg: 90,
|
||||
speedMps: 5,
|
||||
});
|
||||
|
||||
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
@@ -567,6 +603,32 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => {
|
||||
assert.match(readoutEntry.textContent, /BRIDGE/);
|
||||
});
|
||||
|
||||
test('without providers the built-in order decides: a vessel selected after an aircraft still reads COCKPIT', async (t) => {
|
||||
const { $, cockpit, store, selectVessel } = fixture(t, {
|
||||
enabled: ['flights', 'ais-live-vessels'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
selectVessel(VESSEL);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/);
|
||||
// And the other way round: an aircraft tracked after the vessel.
|
||||
const later = fixture(t, {
|
||||
enabled: ['flights', 'ais-live-vessels'],
|
||||
vessel: VESSEL,
|
||||
});
|
||||
later.cockpit.syncEntry();
|
||||
assert.equal(later.store.presence.entryKind, 'vessel');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
later.retrack({ layerId: 'flights', info: CIVIL });
|
||||
later.cockpit.syncEntry();
|
||||
assert.equal(later.store.presence.entryKind, 'aircraft');
|
||||
});
|
||||
|
||||
test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => {
|
||||
const { $, cockpit } = fixture(t, { satellite: SATELLITE });
|
||||
cockpit.syncEntry();
|
||||
@@ -648,3 +710,148 @@ test('turning Satellites off ends the orbital view', (t) => {
|
||||
disable('satellites');
|
||||
assert.equal(cockpit.active, false);
|
||||
});
|
||||
|
||||
/** An application provider whose subject sits on `example-layer`. */
|
||||
function exampleSubject() {
|
||||
const released = [];
|
||||
return {
|
||||
released,
|
||||
provider: {
|
||||
kind: 'example-kind',
|
||||
layerId: 'example-layer',
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
labels: { view: 'Example view', exit: 'Exit example', exitText: 'EXIT' },
|
||||
candidate: () => ({ id: 'a1', name: 'Example 12' }),
|
||||
hold: () => true,
|
||||
read: () => ({
|
||||
id: 'a1',
|
||||
name: 'Example 12',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
heightM: 200,
|
||||
headingDeg: 90,
|
||||
eye: { upM: 2 },
|
||||
}),
|
||||
hud: () => ({ title: 'Example 12' }),
|
||||
release: (id, { restore }) => released.push([id, restore]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a provider's subject needs only its own layer, and never a Context change in flight", async (t) => {
|
||||
const { provider } = exampleSubject();
|
||||
let changing = false;
|
||||
const off = fixture(t, { subjects: [provider] });
|
||||
off.cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(off.$('#cockpit-entry').hidden, true, 'its layer is off');
|
||||
assert.equal(off.cockpit.enter(), false);
|
||||
const on = fixture(t, {
|
||||
enabled: ['example-layer'],
|
||||
subjects: [provider],
|
||||
readContext: () => ({ mode: null, changing }),
|
||||
});
|
||||
changing = true;
|
||||
on.cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(on.$('#cockpit-entry').hidden, true, 'Context is changing');
|
||||
changing = false;
|
||||
on.cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(on.$('#cockpit-entry').hidden, false);
|
||||
assert.match(on.$('#cockpit-entry').textContent, /RIDE/);
|
||||
assert.match(on.$('#cockpit-entry-readout').textContent, /commute/);
|
||||
});
|
||||
|
||||
test("a provider's view reports its subject, refuses voice entry and ends with its layer", async (t) => {
|
||||
const { provider, released } = exampleSubject();
|
||||
const { cockpit, coordinator, disable } = fixture(t, {
|
||||
enabled: ['example-layer', 'flights'],
|
||||
subjects: [provider],
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
const state = coordinator.getCockpitState();
|
||||
assert.equal(state.active, true);
|
||||
assert.deepEqual(state.subject, {
|
||||
kind: 'example-kind',
|
||||
id: 'a1',
|
||||
layerId: 'example-layer',
|
||||
callsign: null,
|
||||
name: 'Example 12',
|
||||
});
|
||||
assert.equal(coordinator.controlCockpit('enter').ok, false);
|
||||
assert.match(coordinator._cockpitEntryRefusal(), /another first-person view/);
|
||||
disable('flights');
|
||||
assert.equal(cockpit.active, true, 'another layer is free to go off');
|
||||
disable('example-layer');
|
||||
assert.equal(cockpit.active, false, 'its own layer going off ends it');
|
||||
assert.deepEqual(released, [['a1', false]]);
|
||||
});
|
||||
|
||||
test("a provider's guards: a throwing candidate, gate or release, and a gate that is not true", async (t) => {
|
||||
const warned = [];
|
||||
t.mock.method(console, 'warn', (message) => warned.push(String(message)));
|
||||
const entered = (provider) => {
|
||||
const { cockpit } = fixture(t, {
|
||||
enabled: ['example-layer'],
|
||||
subjects: [provider],
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
return { cockpit, entered: cockpit.enter() };
|
||||
};
|
||||
const { provider: base } = exampleSubject();
|
||||
// A candidate() that throws offers nothing.
|
||||
const noCandidate = entered({
|
||||
...base,
|
||||
candidate: () => {
|
||||
throw new Error('candidate failed');
|
||||
},
|
||||
});
|
||||
assert.equal(noCandidate.entered, false);
|
||||
assert.equal(noCandidate.cockpit.resolveEntry(), null);
|
||||
// An isEntryAllowed() that throws, or answers anything but true, shuts
|
||||
// the gate.
|
||||
for (const isEntryAllowed of [
|
||||
() => {
|
||||
throw new Error('gate failed');
|
||||
},
|
||||
() => 1,
|
||||
() => 'yes',
|
||||
() => ({}),
|
||||
]) {
|
||||
const gated = entered({ ...base, isEntryAllowed });
|
||||
assert.equal(gated.entered, false, String(isEntryAllowed));
|
||||
assert.equal(gated.cockpit.active, false);
|
||||
}
|
||||
// A release() that throws still ends the view.
|
||||
const { provider } = exampleSubject();
|
||||
const releasing = entered({
|
||||
...provider,
|
||||
release: () => {
|
||||
throw new Error('release failed');
|
||||
},
|
||||
});
|
||||
assert.equal(releasing.entered, true);
|
||||
assert.equal(releasing.cockpit.exit(), true);
|
||||
assert.equal(releasing.cockpit.active, false);
|
||||
assert.equal(document.body.classList.contains('cockpit-subject'), false);
|
||||
for (const method of ['candidate', 'isEntryAllowed', 'release'])
|
||||
assert.ok(
|
||||
warned.some((line) => line.includes(`${method}() failed`)),
|
||||
method,
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed provider is left out and the built-in kinds are unchanged', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { cockpit } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
subjects: [{ kind: 'aircraft', layerId: 'flights' }],
|
||||
});
|
||||
assert.deepEqual(cockpit.subjectProviders, []);
|
||||
cockpit.syncEntry();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
assert.equal(cockpit.subjectKind, 'aircraft');
|
||||
});
|
||||
@@ -41,6 +41,9 @@ function subjectWorldPosition(kind) {
|
||||
}
|
||||
if (kind === 'satellite')
|
||||
return this.services.satellitesLayer?.getCockpitPosition?.() ?? null;
|
||||
// An application's subject has no position before its view is entered:
|
||||
// the selected vessel's sprite must not stand in for it.
|
||||
if (kind !== 'vessel') return null;
|
||||
const info = this.readVesselInfo();
|
||||
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
|
||||
return null;
|
||||
@@ -189,7 +192,7 @@ const LOOK_KEY_OWNERS = [
|
||||
* @returns {boolean} True when the key was taken.
|
||||
*/
|
||||
function handleLookKey(event) {
|
||||
if (!this.active || this.subjectKind !== 'vessel') return false;
|
||||
if (!this.looksAround) return false;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
const step = lookKeyStep(event.key, event.shiftKey);
|
||||
const reset = !event.repeat && (event.key === 'Home' || event.key === '0');
|
||||
|
||||
@@ -52,7 +52,7 @@ function createRimState(value) {
|
||||
*/
|
||||
export function createInstrumentState() {
|
||||
return {
|
||||
// 'aircraft' or 'vessel': which readouts the HUD labels and shows.
|
||||
// 'aircraft', 'vessel' or 'subject': which readouts the HUD shows.
|
||||
kind: 'aircraft',
|
||||
layer: null,
|
||||
callsign: t('AIRCRAFT'),
|
||||
@@ -76,6 +76,19 @@ export function createInstrumentState() {
|
||||
look: { offset: false, yawDeg: 0, readout: '' },
|
||||
clock: '--:--:--Z',
|
||||
position: '--° · --°',
|
||||
// A provider subject's HUD (kind 'subject', cockpitSubjectProviders.js):
|
||||
// its kicker, speed unit and tag, readouts, sources, banner, veil and
|
||||
// progress line, all worded by the provider.
|
||||
kicker: '',
|
||||
speedUnit: '',
|
||||
speedTag: '',
|
||||
speedTone: 'normal',
|
||||
readouts: [],
|
||||
sources: [],
|
||||
banner: null,
|
||||
veil: null,
|
||||
progress: null,
|
||||
lookEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,8 +99,10 @@ export function createInstrumentState() {
|
||||
*/
|
||||
export function createRouteState() {
|
||||
return {
|
||||
// A vessel's card shows only the free-text AIS destination.
|
||||
// A vessel's card shows only the free-text AIS destination; a provider's
|
||||
// subject's (kind 'subject') carries its own title.
|
||||
kind: 'aircraft',
|
||||
title: '',
|
||||
visible: false,
|
||||
from: t('UNKNOWN'),
|
||||
to: t('UNKNOWN'),
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
vesselCameraProfile,
|
||||
} from '../cockpitSubjects.js';
|
||||
import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js';
|
||||
import { cockpitSubjectHudView } from '../cockpitSubjectProviders.js';
|
||||
import {
|
||||
RADAR_MAX_CONTACTS,
|
||||
radarPicture,
|
||||
@@ -90,6 +91,13 @@ export function publishInstrumentsView() {
|
||||
compass: view.compass.map((division) => ({ ...division })),
|
||||
compassMarkers: { ...view.compassMarkers },
|
||||
look: { ...view.look },
|
||||
// A provider's view model is rebuilt on each HUD update, never edited
|
||||
// in place, so it is published as it stands.
|
||||
readouts: view.readouts,
|
||||
sources: view.sources,
|
||||
banner: view.banner,
|
||||
veil: view.veil,
|
||||
progress: view.progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,24 +126,162 @@ function wordLook({ side, bearingDeg, elevation, angleDeg }) {
|
||||
*/
|
||||
export function presentLook() {
|
||||
const target = this.lookTarget;
|
||||
// A provider's view words its own readout (its HUD view's `lookReadout`).
|
||||
const provider = Boolean(this.subjectProvider);
|
||||
const rest = provider ? this.lookRestPitch : VESSEL_BRIDGE_PITCH_DEG;
|
||||
const offset =
|
||||
this.active &&
|
||||
this.subjectKind === 'vessel' &&
|
||||
lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG);
|
||||
(this.active && this.subjectKind === 'vessel') ||
|
||||
(provider && this.looksAround)
|
||||
? lookIsOffset(target, rest)
|
||||
: false;
|
||||
this.instruments.look = offset
|
||||
? {
|
||||
offset: true,
|
||||
yawDeg: wrapLookYaw(target.yawDeg),
|
||||
readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)),
|
||||
readout: provider
|
||||
? (this.subjectHud?.lookReadout ?? '')
|
||||
: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)),
|
||||
}
|
||||
: { offset: false, yawDeg: 0, readout: '' };
|
||||
}
|
||||
|
||||
/** Live Signals' tone for a provider's signal row. */
|
||||
const SUBJECT_SIGNAL_TONES = Object.freeze({
|
||||
warning: 'warning',
|
||||
alert: 'warning',
|
||||
});
|
||||
|
||||
/**
|
||||
* Present a provider subject's HUD: its view model (cockpitSubjectHudView)
|
||||
* in the generic readouts, the compass at the camera's heading, the route
|
||||
* card, and its signals in Live Signals. A HUD view that is null, a `hud()`
|
||||
* that throws, or a view whose reading throws keeps the last one. Contact
|
||||
* navigation stays with aircraft, so the Contact card is withdrawn.
|
||||
*/
|
||||
function updateSubjectHud(info, nowMs, forceContext) {
|
||||
const provider = this.subjectProvider;
|
||||
this.lastSubjectInfo = info;
|
||||
const frame = this.lastSubjectFrame;
|
||||
const look = {
|
||||
yawDeg: this.lookTarget?.yawDeg ?? 0,
|
||||
pitchDeg: this.lookTarget?.pitchDeg ?? 0,
|
||||
offset:
|
||||
this.looksAround && lookIsOffset(this.lookTarget, this.lookRestPitch),
|
||||
};
|
||||
const raw = frame
|
||||
? this.callSubject(
|
||||
provider,
|
||||
'hud',
|
||||
[frame, Object.freeze({ ...this.subjectContext(), look })],
|
||||
null,
|
||||
)
|
||||
: null;
|
||||
if (raw != null)
|
||||
this.subjectHud = cockpitSubjectHudView(raw) ?? this.subjectHud;
|
||||
const hud = this.subjectHud ?? cockpitSubjectHudView(null);
|
||||
const view = this.instruments;
|
||||
view.kind = 'subject';
|
||||
view.trackSource = null;
|
||||
view.kicker = hud.kicker || provider.labels.kicker;
|
||||
view.callsign = hud.title || info.name || '';
|
||||
// While the floor under a subject with no height is acquired
|
||||
// (cockpitCamera.js), the meta line says so in place of the provider's.
|
||||
const acquiring = this.surfaceAcquiring === true;
|
||||
view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text;
|
||||
view.meta.tone = acquiring ? 'warning' : hud.meta.tone;
|
||||
view.meta.emergency = !acquiring && hud.meta.tone === 'alert';
|
||||
const speed = hud.speed?.value ?? null;
|
||||
view.speed = hud.speed
|
||||
? {
|
||||
text: speed === null ? '---' : formatNumber(Math.round(speed)),
|
||||
value: speed === null ? null : Math.round(speed),
|
||||
immediate: forceContext,
|
||||
}
|
||||
: null;
|
||||
view.speedUnit = hud.speed?.unit ?? '';
|
||||
view.speedTag = hud.speed?.tag ?? '';
|
||||
view.speedTone = hud.speed?.tone ?? 'normal';
|
||||
view.speedRim.unavailable = true;
|
||||
view.altitude = null;
|
||||
view.altitudeRim.unavailable = true;
|
||||
view.course = null;
|
||||
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
||||
view.heading = {
|
||||
text: formatHeadingReadout(heading),
|
||||
value: heading,
|
||||
immediate: forceContext,
|
||||
};
|
||||
const viewHeading = normalizeHeading(
|
||||
heading + (provider.look ? (this.lookView?.yawDeg ?? 0) : 0),
|
||||
);
|
||||
view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({
|
||||
slot: division.slot,
|
||||
heading: division.heading,
|
||||
active: division.nearest,
|
||||
label: compassPointLabel(formatCompassDivision(division.heading)),
|
||||
}));
|
||||
view.compassMarkers = {
|
||||
prograde: compassMarkerSlot(heading, viewHeading),
|
||||
retrograde: compassMarkerSlot(heading + 180, viewHeading),
|
||||
};
|
||||
view.clock = new Date().toISOString().slice(11, 19) + 'Z';
|
||||
view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`;
|
||||
view.readouts = hud.readouts;
|
||||
view.sources = hud.sources;
|
||||
view.banner = hud.banner;
|
||||
view.veil = hud.veil;
|
||||
view.progress = hud.progress;
|
||||
view.lookEnabled = Boolean(provider.look);
|
||||
view.layer = provider.layerId;
|
||||
this.presentLook();
|
||||
this.publishInstrumentsView();
|
||||
const route = this.routeView;
|
||||
route.kind = 'subject';
|
||||
route.title = hud.route?.title ?? '';
|
||||
route.from = hud.route?.from ?? '';
|
||||
route.to = hud.route?.to ?? '';
|
||||
route.status = hud.route?.status ?? '';
|
||||
route.visible = Boolean(hud.route);
|
||||
// No destination arrow; clearing also publishes the card.
|
||||
this.clearPredictiveRoute();
|
||||
if (
|
||||
forceContext ||
|
||||
cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastContextUpdateMs,
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
)
|
||||
) {
|
||||
this.lastContextUpdateMs = nowMs;
|
||||
this.updateLocalPosition(info);
|
||||
this.maybeRefreshRegionalBrief(info);
|
||||
this.showContextCard(false);
|
||||
const keys = new Set();
|
||||
for (const signal of hud.signals) {
|
||||
const key = `subject:${signal.id}`;
|
||||
keys.add(key);
|
||||
this.pushCockpitSignal(
|
||||
key,
|
||||
SUBJECT_SIGNAL_TONES[signal.tone] ?? 'info',
|
||||
signal.title,
|
||||
signal.detail || signal.title,
|
||||
);
|
||||
}
|
||||
for (const key of this.subjectSignalKeys ?? [])
|
||||
if (!keys.has(key)) this.dropCockpitSignal(key);
|
||||
this.subjectSignalKeys = keys;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateHud(
|
||||
info,
|
||||
nowMs = performance.now(),
|
||||
forceContext = false,
|
||||
) {
|
||||
if (this.subjectProvider && info?.generic === true) {
|
||||
updateSubjectHud.call(this, info, nowMs, forceContext);
|
||||
return;
|
||||
}
|
||||
if (info?.kind === 'vessel') {
|
||||
updateVesselHud.call(this, info, nowMs, forceContext);
|
||||
return;
|
||||
|
||||
@@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => {
|
||||
assert.equal(store.orbital, null, 'no HUD comes back for a stale answer');
|
||||
});
|
||||
|
||||
test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
|
||||
test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => {
|
||||
const aircraftInfo = {
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
@@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
|
||||
};
|
||||
const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo });
|
||||
viewer.trackedEntity = aircraft;
|
||||
// The satellite and the aircraft are both new to this sync; the fixed order
|
||||
// decides between subjects first seen together. Selected one after the
|
||||
// other, the later one takes entry (cockpitEntryGate.test.mjs).
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
});
|
||||
|
||||
@@ -184,11 +184,15 @@ const COCKPIT_ENTRY_FACES = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* The entry's glyph and localized label for the subject it would ride.
|
||||
* @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind
|
||||
* The entry's glyph and localized label for the subject it would ride. An
|
||||
* application provider's subject brings its own face, already in the page's
|
||||
* language (`presence.entryFace`).
|
||||
* @param {string|null|undefined} kind
|
||||
* @param {{icon: string, label: string}|null} [face]
|
||||
* @returns {{icon: string, label: string}}
|
||||
*/
|
||||
export function cockpitEntryFace(kind) {
|
||||
const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
|
||||
return { icon: face.icon, label: t(face.label) };
|
||||
export function cockpitEntryFace(kind, face = null) {
|
||||
if (face?.icon && face?.label) return { icon: face.icon, label: face.label };
|
||||
const known = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
|
||||
return { icon: known.icon, label: t(known.label) };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,10 @@ import {
|
||||
normalizeHeading,
|
||||
resolveTrackedAircraftInfo,
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
cockpitSubjectCandidate,
|
||||
cockpitSubjectFrame,
|
||||
} from '../cockpitSubjectProviders.js';
|
||||
|
||||
/** The camera's own heading in degrees, or 0 when it has none. */
|
||||
function cameraHeadingDeg(camera) {
|
||||
@@ -33,15 +37,187 @@ export function readSatelliteInfo() {
|
||||
return this.services.satellitesLayer?.getCockpitSubject?.() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an application subject provider would have entry ride now, or null.
|
||||
* @param {object} provider A provider from cockpitSubjectProviders.
|
||||
* @returns {{id: string, name: string, face: object|null}|null}
|
||||
*/
|
||||
export function readSubjectCandidate(provider) {
|
||||
return cockpitSubjectCandidate(
|
||||
this.callSubject(provider, 'candidate', [], null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a provider's `read` and `hud` receive besides the frame: the shared
|
||||
* ground floor at a point (metres, or null), a way to warm its cells, and the
|
||||
* floor service's lift.
|
||||
*/
|
||||
export function subjectContext() {
|
||||
this._subjectContext ??= Object.freeze({
|
||||
groundHeightAt: (latitude, longitude) => {
|
||||
const height = this.services.cachedGroundFloor?.(latitude, longitude);
|
||||
return Number.isFinite(height) ? height : null;
|
||||
},
|
||||
warmGround: (points) => {
|
||||
this.services.warmGroundFloor?.(points);
|
||||
},
|
||||
liftM: Number.isFinite(this.services.GROUND_FLOOR_LIFT_M)
|
||||
? this.services.GROUND_FLOOR_LIFT_M
|
||||
: 0,
|
||||
});
|
||||
return this._subjectContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ridden provider subject as the camera and HUD read it: the frame's
|
||||
* place and motion, named like the built-in kinds' info.
|
||||
* @param {object} frame From cockpitSubjectFrame.
|
||||
*/
|
||||
export function subjectInfo(frame) {
|
||||
return {
|
||||
kind: this.subjectProvider?.kind ?? null,
|
||||
generic: true,
|
||||
layerId: this.subjectProvider?.layerId ?? null,
|
||||
id: frame.id,
|
||||
name: frame.name,
|
||||
latitude: frame.latitude,
|
||||
longitude: frame.longitude,
|
||||
track: frame.headingDeg,
|
||||
motionCourse: frame.courseDeg,
|
||||
velocityMps: frame.speedMps ?? 0,
|
||||
stale: frame.stale,
|
||||
groundClamp: frame.groundClamp,
|
||||
anchor: frame.anchor,
|
||||
onGround: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The built-in kinds' fixed entry order. */
|
||||
const BUILT_IN_ENTRY_ORDER = Object.freeze({
|
||||
aircraft: 0,
|
||||
satellite: 1,
|
||||
vessel: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Every subject entry could ride now, in entry order: a tracked aircraft,
|
||||
* the tracked satellite, the selected vessel and each provider's candidate.
|
||||
* The built-in kinds keep their fixed order (aircraft, satellite, vessel),
|
||||
* whenever each was selected. A provider's candidate goes ahead of them only
|
||||
* when it was selected after every built-in one present: a train picked
|
||||
* after an aircraft was tracked takes entry, an aircraft tracked after it
|
||||
* takes it back. Providers' candidates are ordered most recently selected
|
||||
* first, then by priority. A candidate's time is when this controller first
|
||||
* saw it (it looks on its entry cadence and on tracking changes), or saw it
|
||||
* selected again: another subject of a built-in kind, a provider candidate
|
||||
* whose `selectedAt` changes, or one without a `selectedAt` whose id changes.
|
||||
* A new id under the same `selectedAt` is the same selection, as when a
|
||||
* train's selection moves to another feed's copy of it. One
|
||||
* first seen together with a built-in candidate comes after it. With no
|
||||
* provider, entry is the built-in order alone. Each carries `allowed()`, its
|
||||
* kind's entry gate.
|
||||
* @param {number} [nowMs]
|
||||
* @returns {Array<{kind: string, id: string, seenAtMs: number, order: number,
|
||||
* allowed: () => boolean, provider?: object, candidate?: object}>}
|
||||
*/
|
||||
export function entryCandidates(nowMs = performance.now()) {
|
||||
const seen = (this._entrySeen ??= new Map());
|
||||
const list = [];
|
||||
const note = (kind, id, entry, selectedAt = null) => {
|
||||
const last = seen.get(kind);
|
||||
// A provider's token says when its subject was selected: a new id under
|
||||
// the same token (a train handed to another feed's copy of it) is not a
|
||||
// new selection. Without a token, a new id is.
|
||||
if (
|
||||
!last ||
|
||||
last.selectedAt !== selectedAt ||
|
||||
(selectedAt === null && last.id !== id)
|
||||
)
|
||||
seen.set(kind, { id, selectedAt, atMs: nowMs });
|
||||
else if (last.id !== id) seen.set(kind, { ...last, id });
|
||||
list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry });
|
||||
};
|
||||
const aircraft = this.readAircraftInfo();
|
||||
if (aircraft && this.viewer.trackedEntity?.position)
|
||||
note(
|
||||
'aircraft',
|
||||
String(
|
||||
this.viewer.trackedEntity.gevTrackedId ||
|
||||
aircraft.icao24 ||
|
||||
aircraft.id ||
|
||||
'aircraft',
|
||||
),
|
||||
{
|
||||
order: BUILT_IN_ENTRY_ORDER.aircraft,
|
||||
allowed: () => this.isEntryAllowed(),
|
||||
},
|
||||
);
|
||||
const satellite = this.readSatelliteInfo();
|
||||
if (satellite)
|
||||
note('satellite', String(satellite.noradId ?? satellite.id ?? ''), {
|
||||
order: BUILT_IN_ENTRY_ORDER.satellite,
|
||||
allowed: () => this.isOrbitEntryAllowed(),
|
||||
});
|
||||
const vessel = this.readVesselInfo();
|
||||
if (vessel)
|
||||
note('vessel', String(vessel.id ?? ''), {
|
||||
order: BUILT_IN_ENTRY_ORDER.vessel,
|
||||
allowed: () => this.isBridgeEntryAllowed(),
|
||||
});
|
||||
this.subjectProviders.forEach((provider, index) => {
|
||||
const candidate = this.readSubjectCandidate(provider);
|
||||
if (candidate)
|
||||
note(
|
||||
provider.kind,
|
||||
candidate.id,
|
||||
{
|
||||
order: 3 + index,
|
||||
provider,
|
||||
candidate,
|
||||
allowed: () => this.isSubjectEntryAllowed(provider, candidate),
|
||||
},
|
||||
candidate.selectedAt,
|
||||
);
|
||||
});
|
||||
// A subject gone and back counts as selected again.
|
||||
for (const kind of [...seen.keys()])
|
||||
if (!list.some((entry) => entry.kind === kind)) seen.delete(kind);
|
||||
const builtIns = list
|
||||
.filter((entry) => !entry.provider)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const provided = list
|
||||
.filter((entry) => entry.provider)
|
||||
.sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order);
|
||||
const builtInSeenAtMs = Math.max(
|
||||
-Infinity,
|
||||
...builtIns.map((entry) => entry.seenAtMs),
|
||||
);
|
||||
const later = provided.filter((entry) => entry.seenAtMs > builtInSeenAtMs);
|
||||
const earlier = provided.filter((entry) => entry.seenAtMs <= builtInSeenAtMs);
|
||||
return [...later, ...builtIns, ...earlier];
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject entry would ride: the first in entry order (entryCandidates).
|
||||
* It decides alone, so when its own gate is shut entry is unavailable rather
|
||||
* than taken by the next one.
|
||||
* @returns {object|null} An entry of entryCandidates, or null.
|
||||
*/
|
||||
export function resolveEntry(nowMs = performance.now()) {
|
||||
return this.entryCandidates(nowMs)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer the active view rides: Live AIS Vessels on a bridge, Satellites
|
||||
* in orbit, else the aircraft's own layer. It is read from the tracked
|
||||
* entity's id once the layer has dropped its tracking, so a layer turned off
|
||||
* still names itself.
|
||||
* @returns {'flights'|'military'|'ais-live-vessels'|'satellites'|null}
|
||||
* in orbit, a provider's own layer, else the aircraft's own layer. It is read
|
||||
* from the tracked entity's id once the layer has dropped its tracking, so a
|
||||
* layer turned off still names itself.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function readSubjectLayerId() {
|
||||
if (!this.active) return null;
|
||||
if (this.subjectProvider) return this.subjectProvider.layerId;
|
||||
if (this.subjectKind === 'vessel') return 'ais-live-vessels';
|
||||
if (this.subjectKind === 'satellite') return 'satellites';
|
||||
const layerId = this.readAircraftInfo()?.layerId;
|
||||
@@ -51,23 +227,20 @@ export function readSubjectLayerId() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject entry would take: a Cesium-tracked aircraft first, then the
|
||||
* tracked satellite, else the selected vessel. Vessels never take Cesium
|
||||
* tracking, so an aircraft and a vessel cannot be live through the same owner.
|
||||
* @returns {'aircraft'|'satellite'|'vessel'|null}
|
||||
* The kind entry would take, the first in entry order's (resolveEntry): a
|
||||
* Cesium-tracked aircraft, the tracked satellite, the selected vessel or a
|
||||
* provider's candidate.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveEntryKind() {
|
||||
if (this.readAircraftInfo() && this.viewer.trackedEntity?.position)
|
||||
return 'aircraft';
|
||||
if (this.readSatelliteInfo()) return 'satellite';
|
||||
return this.readVesselInfo() ? 'vessel' : null;
|
||||
return this.resolveEntry()?.kind ?? null;
|
||||
}
|
||||
|
||||
export function dispatchCockpitModeChanged(active, info = null) {
|
||||
const subjectId = active
|
||||
? String(
|
||||
info?.icao24 ||
|
||||
(info?.kind === 'vessel' ? info.id : '') ||
|
||||
(info?.kind === 'vessel' || info?.generic === true ? info.id : '') ||
|
||||
info?.noradId ||
|
||||
'',
|
||||
)
|
||||
@@ -76,9 +249,13 @@ export function dispatchCockpitModeChanged(active, info = null) {
|
||||
: null;
|
||||
const layerId =
|
||||
active &&
|
||||
['flights', 'military', 'ais-live-vessels', 'satellites'].includes(
|
||||
(['flights', 'military', 'ais-live-vessels', 'satellites'].includes(
|
||||
info?.layerId,
|
||||
)
|
||||
) ||
|
||||
(info?.generic === true &&
|
||||
this.subjectProviders.some(
|
||||
(provider) => provider.layerId === info.layerId,
|
||||
)))
|
||||
? info.layerId
|
||||
: null;
|
||||
window.dispatchEvent(
|
||||
@@ -127,40 +304,60 @@ export function syncTr3bToggle(info) {
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the ridden provider subject's labels and Look ahead button again,
|
||||
* after a change of the page's language: a provider may give them as
|
||||
* functions (cockpitSubjectProviders).
|
||||
*/
|
||||
export function refreshSubjectWords() {
|
||||
const provider = this.subjectProvider;
|
||||
if (!this.active || !provider) return;
|
||||
this.presence.subjectLabels = { ...provider.labels };
|
||||
const look = provider.look;
|
||||
this.presence.subjectLook = look ? { aheadLabel: look.aheadLabel } : null;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function syncEntry() {
|
||||
if (this.destroyed) return false;
|
||||
if (this.active) return;
|
||||
const info = this.readAircraftInfo();
|
||||
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
|
||||
this.syncTr3bToggle(trackedContact ? info : null);
|
||||
const orbitContact = !trackedContact && !!this.readSatelliteInfo();
|
||||
const bridgeContact =
|
||||
!trackedContact && !orbitContact && !!this.readVesselInfo();
|
||||
let entryKind = null;
|
||||
if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null;
|
||||
else if (orbitContact)
|
||||
entryKind = this.isOrbitEntryAllowed() ? 'satellite' : null;
|
||||
else if (bridgeContact)
|
||||
entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null;
|
||||
const candidates = this.entryCandidates(performance.now());
|
||||
const tracked = candidates.some(({ kind }) => kind === 'aircraft');
|
||||
this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null);
|
||||
// The first in entry order decides alone (resolveEntry).
|
||||
const chosen = candidates[0] ?? null;
|
||||
const entryKind = chosen?.allowed() ? chosen.kind : null;
|
||||
const face =
|
||||
entryKind && chosen.provider
|
||||
? (chosen.candidate.face ?? chosen.provider.entryFace)
|
||||
: null;
|
||||
const available = entryKind !== null;
|
||||
// Change-only publishes: this runs on a preUpdate cadence, and a fresh
|
||||
// presence every frame would re-render the views even when nothing
|
||||
// changed. (perf item 9)
|
||||
if (
|
||||
this.presence.entryAvailable === available &&
|
||||
this.presence.entryKind === entryKind
|
||||
this.presence.entryKind === entryKind &&
|
||||
this.presence.entryFace?.icon === face?.icon &&
|
||||
this.presence.entryFace?.label === face?.label
|
||||
)
|
||||
return;
|
||||
this.presence.entryAvailable = available;
|
||||
this.presence.entryKind = entryKind;
|
||||
this.presence.entryFace = face && { icon: face.icon, label: face.label };
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function navigateContext(direction, options = {}) {
|
||||
if (this.destroyed) return false;
|
||||
// Contact navigation moves between aircraft; a bridge does not jump ship
|
||||
// and an orbital view stays with its satellite.
|
||||
if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite')
|
||||
// Contact navigation moves between aircraft; a bridge does not jump ship,
|
||||
// an orbital view stays with its satellite and a provider's view with its
|
||||
// subject.
|
||||
if (
|
||||
this.subjectKind === 'vessel' ||
|
||||
this.subjectKind === 'satellite' ||
|
||||
this.subjectProvider
|
||||
)
|
||||
return false;
|
||||
const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
|
||||
const wasActive = this.active;
|
||||
@@ -180,9 +377,14 @@ export function navigateContext(direction, options = {}) {
|
||||
}
|
||||
|
||||
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
||||
// An aircraft tracked from the bridge or from orbit ends that view (see the
|
||||
// vessel and orbital camera updates); it is never adopted as its subject.
|
||||
if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite')
|
||||
// An aircraft tracked from the bridge, from orbit or from a provider's view
|
||||
// ends that view (see the camera updates); it is never adopted as its
|
||||
// subject.
|
||||
if (
|
||||
this.subjectKind === 'vessel' ||
|
||||
this.subjectKind === 'satellite' ||
|
||||
this.subjectProvider
|
||||
)
|
||||
return false;
|
||||
const nextEntity = this.viewer.trackedEntity;
|
||||
if (
|
||||
@@ -213,16 +415,153 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter first person on a tracked aircraft or, with none tracked, the
|
||||
* tracked satellite's orbital view or the selected vessel's bridge.
|
||||
* @param {{kind?: 'aircraft'|'satellite'|'vessel'|null}} [options] Restrict
|
||||
* entry to one subject kind; voice entry is aircraft-only.
|
||||
* Enter a provider's view on its candidate: hold it, read a first frame, then
|
||||
* take the camera as the built-in views do. Entry fails when the gate is shut,
|
||||
* the hold answers anything but `true` or no frame comes; past the gate, a
|
||||
* failed entry releases the subject, since the hold may have taken it.
|
||||
* @param {object} provider From cockpitSubjectProviders.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function enterSubject(provider) {
|
||||
const candidate = this.readSubjectCandidate(provider);
|
||||
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
|
||||
return false;
|
||||
const failed = {};
|
||||
const release = () =>
|
||||
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
|
||||
// An asynchronous hold may land after it was refused: it is released
|
||||
// again once it settles, unless this subject is being ridden by then.
|
||||
const releaseLate = () => {
|
||||
if (
|
||||
this.active &&
|
||||
this.subjectProvider === provider &&
|
||||
this.subjectId === candidate.id
|
||||
)
|
||||
return;
|
||||
release();
|
||||
};
|
||||
const held = this.callSubject(
|
||||
provider,
|
||||
'hold',
|
||||
[candidate.id],
|
||||
failed,
|
||||
releaseLate,
|
||||
);
|
||||
if (held !== true) {
|
||||
// `false` refuses entry; a hold that threw, returned a promise or
|
||||
// answered anything else (warned of once) may still have taken the
|
||||
// subject. Each is released.
|
||||
if (held !== false && held !== failed)
|
||||
this.callSubject.warnOnce(
|
||||
provider,
|
||||
'hold',
|
||||
'must return true or false; entry refused',
|
||||
held,
|
||||
);
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
const frame = cockpitSubjectFrame(
|
||||
this.callSubject(
|
||||
provider,
|
||||
'read',
|
||||
[performance.now(), this.subjectContext()],
|
||||
null,
|
||||
),
|
||||
candidate.id,
|
||||
);
|
||||
if (!frame) {
|
||||
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
|
||||
return false;
|
||||
}
|
||||
this.entryFocusOrigin =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
this.onCameraTakeover?.();
|
||||
this.viewer.camera.cancelFlight();
|
||||
this.subjectKind = provider.kind;
|
||||
this.subjectProvider = provider;
|
||||
this.subjectId = candidate.id;
|
||||
this.lastSubjectFrame = frame;
|
||||
this.subjectHud = null;
|
||||
this.subjectSignalKeys = new Set();
|
||||
this.vesselWaterline = null;
|
||||
this.vesselFix = null;
|
||||
this.trackedEntity = null;
|
||||
this.heading = normalizeHeading(
|
||||
frame.headingDeg ?? cameraHeadingDeg(this.viewer.camera),
|
||||
);
|
||||
this.lastFrameMs = performance.now();
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.lastHudUpdateMs = 0;
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.lastGroundProbeMs = 0;
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
this.cockpitAnchorValid = false;
|
||||
this.subjectHeightM = null;
|
||||
this.lookRestPitchDeg = provider.look?.restPitchDeg ?? null;
|
||||
this.resetLook({ immediate: true });
|
||||
this.active = true;
|
||||
this.services.holdContinuousRender('cockpit');
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
|
||||
document.body.classList.add('cockpit-mode', 'cockpit-subject');
|
||||
this._tr3bSignature = null;
|
||||
Object.assign(this.presence, {
|
||||
active: true,
|
||||
entryAvailable: false,
|
||||
entryKind: null,
|
||||
entryFace: null,
|
||||
subjectKind: provider.kind,
|
||||
subjectGeneric: true,
|
||||
subjectLabels: { ...provider.labels },
|
||||
subjectLook: provider.look
|
||||
? { aheadLabel: provider.look.aheadLabel }
|
||||
: null,
|
||||
tr3b: null,
|
||||
});
|
||||
this.publishPresenceView();
|
||||
this.publishRadar(null);
|
||||
this.signalItems = [];
|
||||
this.signalSignatures.clear();
|
||||
this.showBriefPage(0);
|
||||
this.startBriefRotation();
|
||||
this.pushCockpitSignal('track', 'track', t('TRACK ACQUIRED'), frame.name);
|
||||
const info = this.subjectInfo(frame);
|
||||
this.updateHud(info, performance.now(), true);
|
||||
this.setVisionMode(this.getInitialVisionMode());
|
||||
this.scheduleContextLayout();
|
||||
Promise.resolve().then(() => {
|
||||
if (this.active && !this.destroyed)
|
||||
this.mapViewButton?.focus({ preventScroll: true });
|
||||
});
|
||||
this.onEntered?.();
|
||||
this.onRide?.(provider.layerId);
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter first person on the first subject in entry order (resolveEntry): a
|
||||
* tracked aircraft, the tracked satellite's orbital view, the selected
|
||||
* vessel's bridge or an application provider's subject.
|
||||
* @param {{kind?: string|null}} [options] Restrict entry to one subject kind;
|
||||
* voice entry is aircraft-only.
|
||||
* @returns {boolean} True when the view was entered.
|
||||
*/
|
||||
export function enter({ kind = null } = {}) {
|
||||
if (this.destroyed) return false;
|
||||
if (this.active) return false;
|
||||
const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft';
|
||||
const provider = this.subjectProviders.find(
|
||||
(entry) => entry.kind === subjectKind,
|
||||
);
|
||||
if (provider) return enterSubject.call(this, provider);
|
||||
if (!['aircraft', 'satellite', 'vessel'].includes(subjectKind)) return false;
|
||||
const vessel = subjectKind === 'vessel';
|
||||
const satellite = subjectKind === 'satellite';
|
||||
const allowed = vessel
|
||||
@@ -371,11 +710,17 @@ export function enter({ kind = null } = {}) {
|
||||
export function exit({ restoreTracking = true } = {}) {
|
||||
if (!this.active) return false;
|
||||
const entity = this.trackedEntity;
|
||||
const provider = this.subjectProvider;
|
||||
const providerSubject = provider ? this.subjectId : null;
|
||||
const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null;
|
||||
const satelliteSubject =
|
||||
this.subjectKind === 'satellite' ? this.subjectId : null;
|
||||
this.active = false;
|
||||
this.subjectKind = null;
|
||||
this.subjectProvider = null;
|
||||
this.lastSubjectFrame = null;
|
||||
this.subjectHud = null;
|
||||
this.lookRestPitchDeg = null;
|
||||
this.vesselWaterline = null;
|
||||
this.vesselFix = null;
|
||||
this.subjectId = null;
|
||||
@@ -399,6 +744,7 @@ export function exit({ restoreTracking = true } = {}) {
|
||||
'cockpit-mode',
|
||||
'cockpit-bridge',
|
||||
'cockpit-orbit',
|
||||
'cockpit-subject',
|
||||
);
|
||||
// The camera's own field of view returns before any follow resumes.
|
||||
this.endOrbital();
|
||||
@@ -406,6 +752,9 @@ export function exit({ restoreTracking = true } = {}) {
|
||||
this.publishUtilityLayout(null);
|
||||
this.presence.active = false;
|
||||
this.presence.subjectKind = null;
|
||||
this.presence.subjectGeneric = false;
|
||||
this.presence.subjectLabels = null;
|
||||
this.presence.subjectLook = null;
|
||||
this.publishPresenceView();
|
||||
this.routeView.visible = false;
|
||||
this.clearPredictiveRoute();
|
||||
@@ -419,6 +768,13 @@ export function exit({ restoreTracking = true } = {}) {
|
||||
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null);
|
||||
if (satelliteSubject !== null)
|
||||
this.services.satellitesLayer?.setCockpitSubject?.(null);
|
||||
// The provider gives its subject back; `restore` asks it to bring the map
|
||||
// back to it, as the built-in kinds' refocus does.
|
||||
if (provider)
|
||||
this.callSubject(provider, 'release', [
|
||||
providerSubject,
|
||||
{ restore: restoreTracking === true },
|
||||
]);
|
||||
this.dispatchCockpitModeChanged(false);
|
||||
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
|
||||
this.viewer.trackedEntity = entity;
|
||||
|
||||
@@ -51,6 +51,8 @@ export class ContextControls {
|
||||
this._contextRestoreState = null;
|
||||
this._contextLayerReactionPromises = new Set();
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
/** Layers Context isolation is switching off right now. */
|
||||
this._isolatingLayerIds = new Set();
|
||||
this._userFacingContextNotificationTokens = new Set();
|
||||
this._clearSelectedLayersPromise = null;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
@@ -59,6 +61,16 @@ export class ContextControls {
|
||||
showToast(...args) {
|
||||
if (!this.destroyed) this.actions.showToast(...args);
|
||||
}
|
||||
/**
|
||||
* Whether Context isolation is switching this layer off right now: it is
|
||||
* suspended, and leaving the mode restores it. A switch-off by the user,
|
||||
* inside the mode or out of it, is not.
|
||||
* @param {string} layerId Layer id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
suspendsLayer(layerId) {
|
||||
return this._isolatingLayerIds.has(layerId);
|
||||
}
|
||||
setClearBusy(...args) {
|
||||
if (!this.destroyed) this.actions.setClearBusy(...args);
|
||||
}
|
||||
|
||||
@@ -153,3 +153,56 @@ test('a rejected tracked reaction settles without an unhandled rejection', async
|
||||
await controls._waitForContextLayerSettlement();
|
||||
assert.equal(controls._contextLayerReactionPromises.size, 0);
|
||||
});
|
||||
|
||||
test('only a layer Context isolation is switching off is suspended', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const enabled = new Set(['cctv']);
|
||||
// What the CCTV panel is told at each visibility change of its layer.
|
||||
const seen = [];
|
||||
controls._dataManager = {
|
||||
layers: new Map([['cctv', {}]]),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => null,
|
||||
setLayerParams() {},
|
||||
isEnabled: (layerId) => enabled.has(layerId),
|
||||
isEffectivelyEnabled: (layerId) => enabled.has(layerId),
|
||||
async setEnabled(layerId, next) {
|
||||
// The layer tells its panel it is off while it is being disabled.
|
||||
if (next) enabled.add(layerId);
|
||||
else enabled.delete(layerId);
|
||||
seen.push([layerId, next, controls.suspendsLayer(layerId)]);
|
||||
return true;
|
||||
},
|
||||
async restoreEnabledLayerIds(layerIds) {
|
||||
for (const layerId of layerIds) await this.setEnabled(layerId, true);
|
||||
},
|
||||
};
|
||||
assert.equal(controls.suspendsLayer('cctv'), false);
|
||||
controls._captureContextSessionSnapshot();
|
||||
await controls._clearLayersOutsideContextMode('flights');
|
||||
assert.deepEqual(seen, [['cctv', false, true]]);
|
||||
assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating');
|
||||
// Inside the mode the user turns CCTV back on, then off: their own.
|
||||
await controls._dataManager.setEnabled('cctv', true);
|
||||
await controls._dataManager.setEnabled('cctv', false);
|
||||
assert.deepEqual(seen.at(-1), ['cctv', false, false]);
|
||||
await controls._restoreContextSession();
|
||||
assert.equal(enabled.has('cctv'), true);
|
||||
// A switch-off after the mode is left is the user's own too.
|
||||
await controls._dataManager.setEnabled('cctv', false);
|
||||
assert.deepEqual(seen.at(-1), ['cctv', false, false]);
|
||||
});
|
||||
|
||||
test('a failed isolation leaves no layer suspended', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
controls._dataManager = {
|
||||
layers: new Map([['cctv', {}]]),
|
||||
isEffectivelyEnabled: () => true,
|
||||
isEnabled: () => true,
|
||||
setEnabled: async () => {
|
||||
throw new Error('disable failed');
|
||||
},
|
||||
};
|
||||
await assert.rejects(controls._clearLayersOutsideContextMode('flights'));
|
||||
assert.equal(controls.suspendsLayer('cctv'), false);
|
||||
});
|
||||
@@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode(
|
||||
) {
|
||||
const allowed = contextAllowedLayerIds(mode);
|
||||
const pending = [];
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (
|
||||
!allowed.has(layerId) &&
|
||||
this._dataManager.isEffectivelyEnabled(layerId)
|
||||
) {
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
// Panels read this while their layer goes off (suspendsLayer): a layer
|
||||
// isolation switches off is suspended, not switched off by the user.
|
||||
const isolating = [];
|
||||
let results;
|
||||
try {
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (
|
||||
!allowed.has(layerId) &&
|
||||
this._dataManager.isEffectivelyEnabled(layerId)
|
||||
) {
|
||||
if (!this._isolatingLayerIds.has(layerId)) {
|
||||
this._isolatingLayerIds.add(layerId);
|
||||
isolating.push(layerId);
|
||||
}
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
results = await Promise.all(pending.map(({ transition }) => transition));
|
||||
} finally {
|
||||
for (const layerId of isolating) this._isolatingLayerIds.delete(layerId);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
pending.map(({ transition }) => transition),
|
||||
);
|
||||
const failed = pending
|
||||
.filter(
|
||||
({ layerId }, index) =>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { describeError } from '../app/errors.js';
|
||||
import { appRootUrl } from '../share/shareLinkPath.js';
|
||||
|
||||
/**
|
||||
* Show a recoverable notice when the globe stops drawing, instead of a black
|
||||
* globe behind working controls. Retry restarts the render loop, or reloads
|
||||
* after a lost WebGL context; Reset opens the app's root address. With
|
||||
* `allowReload: false`, Reset is dropped and a lost context shows no notice:
|
||||
* only a reload draws again, and the page says so itself.
|
||||
* Returns a function that removes the notice.
|
||||
*/
|
||||
export function installRenderFailureNotice(
|
||||
viewer,
|
||||
{
|
||||
store = useStatusStore(applicationPinia()),
|
||||
locationRef = globalThis.location,
|
||||
allowReload = true,
|
||||
logError = (error) =>
|
||||
console.error('[Render] The globe stopped drawing:', error),
|
||||
} = {},
|
||||
) {
|
||||
const { scene } = viewer;
|
||||
const { canvas } = viewer;
|
||||
let notice = null;
|
||||
let contextLost = false;
|
||||
// With rethrowRenderErrors, Scene throws the error it has just raised;
|
||||
// the render() wrapper must not raise it again.
|
||||
let raisedByScene = null;
|
||||
let frameFailed = false;
|
||||
let removeFrameListeners = null;
|
||||
|
||||
function retry() {
|
||||
if (contextLost) {
|
||||
locationRef.reload();
|
||||
return;
|
||||
}
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
locationRef.assign(appRootUrl(locationRef.href));
|
||||
}
|
||||
|
||||
function stopWatchingFrames() {
|
||||
removeFrameListeners?.();
|
||||
removeFrameListeners = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
stopWatchingFrames();
|
||||
if (store.renderFailure === notice) store.publishRenderFailure(null);
|
||||
notice = null;
|
||||
}
|
||||
|
||||
// Cesium raises postRender after a failed frame too, so only a frame that
|
||||
// starts and ends without a render error clears the notice.
|
||||
function watchFrames() {
|
||||
if (removeFrameListeners || contextLost) return;
|
||||
const removeStart = scene.preUpdate.addEventListener(() => {
|
||||
frameFailed = false;
|
||||
raisedByScene = null;
|
||||
});
|
||||
const removeEnd = scene.postRender.addEventListener(() => {
|
||||
if (!frameFailed) clear();
|
||||
});
|
||||
removeFrameListeners = () => {
|
||||
removeStart();
|
||||
removeEnd();
|
||||
};
|
||||
}
|
||||
|
||||
function present(detail) {
|
||||
const reason = contextLost ? 'context-lost' : 'render-error';
|
||||
// A loop restarted into the same error keeps the notice as it was.
|
||||
if (notice?.reason === reason && notice.detail === detail) return;
|
||||
notice = {
|
||||
reason,
|
||||
detail,
|
||||
retry,
|
||||
reset: allowReload ? reset : null,
|
||||
};
|
||||
store.publishRenderFailure(notice);
|
||||
watchFrames();
|
||||
}
|
||||
|
||||
const onRenderError = (_scene, error) => {
|
||||
raisedByScene = { error };
|
||||
frameFailed = true;
|
||||
logError(error);
|
||||
// A lost context fails every frame after it; the notice keeps saying why.
|
||||
if (contextLost) return;
|
||||
present(error ? describeError(error) : '');
|
||||
};
|
||||
const onContextLost = () => {
|
||||
contextLost = true;
|
||||
// No frame can clear this one; only a reload draws again.
|
||||
stopWatchingFrames();
|
||||
// A page that must not reload has nothing to offer: an inline panel
|
||||
// shows its own lost-context notice (src/app/globePanelRuntime.js).
|
||||
if (!allowReload) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
present('');
|
||||
};
|
||||
|
||||
const removeRenderError = scene.renderError.addEventListener(onRenderError);
|
||||
// An error thrown outside Scene's error handling (a frame listener, onTick,
|
||||
// resize) stops the loop without a renderError. Raise one, so Cesium, an
|
||||
// inline panel's timer and this notice all see it. First finish the event
|
||||
// raises it cut short, so listeners added or removed meanwhile apply.
|
||||
const unguard = ['resize', 'render'].map((name) =>
|
||||
reportThrown(viewer.cesiumWidget, name, (error) => {
|
||||
frameEvents(viewer).forEach(finishRaise);
|
||||
if (!raisedByScene || raisedByScene.error !== error)
|
||||
scene.renderError.raiseEvent(scene, error);
|
||||
raisedByScene = null;
|
||||
}),
|
||||
);
|
||||
canvas.addEventListener('webglcontextlost', onContextLost);
|
||||
return () => {
|
||||
removeRenderError();
|
||||
unguard.forEach((restore) => restore());
|
||||
canvas.removeEventListener('webglcontextlost', onContextLost);
|
||||
clear();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Events Cesium raises in a frame outside Scene's error handling, which a
|
||||
* listener's error can leave mid-raise. Only top-level tilesets are reached.
|
||||
*/
|
||||
function frameEvents(viewer) {
|
||||
const { scene } = viewer;
|
||||
const events = [
|
||||
scene.preUpdate,
|
||||
scene.postUpdate,
|
||||
scene.preRender,
|
||||
scene.postRender,
|
||||
scene.camera?.changed,
|
||||
scene.camera?.moveStart,
|
||||
scene.camera?.moveEnd,
|
||||
viewer.clock?.onTick,
|
||||
viewer.clock?.onStop,
|
||||
scene.globe?.tileLoadProgressEvent,
|
||||
];
|
||||
const primitives = scene.primitives;
|
||||
for (let i = 0; i < (primitives?.length ?? 0); i += 1) {
|
||||
const tileset = primitives.get(i);
|
||||
events.push(
|
||||
tileset?.loadProgress,
|
||||
tileset?.allTilesLoaded,
|
||||
tileset?.initialTilesLoaded,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a raise a throwing listener cut short. Until a raise ends, Cesium's
|
||||
* `Event` only queues added and removed listeners; apply that queue.
|
||||
* These are Cesium's private fields (`_invokingListeners`, `_toAdd`,
|
||||
* `_toRemove`, `_listeners`, and the widget's `_renderLoopRunning`), checked
|
||||
* against cesium 1.138 / @cesium/engine 22.3.0; the render loop fixture tests
|
||||
* drive the real classes and fail when an upgrade renames them.
|
||||
*/
|
||||
function finishRaise(event) {
|
||||
if (!event?._invokingListeners) return;
|
||||
event._invokingListeners = false;
|
||||
for (const [listener, scopes] of event._toAdd) {
|
||||
const added = event._listeners.get(listener) ?? new Set();
|
||||
scopes.forEach((scope) => added.add(scope));
|
||||
event._listeners.set(listener, added);
|
||||
}
|
||||
event._toAdd.clear();
|
||||
for (const [listener, scopes] of event._toRemove) {
|
||||
const left = event._listeners.get(listener);
|
||||
scopes.forEach((scope) => left?.delete(scope));
|
||||
if (left?.size === 0) event._listeners.delete(listener);
|
||||
}
|
||||
event._toRemove.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error the widget's `name` method throws, and rethrow it.
|
||||
*
|
||||
* A render error marks the loop stopped, but a call made while it ran leaves
|
||||
* its next frame queued, and that frame stops it. Keep the loop marked
|
||||
* running, so a Retry or a shown tab before that frame starts no second loop.
|
||||
* After a throw, the loop's own catch still marks it stopped.
|
||||
*/
|
||||
function reportThrown(widget, name, report) {
|
||||
const original = widget?.[name];
|
||||
if (typeof original !== 'function') return () => {};
|
||||
const own = Object.hasOwn(widget, name);
|
||||
const guarded = function (...args) {
|
||||
const looping = widget._renderLoopRunning;
|
||||
try {
|
||||
return original.apply(this, args);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (looping) widget._renderLoopRunning = true;
|
||||
}
|
||||
};
|
||||
widget[name] = guarded;
|
||||
return () => {
|
||||
if (widget[name] !== guarded) return;
|
||||
if (own) widget[name] = original;
|
||||
else delete widget[name];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installRenderFailureNotice } from './renderFailure.js';
|
||||
|
||||
function fixture(t, options = {}) {
|
||||
const canvasListeners = new Map();
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {
|
||||
this.requests = (this.requests ?? 0) + 1;
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
scene,
|
||||
useDefaultRenderLoop: true,
|
||||
canvas: {
|
||||
addEventListener: (name, listener) => canvasListeners.set(name, listener),
|
||||
removeEventListener: (name, listener) => {
|
||||
if (canvasListeners.get(name) === listener)
|
||||
canvasListeners.delete(name);
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
renderFailure: null,
|
||||
published: [],
|
||||
publishRenderFailure(next) {
|
||||
this.renderFailure = next;
|
||||
this.published.push(next);
|
||||
},
|
||||
};
|
||||
const visits = [];
|
||||
const locationRef = {
|
||||
href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945',
|
||||
reload: () => visits.push('reload'),
|
||||
assign: (url) => visits.push(String(url)),
|
||||
};
|
||||
const logged = [];
|
||||
const uninstall = installRenderFailureNotice(viewer, {
|
||||
store,
|
||||
locationRef,
|
||||
logError: (error) => logged.push(error),
|
||||
...options,
|
||||
});
|
||||
t.after(uninstall);
|
||||
// One Cesium frame: preUpdate, an optional render error, then postRender,
|
||||
// which Cesium raises after a failed frame too.
|
||||
const frame = (error) => {
|
||||
scene.preUpdate.raiseEvent(scene);
|
||||
if (error) {
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
scene.renderError.raiseEvent(scene, error);
|
||||
}
|
||||
scene.postRender.raiseEvent(scene);
|
||||
};
|
||||
return {
|
||||
viewer,
|
||||
scene,
|
||||
store,
|
||||
visits,
|
||||
logged,
|
||||
uninstall,
|
||||
frame,
|
||||
loseContext: () => canvasListeners.get('webglcontextlost')?.({}),
|
||||
canvasListeners,
|
||||
};
|
||||
}
|
||||
|
||||
test('a render error shows a notice that survives the failed frame', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
const error = new Error('Expected width to be greater than 0');
|
||||
f.frame(error);
|
||||
const notice = f.store.renderFailure;
|
||||
assert.equal(notice.reason, 'render-error');
|
||||
assert.equal(notice.detail, 'Expected width to be greater than 0');
|
||||
assert.equal(typeof notice.retry, 'function');
|
||||
assert.equal(typeof notice.reset, 'function');
|
||||
assert.deepEqual(f.logged, [error]);
|
||||
});
|
||||
|
||||
test('Retry restarts the render loop and a clean frame clears the notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.retry();
|
||||
assert.equal(f.viewer.useDefaultRenderLoop, true);
|
||||
assert.equal(f.scene.requests, 1);
|
||||
assert.ok(f.store.renderFailure, 'the notice waits for a frame');
|
||||
f.frame();
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
// Clearing stops the per-frame listeners.
|
||||
assert.equal(f.scene.preUpdate.numberOfListeners, 0);
|
||||
assert.equal(f.scene.postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('a loop restarted into the same error keeps one notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
const first = f.store.renderFailure;
|
||||
f.store.renderFailure.retry();
|
||||
f.frame(new Error('boom'));
|
||||
assert.equal(f.store.renderFailure, first);
|
||||
assert.equal(f.store.published.length, 1);
|
||||
f.frame(new Error('other'));
|
||||
assert.equal(f.store.renderFailure.detail, 'other');
|
||||
assert.ok(!f.store.published.includes(null), 'no new appearance');
|
||||
});
|
||||
|
||||
test('a new failure after recovery is a new appearance', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.retry();
|
||||
f.frame();
|
||||
f.frame(new Error('boom'));
|
||||
assert.deepEqual(
|
||||
f.store.published.map((notice) => notice?.reason ?? null),
|
||||
['render-error', null, 'render-error'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a render error without a value shows no detail', (t) => {
|
||||
const f = fixture(t);
|
||||
f.viewer.useDefaultRenderLoop = false;
|
||||
f.scene.renderError.raiseEvent(f.scene, undefined);
|
||||
assert.equal(f.store.renderFailure.reason, 'render-error');
|
||||
assert.equal(f.store.renderFailure.detail, '');
|
||||
});
|
||||
|
||||
test('Reset opens the app root without the restored view', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.store.renderFailure.reset();
|
||||
assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']);
|
||||
});
|
||||
|
||||
test('a lost context offers a reload and no frame clears it', (t) => {
|
||||
const f = fixture(t);
|
||||
f.loseContext();
|
||||
const notice = f.store.renderFailure;
|
||||
assert.equal(notice.reason, 'context-lost');
|
||||
assert.equal(notice.detail, '');
|
||||
// Cesium's next frame then fails on the dead drawing buffer.
|
||||
f.frame(new Error('Expected width to be greater than 0'));
|
||||
assert.equal(f.store.renderFailure, notice, 'the notice keeps saying why');
|
||||
f.frame();
|
||||
assert.ok(f.store.renderFailure, 'a lost context never draws again');
|
||||
f.store.renderFailure.retry();
|
||||
assert.deepEqual(f.visits, ['reload']);
|
||||
});
|
||||
|
||||
test('a page that must not reload offers neither reload nor reset', (t) => {
|
||||
const f = fixture(t, { allowReload: false });
|
||||
f.frame(new Error('boom'));
|
||||
assert.equal(typeof f.store.renderFailure.retry, 'function');
|
||||
assert.equal(f.store.renderFailure.reset, null);
|
||||
// Only a reload draws again, which this page must not offer: the notice
|
||||
// goes, and the page's own lost-context notice speaks instead.
|
||||
f.loseContext();
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
f.frame(new Error('Expected width to be greater than 0'));
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
});
|
||||
|
||||
test('uninstall removes the listeners and the notice', (t) => {
|
||||
const f = fixture(t);
|
||||
f.frame(new Error('boom'));
|
||||
f.uninstall();
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
assert.equal(f.scene.renderError.numberOfListeners, 0);
|
||||
assert.equal(f.scene.postRender.numberOfListeners, 0);
|
||||
assert.equal(f.canvasListeners.size, 0);
|
||||
f.frame(new Error('later'));
|
||||
assert.equal(f.store.renderFailure, null);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Retry after a scene event listener throws. Cesium's Event.raiseEvent has no
|
||||
// try/finally, so the event stays mid-raise and only queues listener changes.
|
||||
// Drives Cesium's real Event and startRenderLoop.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installRenderFailureNotice } from './renderFailure.js';
|
||||
import { renderLoopFixture } from './renderLoopFixture.mjs';
|
||||
|
||||
function setup() {
|
||||
// Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender
|
||||
// only when a frame is rendered (requestRenderMode idle skips them).
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRenderMode: true,
|
||||
_renderRequested: false,
|
||||
rendered: 0,
|
||||
requestRender() {
|
||||
this._renderRequested = true;
|
||||
},
|
||||
initializeFrame() {},
|
||||
render(time) {
|
||||
this.preUpdate.raiseEvent(this, time);
|
||||
const shouldRender = !this.requestRenderMode || this._renderRequested;
|
||||
if (!shouldRender) return;
|
||||
this._renderRequested = false;
|
||||
this.preRender.raiseEvent(this, time);
|
||||
this.rendered += 1;
|
||||
this.postRender.raiseEvent(this, time);
|
||||
},
|
||||
};
|
||||
return { scene, ...renderLoopFixture(scene) };
|
||||
}
|
||||
|
||||
for (const eventName of ['postRender', 'preUpdate']) {
|
||||
test(`idle: a ${eventName} listener that threw once; a successful Retry clears the notice`, () => {
|
||||
const { scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
let broken = true;
|
||||
scene[eventName].addEventListener(() => {
|
||||
if (broken) throw new TypeError('listener failed');
|
||||
});
|
||||
scene.requestRender();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(viewer.useDefaultRenderLoop, false);
|
||||
broken = false; // the cause goes away
|
||||
store.renderFailure.retry();
|
||||
const before = scene.rendered;
|
||||
runFrames(10); // idle: no camera move, no render request
|
||||
assert.ok(scene.rendered > before, 'the globe drew again');
|
||||
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a throwing postRender listener removed before Retry is not called again', () => {
|
||||
const { scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
let calls = 0;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
calls += 1;
|
||||
throw new TypeError('layer listener failed');
|
||||
});
|
||||
scene.requestRender();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
remove(); // the user turns the layer off
|
||||
store.renderFailure.retry();
|
||||
runFrames(5);
|
||||
assert.equal(calls, 1, 'the removed listener ran again after Retry');
|
||||
assert.equal(viewer.useDefaultRenderLoop, true, 'the loop keeps running');
|
||||
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
const listenerCount = (event) =>
|
||||
[...event._listeners.values()].reduce((sum, scopes) => sum + scopes.size, 0);
|
||||
|
||||
// While the loop is stopped, a layer turned off and on again, or a ground
|
||||
// task in worldAnnotationRenderer that starts, times out and starts again.
|
||||
const RESUBSCRIBE = {
|
||||
'turned off and on': (event, listener) => {
|
||||
event.removeEventListener(listener);
|
||||
event.addEventListener(listener);
|
||||
},
|
||||
'added, removed and added again': (event, listener) => {
|
||||
event.addEventListener(listener);
|
||||
event.removeEventListener(listener);
|
||||
event.addEventListener(listener);
|
||||
},
|
||||
};
|
||||
|
||||
for (const [name, resubscribe] of Object.entries(RESUBSCRIBE)) {
|
||||
test(`a listener ${name} while the loop is stopped runs after Retry`, () => {
|
||||
const { scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
let broken = true;
|
||||
scene.postRender.addEventListener(() => {
|
||||
if (broken) throw new TypeError('layer listener failed');
|
||||
});
|
||||
let calls = 0;
|
||||
const listener = () => {
|
||||
calls += 1;
|
||||
};
|
||||
if (name === 'turned off and on')
|
||||
scene.postRender.addEventListener(listener);
|
||||
scene.requestRender();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
resubscribe(scene.postRender, listener);
|
||||
broken = false;
|
||||
store.renderFailure.retry();
|
||||
calls = 0;
|
||||
scene.requestRender();
|
||||
runFrames(3);
|
||||
assert.ok(calls > 0, 'the re-added listener runs after Retry');
|
||||
assert.equal(
|
||||
scene.postRender.numberOfListeners,
|
||||
listenerCount(scene.postRender),
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Every event a listener's error can cut short is finished when the error
|
||||
// surfaces, so a listener added at once is subscribed.
|
||||
const FRAME_EVENTS = {
|
||||
'scene.preUpdate': (v) => v.scene.preUpdate,
|
||||
'scene.postUpdate': (v) => v.scene.postUpdate,
|
||||
'scene.preRender': (v) => v.scene.preRender,
|
||||
'scene.postRender': (v) => v.scene.postRender,
|
||||
'camera.changed': (v) => v.scene.camera.changed,
|
||||
'camera.moveStart': (v) => v.scene.camera.moveStart,
|
||||
'camera.moveEnd': (v) => v.scene.camera.moveEnd,
|
||||
'clock.onTick': (v) => v.clock.onTick,
|
||||
'clock.onStop': (v) => v.clock.onStop,
|
||||
'globe.tileLoadProgressEvent': (v) => v.scene.globe.tileLoadProgressEvent,
|
||||
'tileset.loadProgress': (v) => v.scene.primitives.get(0).loadProgress,
|
||||
'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded,
|
||||
'tileset.initialTilesLoaded': (v) =>
|
||||
v.scene.primitives.get(0).initialTilesLoaded,
|
||||
};
|
||||
|
||||
for (const [name, pick] of Object.entries(FRAME_EVENTS)) {
|
||||
test(`a listener that throws in ${name} leaves it finished`, () => {
|
||||
const events = () => ({
|
||||
changed: new Cesium.Event(),
|
||||
moveStart: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
});
|
||||
const tileset = {
|
||||
loadProgress: new Cesium.Event(),
|
||||
allTilesLoaded: new Cesium.Event(),
|
||||
initialTilesLoaded: new Cesium.Event(),
|
||||
};
|
||||
const viewer = {
|
||||
scene: {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
camera: events(),
|
||||
globe: { tileLoadProgressEvent: new Cesium.Event() },
|
||||
primitives: { length: 1, get: () => tileset },
|
||||
},
|
||||
clock: { onTick: new Cesium.Event(), onStop: new Cesium.Event() },
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
cesiumWidget: {
|
||||
_renderLoopRunning: false,
|
||||
render: () => pick(viewer).raiseEvent(),
|
||||
},
|
||||
};
|
||||
const store = { publishRenderFailure() {} };
|
||||
const uninstall = installRenderFailureNotice(viewer, {
|
||||
store,
|
||||
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||
logError() {},
|
||||
});
|
||||
try {
|
||||
const event = pick(viewer);
|
||||
event.addEventListener(() => {
|
||||
throw new TypeError('listener failed');
|
||||
});
|
||||
assert.throws(() => viewer.cesiumWidget.render(), TypeError);
|
||||
assert.equal(event._invokingListeners, false);
|
||||
const listener = () => {};
|
||||
event.addEventListener(listener);
|
||||
assert.ok(event._listeners.has(listener));
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Errors thrown in the render loop outside Scene's tryAndCatchError. Drives
|
||||
// Cesium's real startRenderLoop through the useDefaultRenderLoop setter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderLoopFixture } from './renderLoopFixture.mjs';
|
||||
|
||||
function runLoop(render) {
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
initializeFrame() {},
|
||||
render,
|
||||
};
|
||||
const fixture = renderLoopFixture(scene);
|
||||
fixture.viewer.useDefaultRenderLoop = true;
|
||||
fixture.runFrames(3);
|
||||
return { ...fixture, uninstall: fixture.restore };
|
||||
}
|
||||
|
||||
test('an error thrown outside tryAndCatchError still shows the notice', () => {
|
||||
// Scene.render raises preRender outside tryAndCatchError (Scene.js:4439),
|
||||
// so a listener's exception propagates.
|
||||
const { widget, store, logged, uninstall } = runLoop(function () {
|
||||
this.preUpdate.raiseEvent(this);
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'x')");
|
||||
});
|
||||
assert.equal(widget.useDefaultRenderLoop, false);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(logged.length, 1);
|
||||
uninstall();
|
||||
});
|
||||
|
||||
test('an error Scene raised and threw on with rethrowRenderErrors is reported once', () => {
|
||||
const { store, logged, uninstall } = runLoop(function () {
|
||||
this.preUpdate.raiseEvent(this);
|
||||
const error = new Error('boom');
|
||||
this.renderError.raiseEvent(this, error);
|
||||
throw error;
|
||||
});
|
||||
assert.equal(store.renderFailure?.detail, 'boom');
|
||||
assert.equal(logged.length, 1);
|
||||
uninstall();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Retry after an error Scene catches itself: the loop is marked stopped but
|
||||
// its next frame is still queued, so an early Retry must not start a second
|
||||
// loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderLoopFixture } from './renderLoopFixture.mjs';
|
||||
|
||||
function setup({ targetFrameRate } = {}) {
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
broken: false,
|
||||
drawn: 0,
|
||||
requestRender() {},
|
||||
initializeFrame() {},
|
||||
// Scene.render (Scene.js:4369): an update error is caught by
|
||||
// tryAndCatchError, which raises renderError and returns normally.
|
||||
render(time) {
|
||||
this.drawn += 1;
|
||||
this.preUpdate.raiseEvent(this, time);
|
||||
if (this.broken) this.renderError.raiseEvent(this, new Error('boom'));
|
||||
this.preRender.raiseEvent(this, time);
|
||||
this.postRender.raiseEvent(this, time);
|
||||
},
|
||||
};
|
||||
return { scene, ...renderLoopFixture(scene, { targetFrameRate }) };
|
||||
}
|
||||
|
||||
for (const targetFrameRate of [undefined, 60]) {
|
||||
test(`targetFrameRate ${targetFrameRate}: Retry before the next frame after an error Scene caught keeps one render loop`, () => {
|
||||
const { frames, scene, viewer, store, runFrames, restore } = setup({
|
||||
targetFrameRate,
|
||||
});
|
||||
try {
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(2);
|
||||
scene.broken = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
// The cause is gone; Retry runs (a queued Enter on the focused Retry)
|
||||
// before the loop's next animation frame.
|
||||
scene.broken = false;
|
||||
store.renderFailure.retry();
|
||||
assert.equal(frames.length, 1, 'one animation frame queued after Retry');
|
||||
scene.drawn = 0;
|
||||
runFrames(3);
|
||||
assert.equal(store.renderFailure, null);
|
||||
assert.equal(scene.drawn, 3, 'the globe draws once a frame');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('control: Retry after the next frame ran keeps one render loop', () => {
|
||||
const { frames, scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(2);
|
||||
scene.broken = true;
|
||||
runFrames(1);
|
||||
scene.broken = false;
|
||||
runFrames(1);
|
||||
assert.equal(frames.length, 0, 'the stopped loop queued nothing');
|
||||
store.renderFailure.retry();
|
||||
scene.drawn = 0;
|
||||
runFrames(3);
|
||||
assert.equal(scene.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a tab hidden and shown again before the next frame after an error Scene caught keeps one render loop', () => {
|
||||
const { scene, viewer, runFrames, restore } = setup();
|
||||
try {
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(2);
|
||||
scene.broken = true;
|
||||
runFrames(1);
|
||||
scene.broken = false;
|
||||
// src/app/tools.js turns the loop off while the tab is hidden and back on
|
||||
// when it is shown, before the queued frame runs.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
scene.drawn = 0;
|
||||
runFrames(3);
|
||||
assert.equal(scene.drawn, 3, 'the globe draws once a frame');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user