mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec354565c8 | ||
|
|
1d81955f1d |
No files matched your search
+1
-1
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
|||||||
AISSTREAM_API_KEY=
|
AISSTREAM_API_KEY=
|
||||||
# Optional AISStream subscription filters.
|
# Optional AISStream subscription filters.
|
||||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
|
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
||||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||||
# default worldwide subscription — a narrow filter above can be legitimately
|
# default worldwide subscription — a narrow filter above can be legitimately
|
||||||
|
|||||||
@@ -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.
|
- **📡 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.
|
- **🎯 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.
|
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||||
- **🛫 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.
|
- **🛫 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.
|
||||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
|
- **🎨 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.
|
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
- Fix the page freezing for seconds at a time with Transit on over a terrain
|
|
||||||
globe, most visibly while zooming over a large fleet. Every reread asked the
|
|
||||||
terrain for the height of every trail fix of every vehicle in view; heights
|
|
||||||
are now kept per point, read again only while the camera moves or tiles
|
|
||||||
load, and read within a time budget that puts vehicle positions first.
|
|
||||||
Switching the terrain provider or globe starts the kept heights afresh
|
|
||||||
(mazy1998, review by kvnloo, #1688).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- Read terrain heights through one shared, cached and budgeted reader
|
|
||||||
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
|
|
||||||
data layers. Opening Data Centers no longer blocks the page for over two
|
|
||||||
seconds, opening Bike Share for most of a second, and Rail asks the terrain
|
|
||||||
about a twentieth as often (mazy1998, #1689).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- 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,4 @@
|
|||||||
|
- The detection overlay no longer rebuilds every bracket on a paint where the
|
||||||
|
view and every contact are unchanged, which cuts frame work by a fifth to a
|
||||||
|
third at a whole-globe view of Live Flights with a still camera (mazy1998,
|
||||||
|
#1719).
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- 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).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- Draw airport ground vehicles as real shapes instead of stacked boxes: the
|
|
||||||
car is an operations SUV, the van a high-roof panel van, the fire truck an
|
|
||||||
airport crash tender with turrets and the pushback a low tug with a glazed
|
|
||||||
cab, all on the ground at their built size (samehkhamis, #1730).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Draw aircraft models in their own mesh colours instead of a 94% flat
|
|
||||||
white (amber for military) over the model; the tracked and emergency
|
|
||||||
colours now tint the mesh without hiding its paint (samehkhamis, #1731).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
|
|
||||||
or a local receiver) now faces its reported true heading instead of north,
|
|
||||||
so one taxiing south no longer looks like it is moving backwards
|
|
||||||
(samehkhamis, #1732).
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
|
|
||||||
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
|
|
||||||
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
|
|
||||||
hand, safe water, isolated danger or special mark), standing still with no
|
|
||||||
heading, and its icon is a disc rather than a chevron. Its card and readout
|
|
||||||
show its kind of mark with no speed or heading. AISStream now subscribes to
|
|
||||||
aid-to-navigation reports and names each by its kind of mark and its full
|
|
||||||
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
|
|
||||||
counts ships only. A virtual aid (a mark broadcast where nothing physical
|
|
||||||
stands) keeps its disc, is never drawn as a buoy, and its card says it is
|
|
||||||
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
|
|
||||||
@@ -230,125 +230,3 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
|
|||||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||||
markup. Node tests that import application modules register the component
|
markup. Node tests that import application modules register the component
|
||||||
loader with `node --import=<core package>/build/register-vue`.
|
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 })`.
|
|
||||||
+25
-171
@@ -2208,83 +2208,6 @@ the subject (tracking stopped, Satellites off) or tracking another target
|
|||||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||||
covers entry, camera, zoom, look, HUD and exit.
|
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 coordination
|
||||||
|
|
||||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||||
@@ -2306,17 +2229,6 @@ 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
|
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.
|
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
|
||||||
|
|
||||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||||
@@ -2531,53 +2443,6 @@ 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
|
detection is the backstop for when no probe context can be created. Applied
|
||||||
during viewer construction, before any tile builds a draw command.
|
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 control ownership
|
||||||
|
|
||||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||||
@@ -5960,39 +5825,6 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
|
|||||||
settings and screen position rather than observed brightness. Selected-trail
|
settings and screen position rather than observed brightness. Selected-trail
|
||||||
diagnostics report every condition even when selection fails.
|
diagnostics report every condition even when selection fails.
|
||||||
|
|
||||||
Heights of the terrain globe's loaded surface come from one shared reader,
|
|
||||||
`src/services/terrainSurface.js`, which the ground service hands to every layer
|
|
||||||
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
|
|
||||||
drawn tile's mesh, so the reader keeps each answer per point, about a metre
|
|
||||||
apart. While the camera moves or tiles load a kept height is read again after
|
|
||||||
2 s; a height read once both have been still for 5 s is kept until either
|
|
||||||
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
|
|
||||||
of it for re-reads, longest-kept first. A request ranks as a marker (always
|
|
||||||
read), drawn geometry, or background (a quarter of each budget). Past the
|
|
||||||
budget a kept point keeps its last height and a new point takes the last
|
|
||||||
height read in its ~111 m floor cell or waits.
|
|
||||||
|
|
||||||
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
|
|
||||||
the path a marker is drawn along next, the undrawn trail history of
|
|
||||||
unselected vehicles as background, and its reread cycle continues until no
|
|
||||||
point waits. Before this, every reread cast a ray for every trail fix of
|
|
||||||
every vehicle in view, which blocked the page for up to 19 s at a time over
|
|
||||||
a 650-vehicle fleet while zooming.
|
|
||||||
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
|
|
||||||
so a moving train reads a few times a second and a standing one not at all.
|
|
||||||
- Local data layers (Data Centers, Dams) read terrain floors through it, and
|
|
||||||
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
|
|
||||||
frame per layer and continue on the next frames.
|
|
||||||
- Bike Share draws a city's stations at once at the fallback height and finds
|
|
||||||
their ground heights afterwards in 4 ms steps, from the terrain reader on a
|
|
||||||
terrain globe and from depth samples where the globe is hidden.
|
|
||||||
|
|
||||||
Verified in the browser on a keyless terrain globe only: Boston for Transit,
|
|
||||||
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
|
|
||||||
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
|
|
||||||
and any city steeper than Boston. Street Traffic keeps its own height cache
|
|
||||||
and ALPR cameras their own sample budget.
|
|
||||||
|
|
||||||
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
||||||
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
||||||
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
||||||
@@ -7462,9 +7294,9 @@ pins and share links already supply complete boxes.
|
|||||||
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
|
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
|
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||||
can retain a bounded retry delay of up to 30 seconds.
|
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, 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. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `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, 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.
|
||||||
- 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. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. 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.
|
- 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). 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.
|
- **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.
|
- 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.
|
||||||
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
||||||
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
||||||
@@ -7627,6 +7459,28 @@ apply to `public/` assets or built-preview serving.
|
|||||||
holds the way it stripped detection's would take bracket promptness with it, silently.
|
holds the way it stripped detection's would take bracket promptness with it, silently.
|
||||||
- Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not
|
- Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not
|
||||||
re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work).
|
re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work).
|
||||||
|
- **A detection paint reuses the sweep before it when nothing it reads has changed
|
||||||
|
(`src/data/detectionPaintCache.js`).** The sweep in `_drawOverlay` (project every contact, fade
|
||||||
|
it, append its bracket, build callout candidates) depends only on the view and on the contacts.
|
||||||
|
Each paint still asks every layer for its contacts, then compares them with what the last sweep
|
||||||
|
was built from: the same objects in the same order, the same position coordinates, and the same
|
||||||
|
value in every field the sweep reads (`PAINTED_OBJECT_FIELDS`), under the same view (profile,
|
||||||
|
style, canvas size, keyhole, camera position, view-projection matrix, UI rectangles; see
|
||||||
|
`_paintViewOf`). On a match the kept bracket paths, counts and callout candidates are used again
|
||||||
|
and only the strokes and callouts are repainted. No layer has to announce a change, so none can
|
||||||
|
leave a stale picture behind. A sweep that READS a new object field or a new view value must add
|
||||||
|
it to those two lists; `detectionHost.test.mjs` compares 41 frames against a full sweep, canvas
|
||||||
|
call for canvas call.
|
||||||
|
- Never reused or kept: a solve paint (the sweep gathers the cohort), any paint while the Cyber
|
||||||
|
sonar sweep or a Global Context echo is live (brackets change with the clock), and any sweep
|
||||||
|
that reached a depth-tested contact (its answer comes from the scene).
|
||||||
|
- Where it pays: a held camera with contacts that move in steps. Live Flights re-positions the
|
||||||
|
fleet every 80 ms, so at 60 fps about seven paints in ten reuse a sweep. Once a frame takes
|
||||||
|
longer than 80 ms every paint sees moved aircraft and none is reused; after eight such sweeps
|
||||||
|
the contacts are only recorded on every eighth, so the comparison costs nothing there.
|
||||||
|
Satellites move on every frame and are never reused.
|
||||||
|
- `getDetectionDiagnostics().paintReuseCount` (and `data-paint-reuse-count`) counts reused
|
||||||
|
paints since `initDetection`; `placementBuildCount` is 0 on a reused paint.
|
||||||
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
|
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
|
||||||
developer telemetry and is HIDDEN by default. It paints only under
|
developer telemetry and is HIDDEN by default. It paints only under
|
||||||
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
|
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const layers = createInfrastructureLayers({
|
|||||||
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
|
|
||||||
|
|
||||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||||
|
|||||||
@@ -298,7 +298,6 @@
|
|||||||
},
|
},
|
||||||
"./sources/rail-service": "./src/sources/railService.js",
|
"./sources/rail-service": "./src/sources/railService.js",
|
||||||
"./layers/rail/source": "./src/layers/rail/source.js",
|
"./layers/rail/source": "./src/layers/rail/source.js",
|
||||||
"./layers/rail/paths": "./src/data/railPaths.js",
|
|
||||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||||
"./server/providers/mapillary": {
|
"./server/providers/mapillary": {
|
||||||
"node": "./server/providers/mapillary.js"
|
"node": "./server/providers/mapillary.js"
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ How the layers use them:
|
|||||||
type's published length (a vessel to its reported AIS length and beam), so
|
type's published length (a vessel to its reported AIS length and beam), so
|
||||||
every model is drawn life size. Ground objects, balloons and obstacles in
|
every model is drawn life size. Ground objects, balloons and obstacles in
|
||||||
the `other` category are drawn at the size they are built.
|
the `other` category are drawn at the size they are built.
|
||||||
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
|
|
||||||
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
|
|
||||||
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
|
|
||||||
port or starboard hand, safe water, isolated danger, special mark), a red
|
|
||||||
can when its type names no mark, at its built size and with no heading
|
|
||||||
(`vesselModelSpec`, `aidToNavigationModel`).
|
|
||||||
- An aircraft model's origin is its axis; it stands that model's scaled
|
- An aircraft model's origin is its axis; it stands that model's scaled
|
||||||
clearance above the ground. A ship's origin is its waterline, and a transit
|
clearance above the ground. A ship's origin is its waterline, and a transit
|
||||||
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
||||||
|
|||||||
@@ -726,18 +726,18 @@
|
|||||||
"file": "other/car.glb",
|
"file": "other/car.glb",
|
||||||
"min": [
|
"min": [
|
||||||
-2.31,
|
-2.31,
|
||||||
-1.075,
|
-0.906,
|
||||||
0.018
|
0.019
|
||||||
],
|
],
|
||||||
"max": [
|
"max": [
|
||||||
2.31,
|
2.3,
|
||||||
1.075,
|
0.906,
|
||||||
1.84
|
1.948
|
||||||
],
|
],
|
||||||
"length": 4.62,
|
"length": 4.61,
|
||||||
"beam": 2.15,
|
"beam": 1.812,
|
||||||
"clearance": 0.0,
|
"clearance": 0.0,
|
||||||
"tris": 736,
|
"tris": 206,
|
||||||
"icon": "vehicle"
|
"icon": "vehicle"
|
||||||
},
|
},
|
||||||
"car_carrier": {
|
"car_carrier": {
|
||||||
@@ -1124,19 +1124,19 @@
|
|||||||
"category": "other",
|
"category": "other",
|
||||||
"file": "other/fire_truck.glb",
|
"file": "other/fire_truck.glb",
|
||||||
"min": [
|
"min": [
|
||||||
-6.408,
|
-5.51,
|
||||||
-1.507,
|
-1.5,
|
||||||
0.029
|
0.027
|
||||||
],
|
],
|
||||||
"max": [
|
"max": [
|
||||||
5.51,
|
5.5,
|
||||||
1.507,
|
1.5,
|
||||||
3.64
|
4.08
|
||||||
],
|
],
|
||||||
"length": 11.918,
|
"length": 11.01,
|
||||||
"beam": 3.014,
|
"beam": 3.0,
|
||||||
"clearance": 0.0,
|
"clearance": 0.0,
|
||||||
"tris": 1016,
|
"tris": 314,
|
||||||
"icon": "vehicle"
|
"icon": "vehicle"
|
||||||
},
|
},
|
||||||
"fishing_boat": {
|
"fishing_boat": {
|
||||||
@@ -1751,19 +1751,19 @@
|
|||||||
"category": "other",
|
"category": "other",
|
||||||
"file": "other/pushback.glb",
|
"file": "other/pushback.glb",
|
||||||
"min": [
|
"min": [
|
||||||
-3.45,
|
-3.0,
|
||||||
-1.398,
|
-1.45,
|
||||||
0.025
|
0.024
|
||||||
],
|
],
|
||||||
"max": [
|
"max": [
|
||||||
3.05,
|
3.0,
|
||||||
1.398,
|
1.45,
|
||||||
2.14
|
2.1
|
||||||
],
|
],
|
||||||
"length": 6.5,
|
"length": 6.0,
|
||||||
"beam": 2.795,
|
"beam": 2.9,
|
||||||
"clearance": 0.0,
|
"clearance": 0.0,
|
||||||
"tris": 490,
|
"tris": 176,
|
||||||
"icon": "vehicle"
|
"icon": "vehicle"
|
||||||
},
|
},
|
||||||
"r44": {
|
"r44": {
|
||||||
@@ -1785,25 +1785,6 @@
|
|||||||
"tris": 1172,
|
"tris": 1172,
|
||||||
"icon": "heli"
|
"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": {
|
"regional_dmu": {
|
||||||
"category": "transit",
|
"category": "transit",
|
||||||
"file": "transit/regional_dmu.glb",
|
"file": "transit/regional_dmu.glb",
|
||||||
@@ -2303,18 +2284,18 @@
|
|||||||
"file": "other/van.glb",
|
"file": "other/van.glb",
|
||||||
"min": [
|
"min": [
|
||||||
-2.76,
|
-2.76,
|
||||||
-1.22,
|
-1.0,
|
||||||
0.018
|
0.019
|
||||||
],
|
],
|
||||||
"max": [
|
"max": [
|
||||||
2.76,
|
2.75,
|
||||||
1.22,
|
1.0,
|
||||||
2.56
|
2.438
|
||||||
],
|
],
|
||||||
"length": 5.52,
|
"length": 5.51,
|
||||||
"beam": 2.44,
|
"beam": 2.0,
|
||||||
"clearance": 0.0,
|
"clearance": 0.0,
|
||||||
"tris": 736,
|
"tris": 206,
|
||||||
"icon": "vehicle"
|
"icon": "vehicle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -983,14 +983,6 @@
|
|||||||
"SHIP": [
|
"SHIP": [
|
||||||
"blimp",
|
"blimp",
|
||||||
75.0
|
75.0
|
||||||
],
|
|
||||||
"ZVEH": [
|
|
||||||
"car",
|
|
||||||
4.61
|
|
||||||
],
|
|
||||||
"GRND": [
|
|
||||||
"radio_mast",
|
|
||||||
1.9
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"helicopters": {
|
"helicopters": {
|
||||||
@@ -1151,41 +1143,12 @@
|
|||||||
5.0
|
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": [
|
"vessels": [
|
||||||
{
|
{
|
||||||
"types": [
|
"types": [
|
||||||
70,
|
70,
|
||||||
79
|
79
|
||||||
],
|
],
|
||||||
"typical_len": 300,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 70,
|
"min_len": 70,
|
||||||
@@ -1225,7 +1188,6 @@
|
|||||||
80,
|
80,
|
||||||
89
|
89
|
||||||
],
|
],
|
||||||
"typical_len": 250,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
||||||
@@ -1252,7 +1214,6 @@
|
|||||||
60,
|
60,
|
||||||
69
|
69
|
||||||
],
|
],
|
||||||
"typical_len": 140,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 70,
|
"min_len": 70,
|
||||||
@@ -1282,7 +1243,6 @@
|
|||||||
37,
|
37,
|
||||||
37
|
37
|
||||||
],
|
],
|
||||||
"typical_len": 19,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 35,
|
"min_len": 35,
|
||||||
@@ -1313,7 +1273,6 @@
|
|||||||
30,
|
30,
|
||||||
30
|
30
|
||||||
],
|
],
|
||||||
"typical_len": 24,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 15,
|
"min_len": 15,
|
||||||
@@ -1384,7 +1343,6 @@
|
|||||||
55,
|
55,
|
||||||
55
|
55
|
||||||
],
|
],
|
||||||
"typical_len": 40,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 25,
|
"min_len": 25,
|
||||||
@@ -1400,7 +1358,6 @@
|
|||||||
40,
|
40,
|
||||||
49
|
49
|
||||||
],
|
],
|
||||||
"typical_len": 40,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 20,
|
"min_len": 20,
|
||||||
@@ -1416,7 +1373,6 @@
|
|||||||
33,
|
33,
|
||||||
33
|
33
|
||||||
],
|
],
|
||||||
"typical_len": 100,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"min_len": 50,
|
"min_len": 50,
|
||||||
|
|||||||
@@ -557,13 +557,6 @@
|
|||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/frameRateMonitor.test.mjs",
|
"src/ui/frameRateMonitor.test.mjs",
|
||||||
"src/ui/frameRateView.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/lifecycle.js",
|
||||||
"src/layers/cctv/controls.js",
|
"src/layers/cctv/controls.js",
|
||||||
"src/layers/cctv/geometry.js",
|
"src/layers/cctv/geometry.js",
|
||||||
@@ -1436,18 +1429,8 @@
|
|||||||
"src/data/placePacksLoading.test.mjs",
|
"src/data/placePacksLoading.test.mjs",
|
||||||
"src/ui/placePacksLoading.js",
|
"src/ui/placePacksLoading.js",
|
||||||
"src/ui/placePacksLoading.test.mjs",
|
"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/fleetModels.test.mjs",
|
||||||
"src/data/fleetModelInstances.test.mjs",
|
"src/data/fleetModelInstances.test.mjs",
|
||||||
"src/testSupport/fleetGlb.mjs",
|
"src/testSupport/fleetGlb.mjs",
|
||||||
"src/testSupport/fleetModelFakes.mjs",
|
|
||||||
"scripts/measure-fleet-trail-anchors.mjs"
|
"scripts/measure-fleet-trail-anchors.mjs"
|
||||||
]
|
]
|
||||||
@@ -11,8 +11,7 @@
|
|||||||
"src/data/localGeojsonLod.js",
|
"src/data/localGeojsonLod.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/infrastructureOverlayEntry.js",
|
"src/data/infrastructureOverlayEntry.js",
|
||||||
"src/sources/infrastructureData.js",
|
"src/sources/infrastructureData.js"
|
||||||
"src/services/terrainSurface.js"
|
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -23,9 +22,6 @@
|
|||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/views/index.js",
|
"src/ui/views/index.js",
|
||||||
"src/ui/views/CockpitView.vue",
|
"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/CommandDockView.vue",
|
||||||
"src/ui/views/ContextView.vue",
|
"src/ui/views/ContextView.vue",
|
||||||
"src/ui/views/panels/RadioPanel.vue",
|
"src/ui/views/panels/RadioPanel.vue",
|
||||||
@@ -167,7 +163,6 @@
|
|||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/views/panels/GlobeActions.vue",
|
"src/ui/views/panels/GlobeActions.vue",
|
||||||
"src/ui/views/panels/RenderFailure.vue",
|
|
||||||
"src/ui/views/panels/GlobeCompass.vue",
|
"src/ui/views/panels/GlobeCompass.vue",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
@@ -252,7 +247,6 @@
|
|||||||
"src/data/adsbLolFallback.js",
|
"src/data/adsbLolFallback.js",
|
||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js",
|
"src/data/aisWatchdog.js",
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"server/providers/acartia.js",
|
"server/providers/acartia.js",
|
||||||
@@ -1015,8 +1009,6 @@
|
|||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/cockpitSubjectProviders.js",
|
|
||||||
"src/cockpitProgress.js",
|
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
"src/ui/cockpitSignals.js",
|
"src/ui/cockpitSignals.js",
|
||||||
@@ -1182,8 +1174,6 @@
|
|||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/cockpitSubjectProviders.js",
|
|
||||||
"src/cockpitProgress.js",
|
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
"src/ui/cockpitSignals.js",
|
"src/ui/cockpitSignals.js",
|
||||||
@@ -1366,7 +1356,6 @@
|
|||||||
"src/ui/styles/status.css",
|
"src/ui/styles/status.css",
|
||||||
"src/ui/styles/cockpit.css",
|
"src/ui/styles/cockpit.css",
|
||||||
"src/ui/styles/cockpitBridge.css",
|
"src/ui/styles/cockpitBridge.css",
|
||||||
"src/ui/styles/cockpitSubject.css",
|
|
||||||
"src/ui/styles/cockpitRadar.css",
|
"src/ui/styles/cockpitRadar.css",
|
||||||
"src/ui/styles/overlays.css",
|
"src/ui/styles/overlays.css",
|
||||||
"src/ui/styles/layers.css",
|
"src/ui/styles/layers.css",
|
||||||
@@ -1516,7 +1505,6 @@
|
|||||||
"exports": ["./layers/flights"],
|
"exports": ["./layers/flights"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -1596,7 +1584,6 @@
|
|||||||
"src/layers/flights/modelSpec.js",
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/flights/policy.js",
|
"src/layers/flights/policy.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -2114,7 +2101,6 @@
|
|||||||
"src/layers/bikeshare/state.js",
|
"src/layers/bikeshare/state.js",
|
||||||
"src/layers/bikeshare/testing.js",
|
"src/layers/bikeshare/testing.js",
|
||||||
"src/layers/bikeshare/viewport.js",
|
"src/layers/bikeshare/viewport.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
@@ -2704,6 +2690,7 @@
|
|||||||
"src/data/detectionDraw.js",
|
"src/data/detectionDraw.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/detectionOcclusion.js",
|
"src/data/detectionOcclusion.js",
|
||||||
|
"src/data/detectionPaintCache.js",
|
||||||
"src/data/detectionPresentation.js",
|
"src/data/detectionPresentation.js",
|
||||||
"src/data/detectionRenderDemand.js",
|
"src/data/detectionRenderDemand.js",
|
||||||
"src/data/sceneOcclusionProbe.js",
|
"src/data/sceneOcclusionProbe.js",
|
||||||
@@ -3066,7 +3053,6 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -3130,8 +3116,6 @@
|
|||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/cockpitSubjectProviders.js",
|
|
||||||
"src/cockpitProgress.js",
|
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
"src/ui/cockpitSignals.js",
|
"src/ui/cockpitSignals.js",
|
||||||
@@ -3149,7 +3133,6 @@
|
|||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/renderFailure.js",
|
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
"src/ui/weatherPanel.js",
|
"src/ui/weatherPanel.js",
|
||||||
"src/ui/railCards.js",
|
"src/ui/railCards.js",
|
||||||
@@ -3320,7 +3303,6 @@
|
|||||||
"src/app/layers/rail.js",
|
"src/app/layers/rail.js",
|
||||||
"src/data/railFeeds.js",
|
"src/data/railFeeds.js",
|
||||||
"src/layers/rail/index.js",
|
"src/layers/rail/index.js",
|
||||||
"src/layers/rail/cockpitSubject.js",
|
|
||||||
"src/layers/rail/source.js",
|
"src/layers/rail/source.js",
|
||||||
"src/layers/rail/policy.js",
|
"src/layers/rail/policy.js",
|
||||||
"src/layers/rail/ingestion.js",
|
"src/layers/rail/ingestion.js",
|
||||||
@@ -3347,7 +3329,6 @@
|
|||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
"src/layers/transit/lifecycle.js",
|
"src/layers/transit/lifecycle.js",
|
||||||
"src/layers/transit/policy.js",
|
"src/layers/transit/policy.js",
|
||||||
@@ -4247,7 +4228,6 @@
|
|||||||
"src/scopeMaskPolicy.js",
|
"src/scopeMaskPolicy.js",
|
||||||
"src/scopeMask.js",
|
"src/scopeMask.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
"src/data/pointerOwnerNames.js",
|
"src/data/pointerOwnerNames.js",
|
||||||
@@ -4311,7 +4291,6 @@
|
|||||||
"src/app/layers/rail.js",
|
"src/app/layers/rail.js",
|
||||||
"src/data/railFeeds.js",
|
"src/data/railFeeds.js",
|
||||||
"src/layers/rail/index.js",
|
"src/layers/rail/index.js",
|
||||||
"src/layers/rail/cockpitSubject.js",
|
|
||||||
"src/layers/rail/source.js",
|
"src/layers/rail/source.js",
|
||||||
"src/layers/rail/policy.js",
|
"src/layers/rail/policy.js",
|
||||||
"src/layers/rail/ingestion.js",
|
"src/layers/rail/ingestion.js",
|
||||||
@@ -4337,7 +4316,6 @@
|
|||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
"src/layers/transit/lifecycle.js",
|
"src/layers/transit/lifecycle.js",
|
||||||
"src/layers/transit/policy.js",
|
"src/layers/transit/policy.js",
|
||||||
@@ -4395,6 +4373,7 @@
|
|||||||
"src/data/detectionRenderDemand.js",
|
"src/data/detectionRenderDemand.js",
|
||||||
"src/data/detectionPresentation.js",
|
"src/data/detectionPresentation.js",
|
||||||
"src/data/detectionOcclusion.js",
|
"src/data/detectionOcclusion.js",
|
||||||
|
"src/data/detectionPaintCache.js",
|
||||||
"src/data/sceneOcclusionProbe.js",
|
"src/data/sceneOcclusionProbe.js",
|
||||||
"src/data/modelEligibility.js",
|
"src/data/modelEligibility.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
@@ -4596,7 +4575,6 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -4823,10 +4801,7 @@
|
|||||||
"src/data/approxDistance.js",
|
"src/data/approxDistance.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftEmergency.js",
|
"src/data/aircraftEmergency.js"
|
||||||
"src/data/fleetModels.js",
|
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/data/fleetTrailAnchors.js"
|
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -5216,7 +5191,6 @@
|
|||||||
"exports": ["./layers/transit/standalone"],
|
"exports": ["./layers/transit/standalone"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
@@ -5254,7 +5228,6 @@
|
|||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
"src/layers/transit/lifecycle.js",
|
"src/layers/transit/lifecycle.js",
|
||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
@@ -5273,7 +5246,6 @@
|
|||||||
"src/renderGovernor.js",
|
"src/renderGovernor.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
@@ -5310,7 +5282,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
"src/data/vesselTypes.js",
|
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftModelLighting.js",
|
"src/data/aircraftModelLighting.js",
|
||||||
@@ -5326,9 +5297,7 @@
|
|||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
"src/services/terrainSurface.js",
|
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
"src/layers/transit/lifecycle.js",
|
"src/layers/transit/lifecycle.js",
|
||||||
"src/layers/transit/policy.js",
|
"src/layers/transit/policy.js",
|
||||||
@@ -5951,10 +5920,5 @@
|
|||||||
"src/layers/wildlifeSightings/performance.js"
|
"src/layers/wildlifeSightings/performance.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
|
||||||
"rail-paths": {
|
|
||||||
"exports": ["./layers/rail/paths"],
|
|
||||||
"modules": ["src/data/railPaths.js"],
|
|
||||||
"external": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,45 +339,6 @@ try {
|
|||||||
'#cctv-frame',
|
'#cctv-frame',
|
||||||
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
|
(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.click('#cctv-enable-btn');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
@@ -390,42 +351,6 @@ try {
|
|||||||
'disable clears the preview and re-enable reacquires the camera frame',
|
'disable clears the preview and re-enable reacquires the camera frame',
|
||||||
disabledCleanly,
|
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) =>
|
await page.$eval('#cctv-frame', (image) =>
|
||||||
image.scrollIntoView({ block: 'nearest' }),
|
image.scrollIntoView({ block: 'nearest' }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
|||||||
'ExtendedClassBPositionReport',
|
'ExtendedClassBPositionReport',
|
||||||
'ShipStaticData',
|
'ShipStaticData',
|
||||||
'StaticDataReport',
|
'StaticDataReport',
|
||||||
// Buoys, beacons and other aids to navigation (AIS message 21).
|
|
||||||
'AidsToNavigationReport',
|
|
||||||
];
|
];
|
||||||
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
||||||
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
import {
|
|
||||||
aidToNavigationTypeName,
|
|
||||||
isAidToNavigation,
|
|
||||||
} from '../../../src/data/vesselTypes.js';
|
|
||||||
export const AISSTREAM_CACHE_MAX = 50000;
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
@@ -116,21 +112,13 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
const aidReport = messageType === 'AidsToNavigationReport';
|
|
||||||
_aisStreamVessels.set(mmsi, {
|
_aisStreamVessels.set(mmsi, {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name:
|
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
||||||
(aidReport && aidNameFromAis(message)) ||
|
|
||||||
vesselNameFromAis(metadata, message, staticData) ||
|
|
||||||
`MMSI ${mmsi}`,
|
|
||||||
mmsi,
|
mmsi,
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
type: vesselTypeFromAis(message, staticData),
|
||||||
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
|
|
||||||
type: aidReport
|
|
||||||
? aidToNavigationTypeName(message.Type)
|
|
||||||
: vesselTypeFromAis(message, staticData),
|
|
||||||
destination: stringValue(message.Destination ?? staticData.destination),
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
length: staticData.length ?? null,
|
length: staticData.length ?? null,
|
||||||
beam: staticData.beam ?? null,
|
beam: staticData.beam ?? null,
|
||||||
@@ -143,9 +131,6 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
// Use the AIS message's own report time, not server ingest wall-clock —
|
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||||
// trail spacing and dead reckoning depend on true fix epochs.
|
// trail spacing and dead reckoning depend on true fix epochs.
|
||||||
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
// A virtual aid is broadcast where nothing physical stands. Only such a
|
|
||||||
// row carries the field, so ship rows stay their size.
|
|
||||||
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
|
|
||||||
_updatedAt: Date.now(),
|
_updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,15 +276,6 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An aid's whole name: an aid-to-navigation report carries the first 20
|
|
||||||
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
|
|
||||||
*/
|
|
||||||
function aidNameFromAis(message) {
|
|
||||||
const name = stringValue(message.Name);
|
|
||||||
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function vesselTypeFromAis(message, staticData = {}) {
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
return stringValue(
|
return stringValue(
|
||||||
message.Type ??
|
message.Type ??
|
||||||
@@ -309,25 +285,14 @@ function vesselTypeFromAis(message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The current rows, newest first, with every vessel ahead of every aid to
|
|
||||||
* navigation: a capped request never gives up a ship for a buoy.
|
|
||||||
*/
|
|
||||||
export function aisStreamRows(maxRows) {
|
export function aisStreamRows(maxRows) {
|
||||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
const vessels = [];
|
const rows = [];
|
||||||
const aids = [];
|
|
||||||
for (const row of _aisStreamVessels.values()) {
|
for (const row of _aisStreamVessels.values()) {
|
||||||
if (row._updatedAt >= cutoff)
|
if (row._updatedAt >= cutoff) rows.push(row);
|
||||||
(isAidToNavigation(row) ? aids : vessels).push(row);
|
|
||||||
}
|
}
|
||||||
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
||||||
vessels.sort(newestFirst);
|
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
||||||
if (vessels.length < maxRows) aids.sort(newestFirst);
|
|
||||||
return vessels
|
|
||||||
.concat(aids)
|
|
||||||
.slice(0, maxRows)
|
|
||||||
.map(({ _updatedAt, ...row }) => row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisStreamCache() {
|
function pruneAisStreamCache() {
|
||||||
|
|||||||
@@ -400,27 +400,6 @@ 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 () => {
|
test('the live preview hangs at the height the click landed on', async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function createApplicationCatalog({
|
|||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
createApplicationBikeshare({ surface, source: sources.bikeshare }),
|
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||||
createApplicationDirections({ source: sources.directions }),
|
createApplicationDirections({ source: sources.directions }),
|
||||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||||
createApplicationOsmPlaces(),
|
createApplicationOsmPlaces(),
|
||||||
@@ -205,10 +205,7 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
createApplicationCyclones({ feed: sources.cyclones }),
|
createApplicationCyclones({ feed: sources.cyclones }),
|
||||||
...createOsmOverlayLayers(),
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers({
|
...createInfrastructureLayers(localGeoJsonServices),
|
||||||
...localGeoJsonServices,
|
|
||||||
terrainSurface: surface.groundFloor.terrainSurface,
|
|
||||||
}),
|
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
createApplicationRail({
|
createApplicationRail({
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
Vendored
-2
@@ -11,8 +11,6 @@ import { t } from '../i18n/index.js';
|
|||||||
* whose address does not carry one. `localePicker: false` removes the language
|
* whose address does not carry one. `localePicker: false` removes the language
|
||||||
* picker, for a host that offers its own language choice (`switchLocale` from
|
* picker, for a host that offers its own language choice (`switchLocale` from
|
||||||
* the `./i18n` entry).
|
* the `./i18n` entry).
|
||||||
* `services.cockpitSubjects` adds the application's own first-person subjects
|
|
||||||
* to the Cockpit (src/cockpitSubjectProviders.js).
|
|
||||||
*/
|
*/
|
||||||
export function createApplicationControls({
|
export function createApplicationControls({
|
||||||
scene: { viewer, mapStackController, operations },
|
scene: { viewer, mapStackController, operations },
|
||||||
|
|||||||
+28
-11
@@ -94,6 +94,17 @@ export function panelAwareVisibility(doc = globalThis.document) {
|
|||||||
const PANEL_FRAME_MS = 33;
|
const PANEL_FRAME_MS = 33;
|
||||||
const MISSED_FRAMES_MS = 250;
|
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
|
* 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
|
* screen as hidden, which stops the browser's animation frames and with them
|
||||||
@@ -111,27 +122,33 @@ export function keepPanelRendering(
|
|||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
};
|
};
|
||||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||||
// Cesium stops its render loop after a render error, and the timer stops
|
// Cesium stops drawing after a render error, and so does the timer.
|
||||||
// with it; Retry restarts both. The render failure notice logs the error
|
// Report the error in full: workers report plain objects, which Cesium
|
||||||
// (src/ui/renderFailure.js).
|
// 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
const timer = windowRef.setInterval(() => {
|
const timer = windowRef.setInterval(() => {
|
||||||
if (
|
if (
|
||||||
viewer.useDefaultRenderLoop === false ||
|
failed ||
|
||||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||||
viewer.isDestroyed?.()
|
viewer.isDestroyed?.()
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
try {
|
viewer.resize();
|
||||||
viewer.resize();
|
viewer.render();
|
||||||
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);
|
}, PANEL_FRAME_MS);
|
||||||
return () => {
|
return () => {
|
||||||
windowRef.clearInterval(timer);
|
windowRef.clearInterval(timer);
|
||||||
windowRef.cancelAnimationFrame(frameRequest);
|
windowRef.cancelAnimationFrame(frameRequest);
|
||||||
|
removeErrorListener?.();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-14
@@ -457,7 +457,8 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
|
|||||||
assert.equal(frame, null);
|
assert.equal(frame, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a panel stops drawing while its render loop is stopped', () => {
|
test('a panel reports a render error in full and stops drawing', () => {
|
||||||
|
let listener = null;
|
||||||
let tick = null;
|
let tick = null;
|
||||||
const windowRef = {
|
const windowRef = {
|
||||||
requestAnimationFrame: () => 1,
|
requestAnimationFrame: () => 1,
|
||||||
@@ -466,19 +467,31 @@ test('a panel stops drawing while its render loop is stopped', () => {
|
|||||||
clearInterval() {},
|
clearInterval() {},
|
||||||
};
|
};
|
||||||
const drawn = [];
|
const drawn = [];
|
||||||
const viewer = { resize() {}, render: () => drawn.push('render') };
|
const viewer = {
|
||||||
let clock = 0;
|
resize() {},
|
||||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
render: () => drawn.push('render'),
|
||||||
// Cesium stops its render loop after a render error.
|
scene: {
|
||||||
viewer.useDefaultRenderLoop = false;
|
renderError: {
|
||||||
clock = 1000;
|
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||||
tick();
|
},
|
||||||
assert.deepEqual(drawn, []);
|
},
|
||||||
// Retry restarts it, and the timer draws again.
|
};
|
||||||
viewer.useDefaultRenderLoop = true;
|
const logged = [];
|
||||||
tick();
|
const original = console.error;
|
||||||
assert.deepEqual(drawn, ['render']);
|
console.error = (...args) => logged.push(args.join(' '));
|
||||||
stop();
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ import * as picking from '../../data/pickRegistry.js';
|
|||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationBikeshare({ source, surface }) {
|
export function createApplicationBikeshare({ source }) {
|
||||||
return createBikeshareLayer({
|
return createBikeshareLayer({
|
||||||
source,
|
source,
|
||||||
services: {
|
services: { render, sprites, picking, overlays },
|
||||||
render,
|
|
||||||
sprites,
|
|
||||||
picking,
|
|
||||||
overlays,
|
|
||||||
// Station heights come from the terrain surface every layer shares.
|
|
||||||
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// 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,8 +15,6 @@ import {
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { describeError } from './errors.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 { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||||
import { followWorldTime } from '../time/viewerClock.js';
|
import { followWorldTime } from '../time/viewerClock.js';
|
||||||
import { onLocaleChange, t } from '../i18n/index.js';
|
import { onLocaleChange, t } from '../i18n/index.js';
|
||||||
@@ -57,17 +55,11 @@ export async function createApplicationScene({
|
|||||||
const viewer = createApplicationViewer({
|
const viewer = createApplicationViewer({
|
||||||
container: 'cesiumContainer',
|
container: 'cesiumContainer',
|
||||||
creditContainer,
|
creditContainer,
|
||||||
// The render failure notice below replaces Cesium's dead-end panel.
|
|
||||||
showRenderLoopErrors: false,
|
|
||||||
});
|
});
|
||||||
defer(() => {
|
defer(() => {
|
||||||
uninstallRenderGovernor(viewer);
|
uninstallRenderGovernor(viewer);
|
||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
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(installTrackpadPinchZoom(viewer));
|
||||||
defer(
|
defer(
|
||||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||||
|
|||||||
+4
-16
@@ -375,15 +375,8 @@ export function installGlobeInViewRecovery(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Cesium.Viewer options of the standard globe viewer. */
|
||||||
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
|
export function applicationViewerOptions(creditContainer) {
|
||||||
* false leaves render errors to the caller's own notice instead of Cesium's
|
|
||||||
* panel.
|
|
||||||
*/
|
|
||||||
export function applicationViewerOptions(
|
|
||||||
creditContainer,
|
|
||||||
{ showRenderLoopErrors = true } = {},
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
timeline: false,
|
timeline: false,
|
||||||
animation: false,
|
animation: false,
|
||||||
@@ -403,21 +396,16 @@ export function applicationViewerOptions(
|
|||||||
creditContainer,
|
creditContainer,
|
||||||
msaaSamples: 4,
|
msaaSamples: 4,
|
||||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||||
showRenderLoopErrors,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||||
export function createApplicationViewer({
|
export function createApplicationViewer({ container, creditContainer }) {
|
||||||
container,
|
|
||||||
creditContainer,
|
|
||||||
showRenderLoopErrors,
|
|
||||||
}) {
|
|
||||||
if (!container || !creditContainer)
|
if (!container || !creditContainer)
|
||||||
throw new TypeError('Viewer and credit containers are required');
|
throw new TypeError('Viewer and credit containers are required');
|
||||||
const viewer = new Cesium.Viewer(
|
const viewer = new Cesium.Viewer(
|
||||||
container,
|
container,
|
||||||
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
|
applicationViewerOptions(creditContainer),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
viewer.targetFrameRate = 60;
|
viewer.targetFrameRate = 60;
|
||||||
|
|||||||
+2
-68
@@ -32,15 +32,8 @@ export function slewHeading(current, target, maxStepDeg) {
|
|||||||
* then uses this amount to converge on the layer's delayed display position.
|
* then uses this amount to converge on the layer's delayed display position.
|
||||||
* Capping correction below forward speed prevents a late feed fix from
|
* Capping correction below forward speed prevents a late feed fix from
|
||||||
* becoming a first-person surge or reversal while still removing drift.
|
* 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(
|
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||||
distanceM,
|
|
||||||
speedMps,
|
|
||||||
dtSec,
|
|
||||||
maxDtSec = 0.1,
|
|
||||||
) {
|
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(distanceM) ||
|
!Number.isFinite(distanceM) ||
|
||||||
distanceM <= 0 ||
|
distanceM <= 0 ||
|
||||||
@@ -48,36 +41,13 @@ export function cockpitAnchorCorrectionStep(
|
|||||||
dtSec <= 0
|
dtSec <= 0
|
||||||
)
|
)
|
||||||
return 0;
|
return 0;
|
||||||
const dt = Math.min(
|
const dt = Math.min(0.1, dtSec);
|
||||||
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
|
|
||||||
dtSec,
|
|
||||||
);
|
|
||||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||||
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
||||||
const correctionRateMps = Math.max(0.75, speed * 0.22);
|
const correctionRateMps = Math.max(0.75, speed * 0.22);
|
||||||
return Math.min(distanceM, eased, correctionRateMps * dt);
|
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. */
|
/** Return whether a throttled cockpit presentation update is due. */
|
||||||
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
||||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
||||||
@@ -98,42 +68,6 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
|||||||
return nowMs - startedMs >= timeoutMs;
|
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.
|
* Keep the cockpit camera above the shared rendered-surface floor.
|
||||||
* Unknown floors preserve the proposed camera height.
|
* Unknown floors preserve the proposed camera height.
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import {
|
|||||||
cockpitAnchorCorrectionStep,
|
cockpitAnchorCorrectionStep,
|
||||||
cockpitAltitudeDisplayFt,
|
cockpitAltitudeDisplayFt,
|
||||||
cockpitGroundSafeHeight,
|
cockpitGroundSafeHeight,
|
||||||
cockpitSubjectStepSec,
|
|
||||||
cockpitSurfaceWaitExpired,
|
cockpitSurfaceWaitExpired,
|
||||||
cockpitUiUpdateDue,
|
cockpitUiUpdateDue,
|
||||||
COCKPIT_SUBJECT_LIFT_MPS,
|
|
||||||
easeCockpitLift,
|
|
||||||
compassDivisions,
|
compassDivisions,
|
||||||
compassMarkerSlot,
|
compassMarkerSlot,
|
||||||
scrollingCompassDivisions,
|
scrollingCompassDivisions,
|
||||||
@@ -59,28 +56,6 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
|
|||||||
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
|
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', () => {
|
test('cockpit camera clearance never falls below the rendered ground floor', () => {
|
||||||
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
|
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
|
||||||
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
|
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
|
||||||
@@ -460,30 +435,3 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
|
|||||||
assert.equal(formatCockpitEmergency(null), null);
|
assert.equal(formatCockpitEmergency(null), null);
|
||||||
assert.equal(formatCockpitEmergency({ label: '' }), 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);
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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 || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,644 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
@@ -1,698 +0,0 @@
|
|||||||
// 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,24 +481,6 @@ 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.
|
* Internal context-mode id → the word the voice tools accept for it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
|||||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||||
// A parked or slow aircraft reports no ground track, but often its true
|
const track = finiteNumber(aircraft?.track);
|
||||||
// heading (surface messages): the way its nose points, which is what an
|
|
||||||
// aircraft standing still is drawn by. Magnetic heading is not true north.
|
|
||||||
const track =
|
|
||||||
finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
hex,
|
hex,
|
||||||
|
|||||||
@@ -49,15 +49,3 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
|
|||||||
assert.equal(normalized.states[0][7], null);
|
assert.equal(normalized.states[0][7], null);
|
||||||
assert.equal(normalized.states[0][8], true);
|
assert.equal(normalized.states[0][8], true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an aircraft with no track takes its true heading, never its magnetic one', () => {
|
|
||||||
const at = (extra) =>
|
|
||||||
normalizeAdsbLolAircraftState(
|
|
||||||
{ hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra },
|
|
||||||
1000,
|
|
||||||
)[10];
|
|
||||||
assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first');
|
|
||||||
assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading');
|
|
||||||
assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic');
|
|
||||||
assert.equal(at({}), null, 'none');
|
|
||||||
});
|
|
||||||
@@ -458,8 +458,7 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
|||||||
);
|
);
|
||||||
flightState._trackedIcao = null;
|
flightState._trackedIcao = null;
|
||||||
flightState.records.data.set('civil', { emergency: null });
|
flightState.records.data.set('civil', { emergency: null });
|
||||||
// An ordinary model draws its mesh's own colours, whatever the billboard's.
|
assert.equal(rendering._modelColor('civil'), normal);
|
||||||
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||||
* Flights, Local ADS-B).
|
* Flights, Local ADS-B).
|
||||||
@@ -8,24 +6,12 @@ import * as Cesium from 'cesium';
|
|||||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||||
* when the model is first placed and again when it moves more than 1 km, when
|
* when the model is first placed and again when it moves more than 1 km, when
|
||||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||||
* lighting. Without it they are lit from Cesium's default spherical
|
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||||
* harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the
|
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||||
* scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms.
|
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||||
|
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||||
|
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||||
*/
|
*/
|
||||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so
|
|
||||||
* white draws the mesh's own colours as built, and the tracked or emergency
|
|
||||||
* colour tints the mesh without hiding its paint. The IR boost draws the
|
|
||||||
* model flat white instead (MIX at 1, with its unlit shader).
|
|
||||||
* @param {boolean} irBoost
|
|
||||||
* @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}}
|
|
||||||
*/
|
|
||||||
export function aircraftModelBlend(irBoost) {
|
|
||||||
return irBoost
|
|
||||||
? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 }
|
|
||||||
: { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 };
|
|
||||||
}
|
|
||||||
@@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||||
* changing the model's colour blend mode or amount.
|
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||||
* @param {object} input
|
* @param {object} input
|
||||||
* @param {object} input.model
|
* @param {object} input.model
|
||||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||||
|
|||||||
@@ -1882,57 +1882,3 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
|||||||
_setVesselStateForTest({ enabled: false });
|
_setVesselStateForTest({ enabled: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an aid to navigation card shows its kind, never a speed or heading', () => {
|
|
||||||
const aid = makeRecord({
|
|
||||||
name: 'SF APPROACH N',
|
|
||||||
mmsi: '993672001',
|
|
||||||
type: 'Cardinal Mark N',
|
|
||||||
speed: 0,
|
|
||||||
course: 135,
|
|
||||||
heading: 135,
|
|
||||||
});
|
|
||||||
const card = buildVesselCard(aid);
|
|
||||||
assert.deepEqual(card.details, ['CARDINAL MARK N']);
|
|
||||||
assert.equal(card.accent, '217, 210, 192');
|
|
||||||
const selected = buildSelectedVesselCard(aid);
|
|
||||||
assert.equal(selected.details[0], 'CARDINAL MARK N');
|
|
||||||
assert.equal(selected.accent, '217, 210, 192');
|
|
||||||
// Known only by its MMSI, an aid is named as one.
|
|
||||||
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
|
|
||||||
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
|
|
||||||
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
|
|
||||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
|
||||||
try {
|
|
||||||
Object.assign(harness.record, {
|
|
||||||
type: 'Port hand Mark',
|
|
||||||
speed: 0,
|
|
||||||
heading: 135,
|
|
||||||
course: 135,
|
|
||||||
});
|
|
||||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
|
||||||
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
|
|
||||||
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
|
|
||||||
harness.record.virtual = true;
|
|
||||||
aisLiveVesselsLayer.selectById(harness.record.mmsi);
|
|
||||||
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
|
|
||||||
} finally {
|
|
||||||
harness.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a virtual aid to navigation card says it is virtual', () => {
|
|
||||||
const aid = makeRecord({
|
|
||||||
mmsi: '993672001',
|
|
||||||
type: 'Isolated danger',
|
|
||||||
virtual: true,
|
|
||||||
});
|
|
||||||
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
|
|
||||||
assert.equal(
|
|
||||||
buildSelectedVesselCard(aid).details[0],
|
|
||||||
'ISOLATED DANGER · VIRTUAL',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -316,97 +316,3 @@ test('a Class B report carries its dimensions in part B', () => {
|
|||||||
assert.equal(row.length, 12);
|
assert.equal(row.length, 12);
|
||||||
assert.equal(row.beam, 4);
|
assert.equal(row.beam, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
|
|
||||||
assert.equal(
|
|
||||||
ingestAisStreamEnvelope({
|
|
||||||
MessageType: 'AidsToNavigationReport',
|
|
||||||
Message: {
|
|
||||||
AidsToNavigationReport: {
|
|
||||||
UserID: 993672001,
|
|
||||||
Type: 20,
|
|
||||||
Name: 'SF APPROACH N',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MetaData: {
|
|
||||||
MMSI: '993672001',
|
|
||||||
latitude: 37.79,
|
|
||||||
longitude: -122.52,
|
|
||||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const row = rowFor('993672001');
|
|
||||||
assert.equal(row.type, 'Cardinal Mark N');
|
|
||||||
assert.equal(row.name, 'SF APPROACH N');
|
|
||||||
assert.equal(row.speed, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an aid's name runs on into its name extension", () => {
|
|
||||||
ingestAisStreamEnvelope({
|
|
||||||
MessageType: 'AidsToNavigationReport',
|
|
||||||
Message: {
|
|
||||||
AidsToNavigationReport: {
|
|
||||||
UserID: 993672002,
|
|
||||||
Type: 13,
|
|
||||||
Name: 'SAN FRANCISCO MAIN S',
|
|
||||||
NameExtension: 'HIP CHANNEL 2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MetaData: {
|
|
||||||
MMSI: '993672002',
|
|
||||||
ShipName: 'SAN FRANCISCO MAIN S',
|
|
||||||
latitude: 37.79,
|
|
||||||
longitude: -122.52,
|
|
||||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
|
|
||||||
// The aid reports last, so it is the newest row.
|
|
||||||
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
|
|
||||||
ingestAisStreamEnvelope({
|
|
||||||
MessageType: 'AidsToNavigationReport',
|
|
||||||
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
|
|
||||||
MetaData: {
|
|
||||||
MMSI: '993672003',
|
|
||||||
latitude: 37.79,
|
|
||||||
longitude: -122.52,
|
|
||||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const rows = aisStreamRows(50000);
|
|
||||||
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
|
|
||||||
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
|
|
||||||
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
|
|
||||||
const vesselCount =
|
|
||||||
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
|
|
||||||
assert.ok(
|
|
||||||
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
|
|
||||||
const aidReport = (mmsi, VirtualAtoN) => ({
|
|
||||||
MessageType: 'AidsToNavigationReport',
|
|
||||||
Message: {
|
|
||||||
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
|
|
||||||
},
|
|
||||||
MetaData: {
|
|
||||||
MMSI: mmsi,
|
|
||||||
latitude: 37.79,
|
|
||||||
longitude: -122.52,
|
|
||||||
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
ingestAisStreamEnvelope(aidReport('993672004', true));
|
|
||||||
ingestAisStreamEnvelope(aidReport('993672005', false));
|
|
||||||
assert.equal(rowFor('993672004').virtual, true);
|
|
||||||
assert.equal('virtual' in rowFor('993672005'), false);
|
|
||||||
// A later real report of the same aid drops the flag.
|
|
||||||
ingestAisStreamEnvelope(aidReport('993672004', false));
|
|
||||||
assert.equal('virtual' in rowFor('993672004'), false);
|
|
||||||
});
|
|
||||||
+139
-11
@@ -51,6 +51,7 @@ import {
|
|||||||
} from './detectionPolicy.js';
|
} from './detectionPolicy.js';
|
||||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||||
|
import { createDetectionPaintCache } from './detectionPaintCache.js';
|
||||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||||
import {
|
import {
|
||||||
createCyberSonarSampler,
|
createCyberSonarSampler,
|
||||||
@@ -243,6 +244,15 @@ let _hostLane = null;
|
|||||||
let _calloutLane = null;
|
let _calloutLane = null;
|
||||||
let _transitBracketPaths = new Map();
|
let _transitBracketPaths = new Map();
|
||||||
let _transitBracketAlpha = 1;
|
let _transitBracketAlpha = 1;
|
||||||
|
/**
|
||||||
|
* What the last full sweep of the objects built, kept for the paints that
|
||||||
|
* follow it with the same view and the same objects. See `_drawOverlay`.
|
||||||
|
*/
|
||||||
|
const _paintCache = createDetectionPaintCache();
|
||||||
|
/** Scratch for `_paintViewOf`; the cache copies it when it keeps a paint. */
|
||||||
|
const _paintView = [];
|
||||||
|
/** Paints since `initDetection` that reused a kept sweep (diagnostics). */
|
||||||
|
let _paintReuseCount = 0;
|
||||||
/**
|
/**
|
||||||
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
||||||
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||||
@@ -379,6 +389,8 @@ export function destroyDetection() {
|
|||||||
_calloutLane?.unregister?.();
|
_calloutLane?.unregister?.();
|
||||||
_calloutLane = null;
|
_calloutLane = null;
|
||||||
_transitBracketPaths.clear();
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.clear();
|
||||||
|
_paintReuseCount = 0;
|
||||||
_calloutCount = 0;
|
_calloutCount = 0;
|
||||||
_viewer = null;
|
_viewer = null;
|
||||||
_layers = [];
|
_layers = [];
|
||||||
@@ -643,6 +655,7 @@ function _publishDiagnostics() {
|
|||||||
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
||||||
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
||||||
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
||||||
|
dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -718,6 +731,7 @@ function _applyModeState() {
|
|||||||
_syncSurfaceVisibility();
|
_syncSurfaceVisibility();
|
||||||
_setLanesActive(false);
|
_setLanesActive(false);
|
||||||
_labelArbiter.clear();
|
_labelArbiter.clear();
|
||||||
|
_paintCache.clear();
|
||||||
_surfaceOcclusion.clear();
|
_surfaceOcclusion.clear();
|
||||||
_surfaceSuppressedKeys.clear();
|
_surfaceSuppressedKeys.clear();
|
||||||
_occlusionProbe.reset();
|
_occlusionProbe.reset();
|
||||||
@@ -1259,15 +1273,53 @@ function _materializeCandidate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every value of the view that the object sweep in `_drawOverlay` and the
|
||||||
|
* callout candidates it builds read, in a fixed order, for
|
||||||
|
* `detectionPaintCache`. A value the sweep starts to read belongs here.
|
||||||
|
*/
|
||||||
|
function _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) {
|
||||||
|
const view = _paintView;
|
||||||
|
const { keyhole, cameraPosition, viewProjectionMatrix, uiRects } = frame;
|
||||||
|
let n = 0;
|
||||||
|
view[n++] = _mode;
|
||||||
|
view[n++] = _theme;
|
||||||
|
view[n++] = _charWidth;
|
||||||
|
view[n++] = frame.width;
|
||||||
|
view[n++] = frame.height;
|
||||||
|
view[n++] = keyholeOutsideOpacity;
|
||||||
|
view[n++] = cyberMapActive;
|
||||||
|
view[n++] = keyhole?.centerX;
|
||||||
|
view[n++] = keyhole?.centerY;
|
||||||
|
view[n++] = keyhole?.radius;
|
||||||
|
view[n++] = keyhole?.featherPx;
|
||||||
|
view[n++] = cameraPosition?.x;
|
||||||
|
view[n++] = cameraPosition?.y;
|
||||||
|
view[n++] = cameraPosition?.z;
|
||||||
|
for (let i = 0; i < 16; i++) view[n++] = viewProjectionMatrix[i];
|
||||||
|
const rectCount = Array.isArray(uiRects) ? uiRects.length : -1;
|
||||||
|
view[n++] = rectCount;
|
||||||
|
for (let i = 0; i < rectCount; i++) {
|
||||||
|
const rect = uiRects[i];
|
||||||
|
view[n++] = rect?.x;
|
||||||
|
view[n++] = rect?.y;
|
||||||
|
view[n++] = rect?.w;
|
||||||
|
view[n++] = rect?.h;
|
||||||
|
}
|
||||||
|
view.length = n;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paint detection against the shared host frame. Brackets remain broad and
|
* Paint detection against the shared host frame. Brackets remain broad and
|
||||||
* per-frame; rich placements rebuild only for selected/fading identities and
|
* per-frame; rich placements rebuild only for selected/fading identities and
|
||||||
* for a bounded cohort on solve ticks.
|
* for a bounded cohort on solve ticks.
|
||||||
*/
|
*/
|
||||||
function _drawOverlay(frame) {
|
function _drawOverlay(frame) {
|
||||||
_transitBracketPaths.clear();
|
|
||||||
const { width, height } = frame;
|
const { width, height } = frame;
|
||||||
if (!_ctx || width <= 0 || height <= 0) {
|
if (!_ctx || width <= 0 || height <= 0) {
|
||||||
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.forget();
|
||||||
return {
|
return {
|
||||||
didSolve: false,
|
didSolve: false,
|
||||||
solveMs: 0,
|
solveMs: 0,
|
||||||
@@ -1309,6 +1361,8 @@ function _drawOverlay(frame) {
|
|||||||
// callout lane repaints the last solve on frames the sensor lane skips,
|
// callout lane repaints the last solve on frames the sensor lane skips,
|
||||||
// leaving rows here would strand the final callsigns on screen after the
|
// leaving rows here would strand the final callsigns on screen after the
|
||||||
// last data layer is switched off.
|
// last data layer is switched off.
|
||||||
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.clear();
|
||||||
_calloutCount = 0;
|
_calloutCount = 0;
|
||||||
_ctx.font = FONT;
|
_ctx.font = FONT;
|
||||||
_drawScanlines(width, height, now);
|
_drawScanlines(width, height, now);
|
||||||
@@ -1361,6 +1415,9 @@ function _drawOverlay(frame) {
|
|||||||
// every host lane. Detection retains the manual scalar projection below.
|
// every host lane. Detection retains the manual scalar projection below.
|
||||||
const occluder = frame.occluder;
|
const occluder = frame.occluder;
|
||||||
const camPos = frame.cameraPosition;
|
const camPos = frame.cameraPosition;
|
||||||
|
const camX = camPos.x;
|
||||||
|
const camY = camPos.y;
|
||||||
|
const camZ = camPos.z;
|
||||||
// Answers to the occlusion tests started on earlier frames land here.
|
// Answers to the occlusion tests started on earlier frames land here.
|
||||||
_surfaceOcclusion.beginFrame(now);
|
_surfaceOcclusion.beginFrame(now);
|
||||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||||
@@ -1390,7 +1447,7 @@ function _drawOverlay(frame) {
|
|||||||
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
||||||
const tiers = _theme.tiers || null;
|
const tiers = _theme.tiers || null;
|
||||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||||
const bracketPaths = new Map();
|
let bracketPaths = new Map();
|
||||||
// Global Context sonar echoes: a second bracket, banded by strength, around
|
// Global Context sonar echoes: a second bracket, banded by strength, around
|
||||||
// each flight or vessel a pulse front has just crossed.
|
// each flight or vessel a pulse front has just crossed.
|
||||||
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
||||||
@@ -1415,14 +1472,41 @@ function _drawOverlay(frame) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
||||||
const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
let aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
||||||
let protectedVisibleCount = 0;
|
let protectedVisibleCount = 0;
|
||||||
const candidateMap = new Map();
|
let candidateMap = new Map();
|
||||||
const cohortBuilders = shouldSolve ? new Map() : null;
|
const cohortBuilders = shouldSolve ? new Map() : null;
|
||||||
const demandByLayer = shouldSolve ? new Map() : null;
|
const demandByLayer = shouldSolve ? new Map() : null;
|
||||||
let placementBuildCount = 0;
|
let placementBuildCount = 0;
|
||||||
for (let i = 0; i < objects.length; i++) {
|
|
||||||
|
// The sweep below is the cost of this paint: every object projected, faded
|
||||||
|
// and given its bracket. What it builds depends only on the view and on the
|
||||||
|
// objects, so when both are exactly what the last sweep had, that sweep's
|
||||||
|
// paths, counts and callout candidates are used again and only the strokes
|
||||||
|
// and the callouts are repainted. Three things keep a paint from being
|
||||||
|
// reused or kept:
|
||||||
|
// - the Cyber sonar sweep and Global Context echoes change brackets with the
|
||||||
|
// clock alone;
|
||||||
|
// - a solve needs the sweep to gather its cohort;
|
||||||
|
// - a depth-tested contact gets its answer from the scene, not from here.
|
||||||
|
// A kept solve paint holds the solve's own candidates too, so the callouts a
|
||||||
|
// solve has just chosen are placed from the same map either way.
|
||||||
|
const paintKeepable = !sonarActive && !echoField;
|
||||||
|
const paintView = paintKeepable
|
||||||
|
? _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive)
|
||||||
|
: null;
|
||||||
|
const kept =
|
||||||
|
paintKeepable && !shouldSolve
|
||||||
|
? _paintCache.reuse(paintView, objects)
|
||||||
|
: null;
|
||||||
|
let depthTestedSwept = false;
|
||||||
|
let identityLayerId;
|
||||||
|
let renderSources;
|
||||||
|
let selectedSources;
|
||||||
|
if (!kept) _transitBracketPaths.clear();
|
||||||
|
const sweepCount = kept ? 0 : objects.length;
|
||||||
|
for (let i = 0; i < sweepCount; i++) {
|
||||||
const obj = objects[i];
|
const obj = objects[i];
|
||||||
if (!obj.position) continue;
|
if (!obj.position) continue;
|
||||||
// Skip objects occluded by the ellipsoid (behind the horizon)
|
// Skip objects occluded by the ellipsoid (behind the horizon)
|
||||||
@@ -1450,8 +1534,12 @@ function _drawOverlay(frame) {
|
|||||||
let halfW;
|
let halfW;
|
||||||
let halfH;
|
let halfH;
|
||||||
if (obj.type === 'AIR') {
|
if (obj.type === 'AIR') {
|
||||||
|
// Cartesian3.distance, without its two argument checks per object.
|
||||||
|
const dx = camX - px;
|
||||||
|
const dy = camY - py;
|
||||||
|
const dz = camZ - pz;
|
||||||
const bscale = nearFarScale(
|
const bscale = nearFarScale(
|
||||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
Math.sqrt(dx * dx + dy * dy + dz * dz),
|
||||||
BILL_NEAR,
|
BILL_NEAR,
|
||||||
BILL_NEAR_SCALE,
|
BILL_NEAR_SCALE,
|
||||||
BILL_FAR,
|
BILL_FAR,
|
||||||
@@ -1477,6 +1565,7 @@ function _drawOverlay(frame) {
|
|||||||
// Something the scene drew in front of a depth-tested contact hides its
|
// Something the scene drew in front of a depth-tested contact hides its
|
||||||
// bracket and callout too, on every frame, not only on solves.
|
// bracket and callout too, on every frame, not only on solves.
|
||||||
if (obj.depthTested === true) {
|
if (obj.depthTested === true) {
|
||||||
|
depthTestedSwept = true;
|
||||||
const testedLayerId = obj._layerId || 'unknown';
|
const testedLayerId = obj._layerId || 'unknown';
|
||||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||||
const hidden = _surfaceOcclusion.isHidden(
|
const hidden = _surfaceOcclusion.isHidden(
|
||||||
@@ -1557,14 +1646,29 @@ function _drawOverlay(frame) {
|
|||||||
if (!primary && !micro) continue;
|
if (!primary && !micro) continue;
|
||||||
const layerId = obj._layerId || 'unknown';
|
const layerId = obj._layerId || 'unknown';
|
||||||
const sourceId = obj.sourceId ?? obj.id ?? i;
|
const sourceId = obj.sourceId ?? obj.id ?? i;
|
||||||
const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false;
|
// Objects arrive a layer at a time, so the layer's sets are looked up once
|
||||||
|
// per run of objects, not once per object.
|
||||||
|
if (layerId !== identityLayerId) {
|
||||||
|
identityLayerId = layerId;
|
||||||
|
renderSources = renderIdentities.get(layerId);
|
||||||
|
selectedSources = selectedIdentities?.get(layerId);
|
||||||
|
}
|
||||||
|
const liveForRender = renderSources?.has(sourceId) || false;
|
||||||
if (!shouldSolve && !liveForRender) continue;
|
if (!shouldSolve && !liveForRender) continue;
|
||||||
|
|
||||||
obj._cohortSourceId = sourceId;
|
obj._cohortSourceId = sourceId;
|
||||||
obj._cohortPriority = _semanticPriority(obj);
|
obj._cohortPriority = _semanticPriority(obj);
|
||||||
obj._cohortBand =
|
obj._cohortBand =
|
||||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
// The hash is of the identity alone, so it is worked out once per identity.
|
||||||
|
if (
|
||||||
|
obj._cohortHashLayerId !== layerId ||
|
||||||
|
obj._cohortHashSourceId !== sourceId
|
||||||
|
) {
|
||||||
|
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||||
|
obj._cohortHashLayerId = layerId;
|
||||||
|
obj._cohortHashSourceId = sourceId;
|
||||||
|
}
|
||||||
obj._candidateScreenX = sx;
|
obj._candidateScreenX = sx;
|
||||||
obj._candidateScreenY = sy;
|
obj._candidateScreenY = sy;
|
||||||
obj._candidateHalfW = halfW;
|
obj._candidateHalfW = halfW;
|
||||||
@@ -1578,8 +1682,7 @@ function _drawOverlay(frame) {
|
|||||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||||
if (!cohortBuilders.has(layerId))
|
if (!cohortBuilders.has(layerId))
|
||||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||||
const incumbent =
|
const incumbent = selectedSources?.has(sourceId) || false;
|
||||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
|
||||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1702,29 @@ function _drawOverlay(frame) {
|
|||||||
}
|
}
|
||||||
// Start this frame's occlusion tests; nothing here waits for them.
|
// Start this frame's occlusion tests; nothing here waits for them.
|
||||||
_occlusionProbe.endFrame();
|
_occlusionProbe.endFrame();
|
||||||
|
if (kept) {
|
||||||
|
_paintReuseCount++;
|
||||||
|
({
|
||||||
|
bracketPaths,
|
||||||
|
visibleCount,
|
||||||
|
bracketOpacityCounts,
|
||||||
|
aircraftBracketSectors,
|
||||||
|
protectedVisibleCount,
|
||||||
|
candidateMap,
|
||||||
|
} = kept);
|
||||||
|
} else if (paintKeepable && !depthTestedSwept) {
|
||||||
|
// `candidateMap` is kept by reference: a solve below adds to it.
|
||||||
|
_paintCache.keep(paintView, objects, {
|
||||||
|
bracketPaths,
|
||||||
|
visibleCount,
|
||||||
|
bracketOpacityCounts,
|
||||||
|
aircraftBracketSectors,
|
||||||
|
protectedVisibleCount,
|
||||||
|
candidateMap,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_paintCache.forget();
|
||||||
|
}
|
||||||
|
|
||||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||||
@@ -1782,6 +1908,7 @@ function _drawOverlay(frame) {
|
|||||||
// drawing them cost it.
|
// drawing them cost it.
|
||||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||||
|
paintReuseCount: _paintReuseCount,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||||
@@ -1795,6 +1922,7 @@ function _drawOverlay(frame) {
|
|||||||
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
||||||
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
||||||
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
||||||
|
_lastDiagnostics.paintReuseCount = _paintReuseCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
_drawScanlines(width, height, now);
|
_drawScanlines(width, height, now);
|
||||||
|
|||||||
@@ -1661,3 +1661,234 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
|||||||
env.cleanup();
|
env.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Reusing a paint -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contacts as the fleet layers return them: the same objects on every paint,
|
||||||
|
* changed in place. `fresh` returns equal copies instead, which no paint can
|
||||||
|
* reuse, so the two can be compared.
|
||||||
|
*/
|
||||||
|
function steadyContacts({ fresh = false } = {}) {
|
||||||
|
const objects = [
|
||||||
|
{ position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), sourceId: 'air-1', id: 'AIRONE', metric: 'FL350', type: 'AIR' },
|
||||||
|
{ position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752), sourceId: 'sat-1', id: 'SATONE', metric: '412KM', type: 'SAT' },
|
||||||
|
{ position: new Cesium.Cartesian3(0.1, 0.5, 6_356_752), sourceId: 'sea-1', id: 'SEAONE', metric: '12 kn', type: 'SEA' },
|
||||||
|
{ position: new Cesium.Cartesian3(-0.2, -0.5, 6_356_752), sourceId: 'mil-1', id: 'MILONE', metric: 'FL410', type: 'AIR', tier: 'military' },
|
||||||
|
];
|
||||||
|
let calls = 0;
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
get calls() { return calls; },
|
||||||
|
layer: {
|
||||||
|
id: 'flights',
|
||||||
|
getDetectableObjects() {
|
||||||
|
calls += 1;
|
||||||
|
return fresh
|
||||||
|
? objects.map((object) => ({ ...object, position: Cesium.Cartesian3.clone(object.position) }))
|
||||||
|
: objects;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything painted since the trace was last emptied, with each path as its points. */
|
||||||
|
function takePainted(env) {
|
||||||
|
const painted = env.paintTrace.map((call) =>
|
||||||
|
call.map((value) => (value instanceof MockPath2D ? value.points.map((point) => [...point]) : value)));
|
||||||
|
env.paintTrace.length = 0;
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bracketPointCount = (env) => env.detectionCtx.calls
|
||||||
|
.filter(([name]) => name === 'stroke')
|
||||||
|
.reduce((sum, [, path]) => sum + (path?.points?.length ?? 0), 0);
|
||||||
|
|
||||||
|
test('a paint with the same view and the same contacts reuses the last sweep, and paints exactly what a full sweep paints', () => {
|
||||||
|
// The same frames twice: once with contacts a paint can reuse, once with
|
||||||
|
// equal copies it cannot. Every canvas call must match, frame for frame.
|
||||||
|
const frames = (fresh) => {
|
||||||
|
const env = installEnvironment();
|
||||||
|
const contacts = steadyContacts({ fresh });
|
||||||
|
const painted = [];
|
||||||
|
try {
|
||||||
|
initWorldOverlay(env.viewer);
|
||||||
|
initDetection(env.viewer, [contacts.layer], () => {});
|
||||||
|
setMode('DENSE');
|
||||||
|
settleFrame(env);
|
||||||
|
painted.push(takePainted(env));
|
||||||
|
for (let frame = 1; frame <= 40; frame++) {
|
||||||
|
// Through the run: a contact moves, one is relabelled, one changes
|
||||||
|
// tier, and the camera turns.
|
||||||
|
if (frame === 9) contacts.objects[0].position.x += 0.05;
|
||||||
|
if (frame === 17) contacts.objects[1].metric = '398KM';
|
||||||
|
if (frame === 23) contacts.objects[2].tier = 'military';
|
||||||
|
if (frame === 31) env.viewer.camera.viewMatrix[12] = 0.03;
|
||||||
|
nextFrame(env);
|
||||||
|
painted.push(takePainted(env));
|
||||||
|
}
|
||||||
|
return { painted, reused: getDetectionDiagnostics().paintReuseCount };
|
||||||
|
} finally {
|
||||||
|
env.cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reusing = frames(false);
|
||||||
|
const sweeping = frames(true);
|
||||||
|
assert.equal(sweeping.reused, 0, 'copies are swept on every paint');
|
||||||
|
// 40 frames 16 ms apart: a solve about every eighth, four changes, and the
|
||||||
|
// rest reuse the sweep before them.
|
||||||
|
assert.ok(reusing.reused >= 28, `${reusing.reused} of 40 paints reused a sweep`);
|
||||||
|
assert.equal(reusing.painted.length, sweeping.painted.length);
|
||||||
|
reusing.painted.forEach((frame, index) => {
|
||||||
|
assert.ok(frame.some(([target, name]) => target === 'detection' && name === 'stroke'), `frame ${index} strokes brackets`);
|
||||||
|
assert.deepEqual(frame, sweeping.painted[index], `frame ${index}`);
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
reusing.painted.some((frame) => frame.some(([, name, text]) => name === 'fillText' && text === 'AIRONE')),
|
||||||
|
'callouts were painted during the run',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reused paint builds no bracket paths and still strokes the kept ones', () => {
|
||||||
|
const env = installEnvironment();
|
||||||
|
const contacts = steadyContacts();
|
||||||
|
const builtPaths = [];
|
||||||
|
class CountingPath2D extends MockPath2D {
|
||||||
|
constructor() { super(); builtPaths.push(this); }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
globalThis.Path2D = CountingPath2D;
|
||||||
|
initWorldOverlay(env.viewer);
|
||||||
|
initDetection(env.viewer, [contacts.layer], () => {});
|
||||||
|
setMode('DENSE');
|
||||||
|
settleFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 0);
|
||||||
|
const built = builtPaths.length;
|
||||||
|
const points = builtPaths.reduce((sum, path) => sum + path.points.length, 0);
|
||||||
|
assert.ok(points >= 4 * 12, 'four brackets of twelve points were built');
|
||||||
|
|
||||||
|
env.detectionCtx.calls.length = 0;
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||||
|
assert.equal(builtPaths.length, built, 'no path was built');
|
||||||
|
assert.equal(builtPaths.reduce((sum, path) => sum + path.points.length, 0), points, 'no point was added to a kept path');
|
||||||
|
assert.equal(bracketPointCount(env), 4 * 12, 'the kept brackets are stroked');
|
||||||
|
assert.equal(getDetectionDiagnostics().visibleCount, 4);
|
||||||
|
assert.equal(getDetectionDiagnostics().placementBuildCount, 0, 'no placement was built on the reused paint');
|
||||||
|
} finally {
|
||||||
|
env.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('any change to a contact or to the view is swept on the very next paint', () => {
|
||||||
|
const changes = {
|
||||||
|
'a contact moves': (contacts) => { contacts.objects[0].position.y -= 0.01; },
|
||||||
|
'a contact gets a new position object': (contacts) => {
|
||||||
|
contacts.objects[0].position = new Cesium.Cartesian3(0.3, 0.3, 6_356_752);
|
||||||
|
},
|
||||||
|
'a contact is renamed': (contacts) => { contacts.objects[1].id = 'SATTWO'; },
|
||||||
|
'a contact reading changes': (contacts) => { contacts.objects[0].metric = 'FL360'; },
|
||||||
|
'a contact becomes the tracked one': (contacts) => { contacts.objects[0].skipLabel = true; },
|
||||||
|
'a contact changes tier': (contacts) => { contacts.objects[3].tier = undefined; },
|
||||||
|
'a contact changes type': (contacts) => { contacts.objects[2].type = 'VEH'; },
|
||||||
|
'a contact joins': (contacts) => {
|
||||||
|
contacts.objects.push({ position: new Cesium.Cartesian3(0.6, 0.6, 6_356_752), sourceId: 'air-2', id: 'AIRTWO', type: 'AIR' });
|
||||||
|
},
|
||||||
|
'a contact leaves': (contacts) => { contacts.objects.pop(); },
|
||||||
|
'a contact is replaced by an equal one': (contacts) => { contacts.objects[1] = { ...contacts.objects[1] }; },
|
||||||
|
'two contacts swap places in the list': (contacts) => { contacts.objects.reverse(); },
|
||||||
|
'the camera turns': (contacts, env) => { env.viewer.camera.viewMatrix[13] = 0.02; },
|
||||||
|
'the camera moves': (contacts, env) => {
|
||||||
|
env.viewer.camera.positionWC = new Cesium.Cartesian3(0, 100, 10_000_000);
|
||||||
|
},
|
||||||
|
'the style changes': () => { setDetectionStyle('thermal'); },
|
||||||
|
'the profile changes': () => { setMode('SPARSE'); },
|
||||||
|
};
|
||||||
|
for (const [name, change] of Object.entries(changes)) {
|
||||||
|
const env = installEnvironment();
|
||||||
|
const contacts = steadyContacts();
|
||||||
|
try {
|
||||||
|
initWorldOverlay(env.viewer);
|
||||||
|
initDetection(env.viewer, [contacts.layer], () => {});
|
||||||
|
setDetectionStyle('normal');
|
||||||
|
setMode('DENSE');
|
||||||
|
settleFrame(env);
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: steady paints reuse`);
|
||||||
|
|
||||||
|
change(contacts, env);
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: swept again`);
|
||||||
|
assert.equal(getDetectionDiagnostics().observationCount, contacts.objects.length, name);
|
||||||
|
} finally {
|
||||||
|
env.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a depth-tested contact, the sonar sweep and echoes are swept on every paint', () => {
|
||||||
|
const run = ({ depthTested = false, sonar = false, echo = false } = {}) => {
|
||||||
|
const env = installEnvironment();
|
||||||
|
const contacts = steadyContacts();
|
||||||
|
if (depthTested) {
|
||||||
|
contacts.objects[0].type = 'VEH';
|
||||||
|
contacts.objects[0].depthTested = true;
|
||||||
|
}
|
||||||
|
if (sonar) {
|
||||||
|
env.document.documentElement = {
|
||||||
|
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
|
||||||
|
style: { setProperty() {}, removeProperty() {} },
|
||||||
|
};
|
||||||
|
const hud = env.document.createElement('div');
|
||||||
|
hud.id = 'intel-hud';
|
||||||
|
hud.classList = { contains: (className) => className === 'active' };
|
||||||
|
env.document.body.appendChild(hud);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (echo)
|
||||||
|
setAwarenessEchoField({
|
||||||
|
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||||
|
radiusM: 250_000,
|
||||||
|
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||||
|
echo: { layerIds: ['flights'], fadeMs: 60_000, color: 'rgb(1, 2, 3)', spreadPx: 8, lineWidth: 3.25 },
|
||||||
|
});
|
||||||
|
initWorldOverlay(env.viewer);
|
||||||
|
initDetection(env.viewer, [contacts.layer], () => {});
|
||||||
|
setMode('DENSE');
|
||||||
|
settleFrame(env);
|
||||||
|
const sweeps = contacts.calls;
|
||||||
|
for (let i = 0; i < 30; i++) nextFrame(env);
|
||||||
|
assert.ok(contacts.calls > sweeps, 'frames painted');
|
||||||
|
return getDetectionDiagnostics().paintReuseCount;
|
||||||
|
} finally {
|
||||||
|
clearAwarenessEchoField();
|
||||||
|
env.cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert.ok(run() > 15, 'the same contacts with none of these are reused');
|
||||||
|
assert.equal(run({ depthTested: true }), 0, 'depth-tested');
|
||||||
|
assert.equal(run({ sonar: true }), 0, 'sonar');
|
||||||
|
assert.equal(run({ echo: true }), 0, 'echo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turning detection off lets go of the kept sweep', () => {
|
||||||
|
const env = installEnvironment();
|
||||||
|
const contacts = steadyContacts();
|
||||||
|
try {
|
||||||
|
initWorldOverlay(env.viewer);
|
||||||
|
initDetection(env.viewer, [contacts.layer], () => {});
|
||||||
|
setMode('DENSE');
|
||||||
|
settleFrame(env);
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||||
|
setMode('OFF');
|
||||||
|
setMode('DENSE');
|
||||||
|
// The first paint back is a solve, and the one after it reuses that.
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||||
|
nextFrame(env);
|
||||||
|
assert.equal(getDetectionDiagnostics().paintReuseCount, 2);
|
||||||
|
} finally {
|
||||||
|
env.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* @module detectionPaintCache
|
||||||
|
* @description Lets the detection overlay keep what one paint built (bracket
|
||||||
|
* paths, counts, callout candidates) and use it again on the next paint when
|
||||||
|
* that paint would build exactly the same thing.
|
||||||
|
*
|
||||||
|
* Nothing announces a change to this cache. Each paint hands over the values
|
||||||
|
* the sweep reads from the view and the objects the layers returned, and the
|
||||||
|
* cache compares them, value by value, with what the kept paint was built
|
||||||
|
* from. A layer therefore cannot leave a stale picture behind by forgetting to
|
||||||
|
* say it changed: an object that moved, appeared, left or was relabelled is
|
||||||
|
* seen here, and the sweep runs again.
|
||||||
|
*
|
||||||
|
* Pure: no Cesium, no DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object fields the detection sweep and its callout candidates read.
|
||||||
|
* Position is compared separately, by coordinate. `sameFields` and
|
||||||
|
* `recordFields` below name each of these outright, in this order, because a
|
||||||
|
* field read by a name held in a variable costs several times as much, and
|
||||||
|
* this runs for every object on every paint.
|
||||||
|
*/
|
||||||
|
export const PAINTED_OBJECT_FIELDS = Object.freeze([
|
||||||
|
'_layerId',
|
||||||
|
'type',
|
||||||
|
'tier',
|
||||||
|
'skipLabel',
|
||||||
|
'id',
|
||||||
|
'metric',
|
||||||
|
'sourceId',
|
||||||
|
'bracketHalfWidth',
|
||||||
|
'bracketHalfHeight',
|
||||||
|
'depthTested',
|
||||||
|
'cardSourceId',
|
||||||
|
'cardEntryId',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After this many sweeps in a row with no reuse between them, the objects are
|
||||||
|
* only recorded on every RECORD_STRIDE-th sweep. A scene whose objects move on
|
||||||
|
* every paint can never reuse one, so recording each of them would be work
|
||||||
|
* with no return; a scene that settles is picked up again within RECORD_STRIDE
|
||||||
|
* paints.
|
||||||
|
*/
|
||||||
|
export const SWEEPS_BEFORE_BACKOFF = 8;
|
||||||
|
export const RECORD_STRIDE = 8;
|
||||||
|
|
||||||
|
const FIELD_COUNT = PAINTED_OBJECT_FIELDS.length;
|
||||||
|
|
||||||
|
function sameFields(object, fields, f) {
|
||||||
|
return (
|
||||||
|
object._layerId === fields[f] &&
|
||||||
|
object.type === fields[f + 1] &&
|
||||||
|
object.tier === fields[f + 2] &&
|
||||||
|
object.skipLabel === fields[f + 3] &&
|
||||||
|
object.id === fields[f + 4] &&
|
||||||
|
object.metric === fields[f + 5] &&
|
||||||
|
object.sourceId === fields[f + 6] &&
|
||||||
|
object.bracketHalfWidth === fields[f + 7] &&
|
||||||
|
object.bracketHalfHeight === fields[f + 8] &&
|
||||||
|
object.depthTested === fields[f + 9] &&
|
||||||
|
object.cardSourceId === fields[f + 10] &&
|
||||||
|
object.cardEntryId === fields[f + 11]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most fields are what they were at the last sweep; only the others are written. */
|
||||||
|
function recordFields(object, fields, f) {
|
||||||
|
let value;
|
||||||
|
if ((value = object._layerId) !== fields[f]) fields[f] = value;
|
||||||
|
if ((value = object.type) !== fields[f + 1]) fields[f + 1] = value;
|
||||||
|
if ((value = object.tier) !== fields[f + 2]) fields[f + 2] = value;
|
||||||
|
if ((value = object.skipLabel) !== fields[f + 3]) fields[f + 3] = value;
|
||||||
|
if ((value = object.id) !== fields[f + 4]) fields[f + 4] = value;
|
||||||
|
if ((value = object.metric) !== fields[f + 5]) fields[f + 5] = value;
|
||||||
|
if ((value = object.sourceId) !== fields[f + 6]) fields[f + 6] = value;
|
||||||
|
if ((value = object.bracketHalfWidth) !== fields[f + 7])
|
||||||
|
fields[f + 7] = value;
|
||||||
|
if ((value = object.bracketHalfHeight) !== fields[f + 8])
|
||||||
|
fields[f + 8] = value;
|
||||||
|
if ((value = object.depthTested) !== fields[f + 9]) fields[f + 9] = value;
|
||||||
|
if ((value = object.cardSourceId) !== fields[f + 10]) fields[f + 10] = value;
|
||||||
|
if ((value = object.cardEntryId) !== fields[f + 11]) fields[f + 11] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{
|
||||||
|
* reuse: (view: Array, objects: Array<object>) => (object|null),
|
||||||
|
* keep: (view: Array, objects: Array<object>, built: object) => boolean,
|
||||||
|
* forget: () => void,
|
||||||
|
* clear: () => void,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function createDetectionPaintCache() {
|
||||||
|
let built = null;
|
||||||
|
let viewKept = [];
|
||||||
|
let count = 0;
|
||||||
|
let references = [];
|
||||||
|
let fields = [];
|
||||||
|
let coordinates = new Float64Array(0);
|
||||||
|
let positioned = new Uint8Array(0);
|
||||||
|
let sweepsSinceReuse = 0;
|
||||||
|
|
||||||
|
function sameView(view) {
|
||||||
|
if (view.length !== viewKept.length) return false;
|
||||||
|
for (let i = 0; i < view.length; i++)
|
||||||
|
if (view[i] !== viewKept[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameObjects(objects) {
|
||||||
|
if (objects.length !== count) return false;
|
||||||
|
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
|
||||||
|
const object = objects[i];
|
||||||
|
if (object !== references[i]) return false;
|
||||||
|
const position = object.position;
|
||||||
|
if (position) {
|
||||||
|
// A coordinate that is not a number never equals the kept one, so such
|
||||||
|
// an object is swept again on every paint.
|
||||||
|
if (
|
||||||
|
positioned[i] !== 1 ||
|
||||||
|
position.x !== coordinates[c] ||
|
||||||
|
position.y !== coordinates[c + 1] ||
|
||||||
|
position.z !== coordinates[c + 2]
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
} else if (positioned[i] !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!sameFields(object, fields, f)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* What the kept paint built, when `view` and `objects` are what it was
|
||||||
|
* built from; otherwise null.
|
||||||
|
* @param {Array} view - Every value of the view the sweep reads, in a fixed order.
|
||||||
|
* @param {Array<object>} objects - The objects the layers returned this paint.
|
||||||
|
*/
|
||||||
|
reuse(view, objects) {
|
||||||
|
if (!built || !sameView(view) || !sameObjects(objects)) return null;
|
||||||
|
sweepsSinceReuse = 0;
|
||||||
|
return built;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep `result` as the result of sweeping `objects` under `view`.
|
||||||
|
* @returns {boolean} False when recording was skipped (see RECORD_STRIDE).
|
||||||
|
*/
|
||||||
|
keep(view, objects, result) {
|
||||||
|
sweepsSinceReuse++;
|
||||||
|
if (
|
||||||
|
sweepsSinceReuse > SWEEPS_BEFORE_BACKOFF &&
|
||||||
|
(sweepsSinceReuse - SWEEPS_BEFORE_BACKOFF) % RECORD_STRIDE !== 0
|
||||||
|
) {
|
||||||
|
built = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
viewKept = view.slice();
|
||||||
|
count = objects.length;
|
||||||
|
if (positioned.length < count) {
|
||||||
|
// Room to spare, so a feed that grows by a few contacts a poll does
|
||||||
|
// not allocate on each of them.
|
||||||
|
const room = Math.ceil(count * 1.25);
|
||||||
|
coordinates = new Float64Array(room * 3);
|
||||||
|
positioned = new Uint8Array(room);
|
||||||
|
references = new Array(room).fill(undefined);
|
||||||
|
fields = new Array(room * FIELD_COUNT).fill(undefined);
|
||||||
|
}
|
||||||
|
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
|
||||||
|
const object = objects[i];
|
||||||
|
if (references[i] !== object) references[i] = object;
|
||||||
|
const position = object.position;
|
||||||
|
positioned[i] = position ? 1 : 0;
|
||||||
|
if (position) {
|
||||||
|
coordinates[c] = position.x;
|
||||||
|
coordinates[c + 1] = position.y;
|
||||||
|
coordinates[c + 2] = position.z;
|
||||||
|
}
|
||||||
|
recordFields(object, fields, f);
|
||||||
|
}
|
||||||
|
built = result;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reuse nothing until the next `keep`. */
|
||||||
|
forget() {
|
||||||
|
built = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Forget the kept paint and let go of its objects. */
|
||||||
|
clear() {
|
||||||
|
built = null;
|
||||||
|
viewKept = [];
|
||||||
|
count = 0;
|
||||||
|
references = [];
|
||||||
|
fields = [];
|
||||||
|
coordinates = new Float64Array(0);
|
||||||
|
positioned = new Uint8Array(0);
|
||||||
|
sweepsSinceReuse = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createDetectionPaintCache,
|
||||||
|
PAINTED_OBJECT_FIELDS,
|
||||||
|
RECORD_STRIDE,
|
||||||
|
SWEEPS_BEFORE_BACKOFF,
|
||||||
|
} from './detectionPaintCache.js';
|
||||||
|
|
||||||
|
const contacts = () => [
|
||||||
|
{
|
||||||
|
position: { x: 1, y: 2, z: 3 },
|
||||||
|
_layerId: 'flights',
|
||||||
|
sourceId: 'a',
|
||||||
|
id: 'A',
|
||||||
|
metric: 'FL300',
|
||||||
|
type: 'AIR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: { x: 4, y: 5, z: 6 },
|
||||||
|
_layerId: 'vessels',
|
||||||
|
sourceId: 'b',
|
||||||
|
id: 'B',
|
||||||
|
type: 'SEA',
|
||||||
|
},
|
||||||
|
{ _layerId: 'cctv', sourceId: 'c', id: 'C', type: 'CAM' },
|
||||||
|
];
|
||||||
|
const view = () => ['DENSE', 800, 600, 0.25, 1, 0, 0, 1];
|
||||||
|
|
||||||
|
function kept() {
|
||||||
|
const cache = createDetectionPaintCache();
|
||||||
|
const objects = contacts();
|
||||||
|
const built = { paths: 'kept' };
|
||||||
|
assert.equal(cache.keep(view(), objects, built), true);
|
||||||
|
return { cache, objects, built };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the kept sweep is handed back for the same view and the same objects', () => {
|
||||||
|
const { cache, objects, built } = kept();
|
||||||
|
assert.equal(cache.reuse(view(), objects), built);
|
||||||
|
assert.equal(cache.reuse(view(), objects), built, 'and again');
|
||||||
|
// The same objects in a new list are still the same objects.
|
||||||
|
assert.equal(cache.reuse(view(), [...objects]), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing is handed back before a sweep is kept, or after it is forgotten', () => {
|
||||||
|
const cache = createDetectionPaintCache();
|
||||||
|
const objects = contacts();
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
cache.keep(view(), objects, {});
|
||||||
|
cache.forget();
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
cache.keep(view(), objects, {});
|
||||||
|
cache.clear();
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('any value of the view that differs is a miss', () => {
|
||||||
|
for (let i = 0; i < view().length; i++) {
|
||||||
|
const { cache, objects } = kept();
|
||||||
|
const changed = view();
|
||||||
|
changed[i] = typeof changed[i] === 'number' ? changed[i] + 1e-9 : 'SPARSE';
|
||||||
|
assert.equal(cache.reuse(changed, objects), null, `view value ${i}`);
|
||||||
|
}
|
||||||
|
const { cache, objects } = kept();
|
||||||
|
assert.equal(cache.reuse([...view(), 0], objects), null, 'one value more');
|
||||||
|
assert.equal(
|
||||||
|
cache.reuse(view().slice(0, -1), objects),
|
||||||
|
null,
|
||||||
|
'one value fewer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a view value that is not a number never matches itself', () => {
|
||||||
|
const cache = createDetectionPaintCache();
|
||||||
|
const objects = contacts();
|
||||||
|
cache.keep([NaN], objects, {});
|
||||||
|
assert.equal(cache.reuse([NaN], objects), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the view is copied when kept, so the caller may write the next one over it', () => {
|
||||||
|
const cache = createDetectionPaintCache();
|
||||||
|
const objects = contacts();
|
||||||
|
const scratch = view();
|
||||||
|
const built = {};
|
||||||
|
cache.keep(scratch, objects, built);
|
||||||
|
scratch[1] = 1024;
|
||||||
|
assert.equal(cache.reuse(scratch, objects), null);
|
||||||
|
scratch[1] = 800;
|
||||||
|
assert.equal(cache.reuse(scratch, objects), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a coordinate changed in place is a miss, however small', () => {
|
||||||
|
for (const axis of ['x', 'y', 'z']) {
|
||||||
|
const { cache, objects } = kept();
|
||||||
|
objects[1].position[axis] += 1e-9;
|
||||||
|
assert.equal(cache.reuse(view(), objects), null, axis);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a position object swapped for an equal one is still the same paint', () => {
|
||||||
|
const { cache, objects, built } = kept();
|
||||||
|
objects[0].position = { x: 1, y: 2, z: 3 };
|
||||||
|
assert.equal(cache.reuse(view(), objects), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a position that appears, goes away or is not a number is a miss', () => {
|
||||||
|
let { cache, objects } = kept();
|
||||||
|
objects[2].position = { x: 0, y: 0, z: 0 };
|
||||||
|
assert.equal(cache.reuse(view(), objects), null, 'appears');
|
||||||
|
|
||||||
|
({ cache, objects } = kept());
|
||||||
|
objects[0].position = null;
|
||||||
|
assert.equal(cache.reuse(view(), objects), null, 'goes away');
|
||||||
|
|
||||||
|
cache = createDetectionPaintCache();
|
||||||
|
objects = contacts();
|
||||||
|
objects[0].position.x = NaN;
|
||||||
|
cache.keep(view(), objects, {});
|
||||||
|
assert.equal(cache.reuse(view(), objects), null, 'not a number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every field the sweep reads is compared', () => {
|
||||||
|
for (const field of PAINTED_OBJECT_FIELDS) {
|
||||||
|
const { cache, objects, built } = kept();
|
||||||
|
const before = objects[0][field];
|
||||||
|
objects[0][field] = before === 'changed' ? 'again' : 'changed';
|
||||||
|
assert.equal(cache.reuse(view(), objects), null, field);
|
||||||
|
objects[0][field] = before;
|
||||||
|
assert.equal(cache.reuse(view(), objects), built, `${field} put back`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a field the sweep does not read may change freely', () => {
|
||||||
|
const { cache, objects, built } = kept();
|
||||||
|
objects[0]._candidateScreenX = 412;
|
||||||
|
objects[0]._cohortHash = 7;
|
||||||
|
assert.equal(cache.reuse(view(), objects), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a different object, count or order is a miss', () => {
|
||||||
|
let { cache, objects } = kept();
|
||||||
|
assert.equal(cache.reuse(view(), contacts()), null, 'equal copies');
|
||||||
|
|
||||||
|
({ cache, objects } = kept());
|
||||||
|
assert.equal(cache.reuse(view(), objects.slice(0, 2)), null, 'one fewer');
|
||||||
|
assert.equal(cache.reuse(view(), [...objects, objects[0]]), null, 'one more');
|
||||||
|
|
||||||
|
({ cache, objects } = kept());
|
||||||
|
assert.equal(
|
||||||
|
cache.reuse(view(), [objects[1], objects[0], objects[2]]),
|
||||||
|
null,
|
||||||
|
'order',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeping again replaces what was kept, for more or fewer objects', () => {
|
||||||
|
const { cache, objects, built } = kept();
|
||||||
|
const more = [...objects, ...contacts(), ...contacts()];
|
||||||
|
const builtMore = {};
|
||||||
|
cache.keep(view(), more, builtMore);
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
assert.equal(cache.reuse(view(), more), builtMore);
|
||||||
|
cache.keep(view(), objects, built);
|
||||||
|
assert.equal(cache.reuse(view(), more), null);
|
||||||
|
assert.equal(cache.reuse(view(), objects), built);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('objects that change on every paint are recorded only now and then', () => {
|
||||||
|
const cache = createDetectionPaintCache();
|
||||||
|
const objects = contacts();
|
||||||
|
const recorded = [];
|
||||||
|
const sweep = () => {
|
||||||
|
objects[0].position.x += 1;
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
recorded.push(cache.keep(view(), objects, {}));
|
||||||
|
};
|
||||||
|
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) sweep();
|
||||||
|
assert.ok(recorded.every(Boolean), 'the first sweeps are all recorded');
|
||||||
|
|
||||||
|
recorded.length = 0;
|
||||||
|
for (let i = 0; i < RECORD_STRIDE * 5; i++) sweep();
|
||||||
|
assert.equal(recorded.filter(Boolean).length, 5, 'then one in every stride');
|
||||||
|
|
||||||
|
// A skipped sweep leaves nothing behind to reuse by mistake.
|
||||||
|
const built = {};
|
||||||
|
let wasRecorded = cache.keep(view(), objects, built);
|
||||||
|
assert.equal(wasRecorded, false);
|
||||||
|
assert.equal(cache.reuse(view(), objects), null);
|
||||||
|
|
||||||
|
// Once the objects hold still, the next recorded sweep is reused, and
|
||||||
|
// every sweep after a reuse is recorded again.
|
||||||
|
while (!wasRecorded) wasRecorded = cache.keep(view(), objects, built);
|
||||||
|
assert.equal(cache.reuse(view(), objects), built);
|
||||||
|
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++)
|
||||||
|
assert.equal(cache.keep(view(), objects, built), true);
|
||||||
|
});
|
||||||
+23
-157
@@ -11,9 +11,6 @@
|
|||||||
* vehicle's road or rail top), and is drawn life size: a type's published
|
* vehicle's road or rail top), and is drawn life size: a type's published
|
||||||
* length over the model's built length is its scale.
|
* length over the model's built length is its scale.
|
||||||
*
|
*
|
||||||
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
|
|
||||||
* the set's buoy of its IALA mark, by the mark its type names.
|
|
||||||
*
|
|
||||||
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
||||||
* fetches the JSON and every GLB once, up front, so no model is fetched
|
* fetches the JSON and every GLB once, up front, so no model is fetched
|
||||||
* mid-session.
|
* mid-session.
|
||||||
@@ -21,7 +18,6 @@
|
|||||||
|
|
||||||
import { classifyAircraft } from './aircraftClass.js';
|
import { classifyAircraft } from './aircraftClass.js';
|
||||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||||
import { isAidToNavigation } from './vesselTypes.js';
|
|
||||||
|
|
||||||
/** Where the model set is served, relative to the application base. */
|
/** Where the model set is served, relative to the application base. */
|
||||||
export const FLEET_MODEL_BASE = '/models/fleet/';
|
export const FLEET_MODEL_BASE = '/models/fleet/';
|
||||||
@@ -89,62 +85,16 @@ const TEXT_SHIP_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The buoy an aid to navigation is drawn as, by the mark its type names
|
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
|
||||||
* ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
|
* emitter category `category`: the type's, else the category's, else
|
||||||
* that matches. Beacons, lights, RACONs and reference points with no mark of
|
* `default_aircraft`.
|
||||||
* their own are the special mark.
|
|
||||||
*/
|
|
||||||
const AID_MODELS = [
|
|
||||||
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
|
|
||||||
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
|
|
||||||
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
|
|
||||||
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
|
|
||||||
[/isolated danger/i, 'buoy_isolated_danger'],
|
|
||||||
[/safe water/i, 'buoy_safe_water'],
|
|
||||||
[/port hand/i, 'buoy_lateral_port'],
|
|
||||||
[/starboard hand/i, 'buoy_lateral_starboard'],
|
|
||||||
[/special mark|light vessel|lanby/i, 'buoy_special'],
|
|
||||||
[
|
|
||||||
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
|
|
||||||
'buoy_special',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The buoy an aid to navigation whose type names no mark is drawn as. */
|
|
||||||
export const DEFAULT_AID_MODEL = 'can_red';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The buoy model of an aid to navigation's type, else undefined when the
|
|
||||||
* type names no mark.
|
|
||||||
* @param {string|null} [type]
|
|
||||||
* @returns {string|undefined}
|
|
||||||
*/
|
|
||||||
export function aidToNavigationModel(type) {
|
|
||||||
const text = String(type ?? '').trim();
|
|
||||||
for (const [pattern, model] of AID_MODELS)
|
|
||||||
if (pattern.test(text)) return model;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {object} types Parsed `types.json`.
|
||||||
* @param {string} [type]
|
* @param {string} [type]
|
||||||
* @param {string} [category]
|
* @param {string} [category]
|
||||||
* @param {string} [callsign]
|
|
||||||
* @returns {{model: string, length: number|null}}
|
* @returns {{model: string, length: number|null}}
|
||||||
*/
|
*/
|
||||||
export function resolveAircraft(types, type, category, callsign) {
|
export function resolveAircraft(types, type, category) {
|
||||||
const t = type?.trim().toUpperCase();
|
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] =
|
const [model, length] =
|
||||||
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
|
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
|
||||||
(category && types.categories[category]) ||
|
(category && types.categories[category]) ||
|
||||||
@@ -152,51 +102,6 @@ export function resolveAircraft(types, type, category, callsign) {
|
|||||||
return { model, length };
|
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).
|
* A reported length as one a hull can have, else undefined (drawn as built).
|
||||||
* @param {number} [length]
|
* @param {number} [length]
|
||||||
@@ -228,9 +133,8 @@ export function hullBeam(beam) {
|
|||||||
/**
|
/**
|
||||||
* The model for a vessel of AIS ship type `shipType` and the reported length
|
* 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
|
* 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;
|
* range that has one, else `default_vessel`. Drawn at the reported length, as
|
||||||
* when it is unknown (most live feeds send none), chosen and drawn at the
|
* built when it is unknown.
|
||||||
* range's `typical_len`, else as built.
|
|
||||||
* @param {object} types Parsed `types.json`.
|
* @param {object} types Parsed `types.json`.
|
||||||
* @param {number} [shipType]
|
* @param {number} [shipType]
|
||||||
* @param {number} [length]
|
* @param {number} [length]
|
||||||
@@ -241,14 +145,13 @@ export function hullBeam(beam) {
|
|||||||
export function resolveVessel(types, shipType, length, beam, name) {
|
export function resolveVessel(types, shipType, length, beam, name) {
|
||||||
const L = hullLength(length) ?? 0;
|
const L = hullLength(length) ?? 0;
|
||||||
const B = hullBeam(beam) ?? 0;
|
const B = hullBeam(beam) ?? 0;
|
||||||
|
const drawn = L > 0 ? L : null;
|
||||||
if (shipType !== undefined && shipType !== null) {
|
if (shipType !== undefined && shipType !== null) {
|
||||||
for (const group of types.vessels) {
|
for (const group of types.vessels) {
|
||||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
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) {
|
for (const rule of group.rules) {
|
||||||
if (rule.min_len && len < rule.min_len) continue;
|
if (rule.min_len && L < rule.min_len) continue;
|
||||||
if (rule.max_len && len > rule.max_len) continue;
|
if (rule.max_len && L > rule.max_len) continue;
|
||||||
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
||||||
continue;
|
continue;
|
||||||
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
||||||
@@ -256,7 +159,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { model: types.default_vessel, length: L > 0 ? L : null };
|
return { model: types.default_vessel, length: drawn };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -401,32 +304,24 @@ function specOf(set, resolved, fallback, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model an aircraft is drawn with. The type's callsign rules (an airport
|
* The model an aircraft is drawn with. The type's entry first; then, for a
|
||||||
* 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
|
* 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
|
* glider) when the type names one; then the emitter category's; then
|
||||||
* `default_aircraft`.
|
* `default_aircraft`.
|
||||||
* @param {{catalog: object, types: object}} set
|
* @param {{catalog: object, types: object}} set
|
||||||
* @param {{typeCode?: string|null, type?: string|null,
|
* @param {{typeCode?: string|null, type?: string|null,
|
||||||
* category?: string|number|null, callsign?: string|null}} aircraft
|
* category?: string|number|null}} aircraft
|
||||||
* @returns {object} {@link fleetModelSpec}
|
* @returns {object} {@link fleetModelSpec}
|
||||||
*/
|
*/
|
||||||
export function aircraftModelSpec(
|
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||||
set,
|
|
||||||
{ typeCode, type, category, callsign } = {},
|
|
||||||
) {
|
|
||||||
const { types, catalog } = set;
|
const { types, catalog } = set;
|
||||||
const code = String(typeCode ?? type ?? '')
|
const code = String(typeCode ?? type ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
const emitter = emitterCategory(category);
|
const emitter = emitterCategory(category);
|
||||||
const sign = callsign ? String(callsign) : undefined;
|
let resolved = resolveAircraft(types, code, emitter);
|
||||||
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]);
|
const typed = code && (types.aircraft[code] || types.helicopters[code]);
|
||||||
if (!ruled && !typed && code) {
|
if (!typed && code) {
|
||||||
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
|
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
|
||||||
if (model && catalog[model])
|
if (model && catalog[model])
|
||||||
resolved = { model, length: catalog[model].length };
|
resolved = { model, length: catalog[model].length };
|
||||||
@@ -439,49 +334,25 @@ export function aircraftModelSpec(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
||||||
* family), reported length and beam (metres) and name. An aid to navigation
|
* family), reported length and beam (metres) and name.
|
||||||
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
|
|
||||||
* light, or its MMSI is an aid's) is the buoy of its mark
|
|
||||||
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
|
|
||||||
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
|
|
||||||
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
|
|
||||||
* @param {{catalog: object, types: object}} set
|
* @param {{catalog: object, types: object}} set
|
||||||
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
* @param {{type?: string|number|null, length?: number|null,
|
||||||
* virtual?: boolean, length?: number|null, beam?: number|null,
|
* beam?: number|null, name?: string|null}} vessel
|
||||||
* name?: string|null}} vessel
|
* @returns {object} {@link fleetModelSpec}
|
||||||
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
|
|
||||||
* null for a virtual aid
|
|
||||||
*/
|
*/
|
||||||
export function vesselModelSpec(
|
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
||||||
set,
|
|
||||||
{ type, mmsi, virtual, length, beam, name } = {},
|
|
||||||
) {
|
|
||||||
if (virtual === true) return null;
|
|
||||||
if (isAidToNavigation({ type, mmsi })) {
|
|
||||||
const model = aidToNavigationModel(type);
|
|
||||||
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
|
|
||||||
if (set.catalog[known])
|
|
||||||
return Object.freeze({
|
|
||||||
...fleetModelSpec(set, known, null),
|
|
||||||
fixed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 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(
|
const resolved = resolveVessel(
|
||||||
set.types,
|
set.types,
|
||||||
aisShipTypeCode(type),
|
aisShipTypeCode(type),
|
||||||
reported,
|
length ?? undefined,
|
||||||
hullWidth,
|
beam ?? undefined,
|
||||||
name ?? undefined,
|
name ?? undefined,
|
||||||
);
|
);
|
||||||
return specOf(
|
return specOf(
|
||||||
set,
|
set,
|
||||||
resolved,
|
resolved,
|
||||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||||
{ beam: hullWidth },
|
{ beam: beam ?? undefined },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,9 +381,6 @@ export function namedFleetModels(types) {
|
|||||||
...pairs(types.aircraft),
|
...pairs(types.aircraft),
|
||||||
...pairs(types.helicopters),
|
...pairs(types.helicopters),
|
||||||
...pairs(types.categories),
|
...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.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
|
||||||
types.default_aircraft[0],
|
types.default_aircraft[0],
|
||||||
types.default_vessel,
|
types.default_vessel,
|
||||||
@@ -522,8 +390,6 @@ export function namedFleetModels(types) {
|
|||||||
transit.default[0],
|
transit.default[0],
|
||||||
...Object.keys(transit.elevation),
|
...Object.keys(transit.elevation),
|
||||||
...Object.values(CLASS_MODELS),
|
...Object.values(CLASS_MODELS),
|
||||||
...AID_MODELS.map(([, model]) => model),
|
|
||||||
DEFAULT_AID_MODEL,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
DEFAULT_AID_MODEL,
|
|
||||||
FLEET_MODEL_BASE,
|
FLEET_MODEL_BASE,
|
||||||
aidToNavigationModel,
|
|
||||||
aircraftModelSpec,
|
aircraftModelSpec,
|
||||||
aisShipTypeCode,
|
aisShipTypeCode,
|
||||||
emitterCategory,
|
emitterCategory,
|
||||||
@@ -53,22 +51,12 @@ const sliceTypes = {
|
|||||||
A21N: ['a320', 44.51],
|
A21N: ['a320', 44.51],
|
||||||
A319: ['a320', 33.84],
|
A319: ['a320', 33.84],
|
||||||
B38M: ['b738', 39.52],
|
B38M: ['b738', 39.52],
|
||||||
ZVEH: ['car', 4.61],
|
|
||||||
GRND: ['radio_mast', 1.9],
|
|
||||||
},
|
},
|
||||||
helicopters: { EC35: ['ec35', 10.2] },
|
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] },
|
categories: { A1: ['c172', 8.3] },
|
||||||
vessels: [
|
vessels: [
|
||||||
{
|
{
|
||||||
types: [70, 79],
|
types: [70, 79],
|
||||||
typical_len: 300,
|
|
||||||
rules: [
|
rules: [
|
||||||
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
||||||
{
|
{
|
||||||
@@ -123,33 +111,6 @@ 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', () => {
|
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(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -172,29 +133,6 @@ 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", () => {
|
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), {
|
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
|
||||||
model: 'tug',
|
model: 'tug',
|
||||||
@@ -263,46 +201,6 @@ 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', () => {
|
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, { typeCode: 'A320' }).model, 'a320');
|
||||||
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
|
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
|
||||||
@@ -369,12 +267,6 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
|||||||
const [sx, sy, sz] = box.scaleXYZ;
|
const [sx, sy, sz] = box.scaleXYZ;
|
||||||
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
||||||
assert.ok(Math.abs(sz - (sx + sy) / 2) < 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: 'Tug', length: 30 }).model, 'tug');
|
||||||
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
||||||
// No type, no size: the generic vessel as built.
|
// No type, no size: the generic vessel as built.
|
||||||
@@ -383,67 +275,6 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
|||||||
assert.equal(unknown.scale, 1);
|
assert.equal(unknown.scale, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aids to navigation are the buoy of the mark their type names', () => {
|
|
||||||
const cases = [
|
|
||||||
['Cardinal Mark N', 'buoy_cardinal_north'],
|
|
||||||
['Beacon, Cardinal E', 'buoy_cardinal_east'],
|
|
||||||
['Cardinal Mark S', 'buoy_cardinal_south'],
|
|
||||||
['Beacon, Cardinal W', 'buoy_cardinal_west'],
|
|
||||||
['Port Hand Mark', 'buoy_lateral_port'],
|
|
||||||
['Beacon, Port hand', 'buoy_lateral_port'],
|
|
||||||
['Starboard Hand Mark', 'buoy_lateral_starboard'],
|
|
||||||
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
|
|
||||||
['Safe Water', 'buoy_safe_water'],
|
|
||||||
['Isolated danger', 'buoy_isolated_danger'],
|
|
||||||
['Beacon, Special Mark', 'buoy_special'],
|
|
||||||
['Special Mark', 'buoy_special'],
|
|
||||||
['Light Vessel / LANBY / Rigs', 'buoy_special'],
|
|
||||||
['RACON', 'buoy_special'],
|
|
||||||
['Light, with Sectors', 'buoy_special'],
|
|
||||||
['Reference point', 'buoy_special'],
|
|
||||||
['Navigation aid', 'buoy_special'],
|
|
||||||
];
|
|
||||||
for (const [type, model] of cases) {
|
|
||||||
assert.equal(aidToNavigationModel(type), model, type);
|
|
||||||
assert.ok(catalog[model], model);
|
|
||||||
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
|
|
||||||
assert.equal(spec.model, model, type);
|
|
||||||
assert.equal(spec.category, 'buoys', type);
|
|
||||||
assert.equal(spec.fixed, true, type);
|
|
||||||
// A buoy is drawn as built, whatever length the report gives.
|
|
||||||
assert.equal(spec.scale, 1, type);
|
|
||||||
}
|
|
||||||
assert.equal(
|
|
||||||
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
// An aid of no stated kind, known only by its MMSI, is the default can.
|
|
||||||
assert.ok(catalog[DEFAULT_AID_MODEL]);
|
|
||||||
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
|
|
||||||
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
|
|
||||||
assert.equal(unnamed.fixed, true);
|
|
||||||
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
|
|
||||||
// A virtual aid is broadcast where nothing stands: it keeps its disc.
|
|
||||||
assert.equal(
|
|
||||||
vesselModelSpec(set, {
|
|
||||||
type: 'Cardinal Mark N',
|
|
||||||
mmsi: '993672001',
|
|
||||||
virtual: true,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
// Vessels, including those that work on buoys, keep their hulls.
|
|
||||||
for (const vessel of [
|
|
||||||
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
|
|
||||||
{ type: '70', mmsi: '366999002' },
|
|
||||||
{ type: 'Tug', mmsi: '' },
|
|
||||||
]) {
|
|
||||||
const spec = vesselModelSpec(set, vessel);
|
|
||||||
assert.notEqual(spec.category, 'buoys', vessel.type);
|
|
||||||
assert.equal(spec.fixed, undefined, vessel.type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
||||||
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
||||||
model: 'intercity',
|
model: 'intercity',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
|||||||
c172: Object.freeze([4.0507, 0.4481, 0]),
|
c172: Object.freeze([4.0507, 0.4481, 0]),
|
||||||
c30j: Object.freeze([13.568, 0.9917, 0]),
|
c30j: Object.freeze([13.568, 0.9917, 0]),
|
||||||
c56x: Object.freeze([7.4627, 0.4526, 0]),
|
c56x: Object.freeze([7.4627, 0.4526, 0]),
|
||||||
car: Object.freeze([2.1851, 0.301, 0]),
|
car: Object.freeze([2.3, 0.418, 0]),
|
||||||
cl35: Object.freeze([10.4607, 0.8247, 0]),
|
cl35: Object.freeze([10.4607, 0.8247, 0]),
|
||||||
crj9: Object.freeze([17.8413, 0.8624, 0]),
|
crj9: Object.freeze([17.8413, 0.8624, 0]),
|
||||||
dh8d: Object.freeze([15.7902, 0.7501, 0]),
|
dh8d: Object.freeze([15.7902, 0.7501, 0]),
|
||||||
@@ -40,7 +40,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
|||||||
e55p: Object.freeze([7.6566, 0.503, 0]),
|
e55p: Object.freeze([7.6566, 0.503, 0]),
|
||||||
ec35: Object.freeze([5.1003, 0.1471, 0]),
|
ec35: Object.freeze([5.1003, 0.1471, 0]),
|
||||||
ec45: Object.freeze([5.8004, 0.555, 0]),
|
ec45: Object.freeze([5.8004, 0.555, 0]),
|
||||||
fire_truck: Object.freeze([5.39, 0.4227, 0]),
|
fire_truck: Object.freeze([5.5, 0.6051, 0]),
|
||||||
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
|
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
|
||||||
generic_plane: Object.freeze([16.5228, 0.712, 0]),
|
generic_plane: Object.freeze([16.5228, 0.712, 0]),
|
||||||
glf4: Object.freeze([13.4603, 0.8243, 0]),
|
glf4: Object.freeze([13.4603, 0.8243, 0]),
|
||||||
@@ -53,9 +53,8 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
|||||||
mq9: Object.freeze([5.8498, -0.062, 0]),
|
mq9: Object.freeze([5.8498, -0.062, 0]),
|
||||||
p28a: Object.freeze([3.3695, 0.3959, 0]),
|
p28a: Object.freeze([3.3695, 0.3959, 0]),
|
||||||
pc12: Object.freeze([6.6416, 0.5288, 0]),
|
pc12: Object.freeze([6.6416, 0.5288, 0]),
|
||||||
pushback: Object.freeze([2.9536, 0.388, 0]),
|
pushback: Object.freeze([3, 0.2499, 0]),
|
||||||
r44: Object.freeze([4.4219, -0.3001, 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]),
|
s76: Object.freeze([6.6001, 0.1915, 0]),
|
||||||
sr22: Object.freeze([3.6762, 0.2376, 0]),
|
sr22: Object.freeze([3.6762, 0.2376, 0]),
|
||||||
t38: Object.freeze([7.0246, -0.3791, 0]),
|
t38: Object.freeze([7.0246, -0.3791, 0]),
|
||||||
@@ -63,5 +62,5 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
|||||||
tower: Object.freeze([1.2952, -0.0007, 0]),
|
tower: Object.freeze([1.2952, -0.0007, 0]),
|
||||||
ultralight: Object.freeze([3.2271, 0.3259, 0]),
|
ultralight: Object.freeze([3.2271, 0.3259, 0]),
|
||||||
v22: Object.freeze([7.9868, 0.2956, 0]),
|
v22: Object.freeze([7.9868, 0.2956, 0]),
|
||||||
van: Object.freeze([2.695, 0.2812, 0]),
|
van: Object.freeze([2.75, 0.418, 0]),
|
||||||
});
|
});
|
||||||
@@ -196,7 +196,6 @@ test('consumer build includes only infrastructure code and resolves assets under
|
|||||||
'infrastructureOverlayEntry.js',
|
'infrastructureOverlayEntry.js',
|
||||||
'localGeojsonCore.js',
|
'localGeojsonCore.js',
|
||||||
'localGeojsonLod.js',
|
'localGeojsonLod.js',
|
||||||
'terrainSurface.js',
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
entry.imports,
|
entry.imports,
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ import {
|
|||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
|
||||||
/** The catalog's services, with the overlay host each test supplies. */
|
/** The catalog's services, with the overlay host each test supplies. */
|
||||||
const createLocalGeoJsonLayer = (options, services = {}) =>
|
const createLocalGeoJsonLayer = (options) =>
|
||||||
createLayer(options, {
|
createLayer(options, {
|
||||||
...localGeoJsonServices,
|
...localGeoJsonServices,
|
||||||
...services,
|
|
||||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,15 +58,10 @@ class MockLayerEvent {
|
|||||||
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
||||||
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
||||||
* (default: throws like a scene whose tiles are not sampleable yet).
|
* (default: throws like a scene whose tiles are not sampleable yet).
|
||||||
* @param {number[][]} [options.points] `[lon, lat]` sites to load
|
|
||||||
* instead of the single dam polygon.
|
|
||||||
* @param {object} [options.services] Extra services for the layer.
|
|
||||||
*/
|
*/
|
||||||
async function createRealLocalLayerHarness({
|
async function createRealLocalLayerHarness({
|
||||||
sampleHeightSupported = false,
|
sampleHeightSupported = false,
|
||||||
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
||||||
points = null,
|
|
||||||
services = {},
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -78,22 +72,7 @@ async function createRealLocalLayerHarness({
|
|||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
|
text: async () => JSON.stringify({
|
||||||
type: 'Feature',
|
|
||||||
id: `site-${index}`,
|
|
||||||
properties: { name: `Site ${index}` },
|
|
||||||
// A small outline: the layer stems its centre. (Cesium draws a point
|
|
||||||
// feature as a canvas pin, which Node cannot make.)
|
|
||||||
geometry: {
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [[
|
|
||||||
[lon - 0.001, lat - 0.001],
|
|
||||||
[lon + 0.001, lat - 0.001],
|
|
||||||
[lon, lat + 0.001],
|
|
||||||
[lon - 0.001, lat - 0.001],
|
|
||||||
]],
|
|
||||||
},
|
|
||||||
})).join('\n') : JSON.stringify({
|
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
id: 'real-dam',
|
id: 'real-dam',
|
||||||
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
||||||
@@ -170,7 +149,7 @@ async function createRealLocalLayerHarness({
|
|||||||
setInputAction() {},
|
setInputAction() {},
|
||||||
destroy() {},
|
destroy() {},
|
||||||
}),
|
}),
|
||||||
}, services);
|
});
|
||||||
try {
|
try {
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -924,13 +903,12 @@ test('settled terrain refinement lifts an already sampled stem without another G
|
|||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
||||||
globe.tilesLoaded = true;
|
globe.tilesLoaded = true;
|
||||||
// The terrain surface reuses a height for 2 s before reading the mesh again.
|
clock.advance(500);
|
||||||
clock.advance(2_100);
|
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
||||||
|
|
||||||
terrain = 110;
|
terrain = 110;
|
||||||
clock.advance(2_100);
|
clock.advance(500);
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
||||||
terrain = 140;
|
terrain = 140;
|
||||||
@@ -1515,161 +1493,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
|||||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/** A loaded terrain globe that counts its ray casts. */
|
|
||||||
function countingTerrain(height) {
|
|
||||||
const globe = {
|
|
||||||
show: true,
|
|
||||||
tilesLoaded: true,
|
|
||||||
reads: 0,
|
|
||||||
getHeight: () => { globe.reads += 1; return height; },
|
|
||||||
};
|
|
||||||
return globe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Height of every stem base, in entity order. */
|
|
||||||
function allBaseHeightsM(env) {
|
|
||||||
return env.dataSources[0].entities.values.map(
|
|
||||||
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAMPLE_SITES = [
|
|
||||||
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
|
|
||||||
];
|
|
||||||
|
|
||||||
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
|
|
||||||
const env = await createRealLocalLayerHarness({
|
|
||||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
|
||||||
});
|
|
||||||
const clock = installFakeClock(t);
|
|
||||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
|
||||||
const globe = countingTerrain(117);
|
|
||||||
env.viewer.scene.globe = globe;
|
|
||||||
setCameraAltitude(env, 20_000);
|
|
||||||
for (let walk = 0; walk < 40; walk += 1) {
|
|
||||||
env.preRender.raise();
|
|
||||||
clock.advance(500);
|
|
||||||
}
|
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
|
||||||
// 20 s parked: a few reads while the surface settles, then none.
|
|
||||||
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
|
|
||||||
const settled = globe.reads;
|
|
||||||
for (let walk = 0; walk < 20; walk += 1) {
|
|
||||||
env.preRender.raise();
|
|
||||||
clock.advance(500);
|
|
||||||
}
|
|
||||||
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
|
|
||||||
const env = await createRealLocalLayerHarness({
|
|
||||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
|
||||||
});
|
|
||||||
const clock = installFakeClock(t);
|
|
||||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
|
||||||
const globe = countingTerrain(117);
|
|
||||||
env.viewer.scene.globe = globe;
|
|
||||||
setCameraAltitude(env, 20_000);
|
|
||||||
env.setBaseInView(false);
|
|
||||||
for (let walk = 0; walk < 40; walk += 1) {
|
|
||||||
env.preRender.raise();
|
|
||||||
clock.advance(500);
|
|
||||||
}
|
|
||||||
assert.equal(env.sampleCalls.count, 0);
|
|
||||||
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
|
|
||||||
const asked = [];
|
|
||||||
const terrainSurface = {
|
|
||||||
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
|
|
||||||
};
|
|
||||||
const env = await createRealLocalLayerHarness({
|
|
||||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
|
||||||
points: [[-97.695, 30.205]],
|
|
||||||
services: { terrainSurface },
|
|
||||||
});
|
|
||||||
const clock = installFakeClock(t);
|
|
||||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
|
||||||
const globe = countingTerrain(117);
|
|
||||||
env.viewer.scene.globe = globe;
|
|
||||||
setCameraAltitude(env, 20_000);
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
|
|
||||||
clock.advance(500);
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.ok(asked.length >= 2);
|
|
||||||
for (const call of asked) {
|
|
||||||
assert.equal(call.viewer, env.viewer);
|
|
||||||
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
|
|
||||||
`asked at ${call.lat}, ${call.lon}`);
|
|
||||||
}
|
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
|
|
||||||
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
|
|
||||||
const heights = new Map();
|
|
||||||
let clock = null;
|
|
||||||
const env = await createRealLocalLayerHarness({
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
points: SAMPLE_SITES,
|
|
||||||
sampleHeight: (carto) => {
|
|
||||||
clock.advance(15); // one GPU depth render
|
|
||||||
const height = 100 + heights.size;
|
|
||||||
heights.set(`${carto.longitude},${carto.latitude}`, height);
|
|
||||||
return height;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
_resetRenderGovernorForTest();
|
|
||||||
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
|
||||||
installRenderGovernor({ scene: governorScene });
|
|
||||||
governorScene.renders = 0; // discard the governor's own install settling frame
|
|
||||||
clock = installFakeClock(t);
|
|
||||||
t.after(() => {
|
|
||||||
env.layer.destroy(env.viewer);
|
|
||||||
env.cleanup();
|
|
||||||
_resetRenderGovernorForTest();
|
|
||||||
});
|
|
||||||
setCameraAltitude(env, 20_000);
|
|
||||||
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
|
|
||||||
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
|
|
||||||
|
|
||||||
// The following frames arrive well inside the 450 ms walk interval.
|
|
||||||
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
|
|
||||||
clock.advance(16);
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
|
|
||||||
}
|
|
||||||
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
|
|
||||||
clock.advance(16);
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
|
|
||||||
|
|
||||||
const expected = env.dataSources[0].entities.values.map((entity) => {
|
|
||||||
const carto = entity.__localBaseCarto;
|
|
||||||
return heights.get(`${carto.longitude},${carto.latitude}`);
|
|
||||||
});
|
|
||||||
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
|
|
||||||
for (const entity of env.dataSources[0].entities.values) {
|
|
||||||
const stem = entity.polyline.positions.getValue();
|
|
||||||
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
|
|
||||||
const env = await createRealLocalLayerHarness({
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
points: SAMPLE_SITES,
|
|
||||||
sampleHeight: () => 117,
|
|
||||||
});
|
|
||||||
installFakeClock(t);
|
|
||||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
|
||||||
setCameraAltitude(env, 20_000);
|
|
||||||
env.preRender.raise();
|
|
||||||
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
|
|
||||||
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
|
|
||||||
});
|
|
||||||
+15
-134
@@ -11,10 +11,6 @@ import {
|
|||||||
applyInfraEvictionGrace,
|
applyInfraEvictionGrace,
|
||||||
shouldRecomputeInfraLod,
|
shouldRecomputeInfraLod,
|
||||||
} from './localGeojsonLod.js';
|
} from './localGeojsonLod.js';
|
||||||
import {
|
|
||||||
createTerrainSurface,
|
|
||||||
TERRAIN_NEED,
|
|
||||||
} from '../services/terrainSurface.js';
|
|
||||||
|
|
||||||
const DEFAULT_LABEL_MAX = 900;
|
const DEFAULT_LABEL_MAX = 900;
|
||||||
const DEFAULT_LABEL_GRID_PX = 132;
|
const DEFAULT_LABEL_GRID_PX = 132;
|
||||||
@@ -42,15 +38,6 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
|||||||
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
||||||
*/
|
*/
|
||||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||||
/**
|
|
||||||
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
|
|
||||||
* offscreen depth render (about 15 ms measured), and a first walk near a dense
|
|
||||||
* cluster wants one per site in view: 168 of them held the main thread for
|
|
||||||
* 2.6 s. Past the budget the remaining sites queue, and each following frame
|
|
||||||
* takes as many as fit, so the sites reach the same heights a frame or a few
|
|
||||||
* apart instead of all inside one blocked frame.
|
|
||||||
*/
|
|
||||||
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
|
|
||||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||||
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||||
@@ -349,20 +336,8 @@ export function createLocalGeoJsonLayer(
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
showOsmCredit,
|
showOsmCredit,
|
||||||
hideOsmCredit,
|
hideOsmCredit,
|
||||||
terrainSurface,
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
/**
|
|
||||||
* What ground sampling shares across a layer's records: the terrain
|
|
||||||
* surface (the application's, so every layer reads a point once and spends
|
|
||||||
* one budget, or this layer's own), the sample time spent this frame, and
|
|
||||||
* the records the frame budget left for a later frame.
|
|
||||||
*/
|
|
||||||
const _ground = {
|
|
||||||
terrain: terrainSurface || createTerrainSurface(),
|
|
||||||
sampleMs: 0,
|
|
||||||
queued: [],
|
|
||||||
};
|
|
||||||
let _dataSource = null;
|
let _dataSource = null;
|
||||||
let _enabled = false;
|
let _enabled = false;
|
||||||
let _clickHandler = null;
|
let _clickHandler = null;
|
||||||
@@ -469,42 +444,6 @@ export function createLocalGeoJsonLayer(
|
|||||||
clearTimeout(_groundRetryTimer);
|
clearTimeout(_groundRetryTimer);
|
||||||
_groundRetryTimer = null;
|
_groundRetryTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sample the sites an earlier frame's budget left over, within this
|
|
||||||
* frame's. They were never attempted, so the 2 s retry spacing does not
|
|
||||||
* apply to them.
|
|
||||||
* @param {Cesium.Viewer} viewer
|
|
||||||
* @param {number} now
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function sampleQueuedGround(viewer, now) {
|
|
||||||
const queued = _ground.queued;
|
|
||||||
if (queued.length === 0) return;
|
|
||||||
_ground.queued = [];
|
|
||||||
for (const record of queued) {
|
|
||||||
record.groundSampleQueued = false;
|
|
||||||
// The camera may have left the site since it queued.
|
|
||||||
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
|
|
||||||
const distance = Cesium.Cartesian3.distance(
|
|
||||||
viewer.camera.positionWC,
|
|
||||||
record.base,
|
|
||||||
);
|
|
||||||
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
|
|
||||||
// Past the budget this puts the record back in the queue.
|
|
||||||
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
|
|
||||||
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
|
||||||
_groundRetryArms = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ask for the frame that samples what this one's budget left over. */
|
|
||||||
function requestQueuedGroundFrame() {
|
|
||||||
if (_ground.queued.length > 0)
|
|
||||||
governorRequestRender(`local-ground-sample:${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
||||||
sourceId: id,
|
sourceId: id,
|
||||||
host: overlayHost,
|
host: overlayHost,
|
||||||
@@ -533,7 +472,6 @@ export function createLocalGeoJsonLayer(
|
|||||||
const source = _dataSource;
|
const source = _dataSource;
|
||||||
_dataSource = null;
|
_dataSource = null;
|
||||||
_stemRecords = [];
|
_stemRecords = [];
|
||||||
_ground.queued = [];
|
|
||||||
_stemGeometryDirty = true;
|
_stemGeometryDirty = true;
|
||||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||||
removeEntityContextsForLayer(id);
|
removeEntityContextsForLayer(id);
|
||||||
@@ -806,8 +744,6 @@ export function createLocalGeoJsonLayer(
|
|||||||
// Store references for bounded stem scaling and native picking.
|
// Store references for bounded stem scaling and native picking.
|
||||||
feature.__localBaseCarto = carto;
|
feature.__localBaseCarto = carto;
|
||||||
feature.__localBaseCartesian = base;
|
feature.__localBaseCartesian = base;
|
||||||
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
|
||||||
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
|
||||||
const context = registerEntityContext(feature, {
|
const context = registerEntityContext(feature, {
|
||||||
id: `${id}:${recordId}`,
|
id: `${id}:${recordId}`,
|
||||||
layerId: id,
|
layerId: id,
|
||||||
@@ -859,11 +795,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
stemPositionBuffers,
|
stemPositionBuffers,
|
||||||
stemPositionBufferIndex: 0,
|
stemPositionBufferIndex: 0,
|
||||||
groundHeight,
|
groundHeight,
|
||||||
// Degrees, as the terrain surface takes them.
|
|
||||||
latDeg,
|
|
||||||
lonDeg,
|
|
||||||
groundSampled: false,
|
groundSampled: false,
|
||||||
groundSampleQueued: false,
|
|
||||||
lastGroundSampleMs: 0,
|
lastGroundSampleMs: 0,
|
||||||
priority,
|
priority,
|
||||||
entry: labels
|
entry: labels
|
||||||
@@ -984,13 +916,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||||
if (!_enabled || !_dataSource) return;
|
if (!_enabled || !_dataSource) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
// One sample budget per frame, shared by the queue and the walk.
|
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
|
||||||
_ground.sampleMs = 0;
|
|
||||||
sampleQueuedGround(viewer, now);
|
|
||||||
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
|
|
||||||
requestQueuedGroundFrame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_lastVisibilityUpdate = now;
|
_lastVisibilityUpdate = now;
|
||||||
|
|
||||||
const cameraPos = viewer.camera.positionWC;
|
const cameraPos = viewer.camera.positionWC;
|
||||||
@@ -1041,9 +967,6 @@ export function createLocalGeoJsonLayer(
|
|||||||
_lastGroundSampleCapability = canSampleGround;
|
_lastGroundSampleCapability = canSampleGround;
|
||||||
let groundRetryPending = false;
|
let groundRetryPending = false;
|
||||||
let groundSampleProgress = false;
|
let groundSampleProgress = false;
|
||||||
// This walk asks for no height while tiles load, so it shows the
|
|
||||||
// surface the load itself; see terrainSurface.js observe().
|
|
||||||
_ground.terrain.observe?.(viewer);
|
|
||||||
|
|
||||||
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
||||||
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
||||||
@@ -1105,10 +1028,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
const terrainFloorChanged = refreshLocalTerrainFloor(
|
const terrainFloorChanged = refreshLocalTerrainFloor(
|
||||||
viewer,
|
viewer,
|
||||||
record,
|
record,
|
||||||
_ground,
|
|
||||||
);
|
);
|
||||||
if (refreshStemGeometry || terrainFloorChanged) {
|
if (refreshStemGeometry || terrainFloorChanged) {
|
||||||
updateLocalStemGeometry(viewer, record, now, _ground);
|
updateLocalStemGeometry(viewer, record, now);
|
||||||
} else if (
|
} else if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
@@ -1122,9 +1044,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
sampleLocalGroundHeight(viewer, record, now, _ground)
|
sampleLocalGroundHeight(viewer, record, now)
|
||||||
) {
|
) {
|
||||||
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
updateLocalStemGeometry(viewer, record, now, distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!wasGroundSampled && record.groundSampled)
|
if (!wasGroundSampled && record.groundSampled)
|
||||||
@@ -1136,19 +1058,16 @@ export function createLocalGeoJsonLayer(
|
|||||||
// the user happens to move. Schedule the frame the retry needs.
|
// the user happens to move. Schedule the frame the retry needs.
|
||||||
// Gated on a sampleable scene and in-range, in-view records only,
|
// Gated on a sampleable scene and in-range, in-view records only,
|
||||||
// so a far camera (or a keyless scene) stays fully idle; the
|
// so a far camera (or a keyless scene) stays fully idle; the
|
||||||
// distance is only computed for still-unsampled stems. A record
|
// distance is only computed for still-unsampled stems.
|
||||||
// the frame budget queued is sampled by the frame the queue asks
|
|
||||||
// for, with no timer.
|
|
||||||
if (
|
if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
!record.groundSampleQueued &&
|
|
||||||
!groundRetryPending &&
|
!groundRetryPending &&
|
||||||
Cesium.Cartesian3.distance(
|
Cesium.Cartesian3.distance(
|
||||||
viewer.camera.positionWC,
|
viewer.camera.positionWC,
|
||||||
record.base,
|
record.base,
|
||||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
groundSampleInView(viewer, record, _ground)
|
groundSampleInView(viewer, record)
|
||||||
) {
|
) {
|
||||||
groundRetryPending = true;
|
groundRetryPending = true;
|
||||||
}
|
}
|
||||||
@@ -1159,7 +1078,6 @@ export function createLocalGeoJsonLayer(
|
|||||||
// so the records still waiting get their own bounded run of retries.
|
// so the records still waiting get their own bounded run of retries.
|
||||||
if (groundSampleProgress) _groundRetryArms = 0;
|
if (groundSampleProgress) _groundRetryArms = 0;
|
||||||
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
||||||
requestQueuedGroundFrame();
|
|
||||||
|
|
||||||
const canvas = viewer.scene.canvas;
|
const canvas = viewer.scene.canvas;
|
||||||
const cohort = selectLocalInfrastructureOverlayCohort(
|
const cohort = selectLocalInfrastructureOverlayCohort(
|
||||||
@@ -1350,7 +1268,7 @@ function insertLocalCellContender(contenders, record) {
|
|||||||
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLocalTerrainFloor(viewer, record, ground) {
|
function refreshLocalTerrainFloor(viewer, record) {
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
||||||
return false;
|
return false;
|
||||||
@@ -1362,15 +1280,7 @@ function refreshLocalTerrainFloor(viewer, record, ground) {
|
|||||||
// Camera motion can refine terrain after the first successful depth sample.
|
// Camera motion can refine terrain after the first successful depth sample.
|
||||||
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
||||||
// another GPU readback just to maintain this floor. Keep roof elevations.
|
// another GPU readback just to maintain this floor. Keep roof elevations.
|
||||||
// The surface keeps the height per point and casts the ray again only after
|
const height = globe.getHeight?.(record.carto);
|
||||||
// the camera moves or tiles load. MARKER: the answer is this point's own
|
|
||||||
// height, never a neighbour's, since a height taken here is kept for good.
|
|
||||||
const height = ground.terrain.height(
|
|
||||||
viewer,
|
|
||||||
record.latDeg,
|
|
||||||
record.lonDeg,
|
|
||||||
TERRAIN_NEED.MARKER,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(height) ||
|
!Number.isFinite(height) ||
|
||||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||||
@@ -1413,20 +1323,11 @@ function groundSamplePoint(record, height, result) {
|
|||||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||||
* sees it.
|
* sees it.
|
||||||
*
|
|
||||||
* The terrain height comes from the shared surface, which keeps it per point:
|
|
||||||
* this runs for every unsampled site in range on every walk, and a ray cast
|
|
||||||
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
|
|
||||||
* height only roughly, so a height the surface has not read yet, or tiles
|
|
||||||
* still loading, count as unknown ground.
|
|
||||||
*/
|
*/
|
||||||
function groundSampleInView(viewer, record, ground) {
|
function groundSampleInView(viewer, record) {
|
||||||
const camera = viewer.camera;
|
const camera = viewer.camera;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
const terrain =
|
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
||||||
globe?.show && globe.tilesLoaded !== false
|
|
||||||
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
|
|
||||||
: undefined;
|
|
||||||
const known =
|
const known =
|
||||||
Number.isFinite(terrain) &&
|
Number.isFinite(terrain) &&
|
||||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||||
@@ -1467,30 +1368,18 @@ function groundSampleInView(viewer, record, ground) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleLocalGroundHeight(viewer, record, now, ground) {
|
function sampleLocalGroundHeight(viewer, record, now) {
|
||||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||||
// This frame's samples have used its budget: leave the record for the next
|
if (!groundSampleInView(viewer, record)) return false;
|
||||||
// frame, unattempted, so the retry spacing does not hold it back.
|
|
||||||
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
|
|
||||||
if (!record.groundSampleQueued) {
|
|
||||||
record.groundSampleQueued = true;
|
|
||||||
ground.queued.push(record);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!groundSampleInView(viewer, record, ground)) return false;
|
|
||||||
record.lastGroundSampleMs = now;
|
record.lastGroundSampleMs = now;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||||
let sampled;
|
let sampled;
|
||||||
const startedAt = performance.now();
|
|
||||||
try {
|
try {
|
||||||
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
||||||
} catch {
|
} catch {
|
||||||
return false; // tiles not ready; retry on a later bounded update
|
return false; // tiles not ready; retry on a later bounded update
|
||||||
} finally {
|
|
||||||
ground.sampleMs += performance.now() - startedAt;
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(sampled) ||
|
!Number.isFinite(sampled) ||
|
||||||
@@ -1500,9 +1389,7 @@ function sampleLocalGroundHeight(viewer, record, now, ground) {
|
|||||||
// A coarse scene-depth sample can lie below the terrain already loaded by
|
// A coarse scene-depth sample can lie below the terrain already loaded by
|
||||||
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
||||||
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
||||||
// inactive terrain must not constrain their geometry. Read from the globe
|
// inactive terrain must not constrain their geometry.
|
||||||
// itself, once per site: the floor under a sample must be the mesh that is
|
|
||||||
// loaded now, not a height kept from a moment before.
|
|
||||||
const terrainHeight = globe?.show
|
const terrainHeight = globe?.show
|
||||||
? globe.getHeight?.(record.carto)
|
? globe.getHeight?.(record.carto)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1529,18 +1416,12 @@ function setLocalGroundHeight(record, height) {
|
|||||||
record.entity.__localBaseCartesian = record.base;
|
record.entity.__localBaseCartesian = record.base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocalStemGeometry(
|
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
||||||
viewer,
|
|
||||||
record,
|
|
||||||
now,
|
|
||||||
ground,
|
|
||||||
knownDistance = null,
|
|
||||||
) {
|
|
||||||
const distance = Number.isFinite(knownDistance)
|
const distance = Number.isFinite(knownDistance)
|
||||||
? knownDistance
|
? knownDistance
|
||||||
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
||||||
sampleLocalGroundHeight(viewer, record, now, ground);
|
sampleLocalGroundHeight(viewer, record, now);
|
||||||
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
||||||
// minimum made a marker hundreds of metres tall beside a nearby building.
|
// minimum made a marker hundreds of metres tall beside a nearby building.
|
||||||
const effectiveDistance = Math.max(distance, 1);
|
const effectiveDistance = Math.max(distance, 1);
|
||||||
|
|||||||
@@ -57,14 +57,7 @@ test('every typed aircraft renders at its published length, a real-world aircraf
|
|||||||
);
|
);
|
||||||
// A grounded aircraft stands on its wheels: its origin the scaled
|
// A grounded aircraft stands on its wheels: its origin the scaled
|
||||||
// clearance above the ground, never below it.
|
// clearance above the ground, never below it.
|
||||||
// A model built standing on its origin (a ground vehicle, a ground
|
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||||
// 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`);
|
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,41 +296,3 @@ test('a layer asking every tick gets one cached spec per type and category', asy
|
|||||||
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
||||||
assert.ok(Object.isFrozen(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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -291,7 +291,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
|||||||
assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
|
assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('civilian and military trail heads use the lower-centre model anchor', async () => {
|
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
||||||
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
||||||
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
||||||
)));
|
)));
|
||||||
@@ -300,5 +300,7 @@ test('civilian and military trail heads use the lower-centre model anchor', asyn
|
|||||||
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
|
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
|
||||||
`${name} trail head uses the dedicated lower-centre model anchor`,
|
`${name} trail head uses the dedicated lower-centre model anchor`,
|
||||||
);
|
);
|
||||||
|
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
||||||
|
`${name} keeps diffuse texture contribution weak through code-side MIX`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -46,15 +46,6 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
/**
|
|
||||||
* An aid to navigation's disc and card accent: a bone white apart from every
|
|
||||||
* ship type's colour.
|
|
||||||
*/
|
|
||||||
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
|
|
||||||
css: '#d9d2c0',
|
|
||||||
accent: '217, 210, 192',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||||
// and tests match on them.
|
// and tests match on them.
|
||||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||||
|
|||||||
@@ -45,110 +45,3 @@ export function normalizeVesselType(type) {
|
|||||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
|
|
||||||
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
|
|
||||||
* floating marks by their cardinal quadrant, lateral side, safe water,
|
|
||||||
* isolated danger or special mark, and the lights, RACONs and reference
|
|
||||||
* points around them. Code 0 names no kind.
|
|
||||||
*/
|
|
||||||
const AID_TYPE_NAMES = Object.freeze([
|
|
||||||
'Navigation aid',
|
|
||||||
'Reference point',
|
|
||||||
'RACON',
|
|
||||||
'Fixed structure off shore',
|
|
||||||
'Emergency wreck marking buoy',
|
|
||||||
'Light, without sectors',
|
|
||||||
'Light, with sectors',
|
|
||||||
'Leading light front',
|
|
||||||
'Leading light rear',
|
|
||||||
'Beacon, Cardinal N',
|
|
||||||
'Beacon, Cardinal E',
|
|
||||||
'Beacon, Cardinal S',
|
|
||||||
'Beacon, Cardinal W',
|
|
||||||
'Beacon, Port hand',
|
|
||||||
'Beacon, Starboard hand',
|
|
||||||
'Beacon, Preferred channel port hand',
|
|
||||||
'Beacon, Preferred channel starboard hand',
|
|
||||||
'Beacon, Isolated danger',
|
|
||||||
'Beacon, Safe water',
|
|
||||||
'Beacon, Special mark',
|
|
||||||
'Cardinal Mark N',
|
|
||||||
'Cardinal Mark E',
|
|
||||||
'Cardinal Mark S',
|
|
||||||
'Cardinal Mark W',
|
|
||||||
'Port hand Mark',
|
|
||||||
'Starboard hand Mark',
|
|
||||||
'Preferred Channel Port hand',
|
|
||||||
'Preferred Channel Starboard hand',
|
|
||||||
'Isolated danger',
|
|
||||||
'Safe Water',
|
|
||||||
'Special Mark',
|
|
||||||
'Light Vessel / LANBY / Rigs',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
|
|
||||||
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
|
|
||||||
* @param {string|number|null|undefined} code
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function aidToNavigationTypeName(code) {
|
|
||||||
const text = String(code ?? '').trim();
|
|
||||||
const index = /^\d+$/.test(text) ? Number(text) : -1;
|
|
||||||
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
|
|
||||||
const AID_TYPE_NAME_SET = new Set(
|
|
||||||
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Words a vessel type uses for an aid to navigation. A vessel that works on
|
|
||||||
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
|
|
||||||
* not a mark on the water.
|
|
||||||
*/
|
|
||||||
const AID_TYPE_TEXT =
|
|
||||||
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
|
|
||||||
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
|
|
||||||
|
|
||||||
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
|
||||||
const AID_MMSI = /^99\d{7}$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether each type text seen names an aid. Vessel layers ask per vessel on
|
|
||||||
* their draw passes, and a feed uses few distinct type texts, so each is
|
|
||||||
* matched once; the memo is bounded against a feed of free-form types.
|
|
||||||
*/
|
|
||||||
const AID_TYPE_MEMO = new Map();
|
|
||||||
const AID_TYPE_MEMO_MAX = 1024;
|
|
||||||
|
|
||||||
function typeNamesAid(text) {
|
|
||||||
let aid = AID_TYPE_MEMO.get(text);
|
|
||||||
if (aid !== undefined) return aid;
|
|
||||||
aid =
|
|
||||||
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
|
|
||||||
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
|
|
||||||
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
|
|
||||||
AID_TYPE_MEMO.set(text, aid);
|
|
||||||
return aid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
|
|
||||||
* RACON or light) rather than a vessel: its type is one of the kinds an aid
|
|
||||||
* report names or uses a word for one, or its MMSI is an aid-to-navigation
|
|
||||||
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
|
|
||||||
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
|
||||||
* virtual?: boolean}} vessel
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
|
|
||||||
if (virtual === true) return true;
|
|
||||||
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
|
|
||||||
if (text && typeNamesAid(text)) return true;
|
|
||||||
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
|
|
||||||
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
|
|
||||||
|
|
||||||
test('an aid-to-navigation type code names its kind of aid', () => {
|
|
||||||
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
|
|
||||||
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
|
|
||||||
assert.equal(aidToNavigationTypeName(2), 'RACON');
|
|
||||||
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
|
|
||||||
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
|
|
||||||
// Code 0, a code past 31 and no code name no kind.
|
|
||||||
for (const code of [0, 32, -1, '', null, undefined, 'x'])
|
|
||||||
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('aids to navigation are known by their type, else their MMSI', () => {
|
|
||||||
for (const type of [
|
|
||||||
'Beacon, Special Mark',
|
|
||||||
'Cardinal Mark N',
|
|
||||||
'Port Hand Mark',
|
|
||||||
'Starboard Hand Mark',
|
|
||||||
'Safe Water',
|
|
||||||
'Isolated danger',
|
|
||||||
'Preferred Channel Port hand',
|
|
||||||
'RACON',
|
|
||||||
'Light Vessel / LANBY / Rigs',
|
|
||||||
'Reference point',
|
|
||||||
'Navigation aid',
|
|
||||||
'AtoN',
|
|
||||||
'Lighthouse',
|
|
||||||
'Buoy',
|
|
||||||
// Every kind an aid report names is an aid, whatever its MMSI.
|
|
||||||
'Light, with sectors',
|
|
||||||
'Leading light rear',
|
|
||||||
'Fixed structure off shore',
|
|
||||||
'Emergency wreck marking buoy',
|
|
||||||
])
|
|
||||||
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
|
|
||||||
// A virtual aid is an aid whatever its type or MMSI.
|
|
||||||
assert.equal(
|
|
||||||
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
|
|
||||||
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
|
|
||||||
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
|
|
||||||
for (const vessel of [
|
|
||||||
{ type: 'Buoy Tender', mmsi: '366999001' },
|
|
||||||
{ type: 'Buoy-Laying Vessel', mmsi: '' },
|
|
||||||
{ type: 'Buoy Layer', mmsi: '' },
|
|
||||||
{ type: 'Lighter', mmsi: '' },
|
|
||||||
{ type: 'Flight deck', mmsi: '' },
|
|
||||||
{ type: 70, mmsi: 366999002 },
|
|
||||||
{ type: '70', mmsi: '366999002' },
|
|
||||||
{ type: '0', mmsi: '' },
|
|
||||||
{ type: 'Cargo', mmsi: '99' },
|
|
||||||
{ type: 'Passenger', mmsi: '9936720011' },
|
|
||||||
{},
|
|
||||||
])
|
|
||||||
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
|
|
||||||
});
|
|
||||||
@@ -776,8 +776,6 @@
|
|||||||
"TANKER": "ناقلة",
|
"TANKER": "ناقلة",
|
||||||
"OTHER": "أخرى",
|
"OTHER": "أخرى",
|
||||||
"VESSEL": "سفينة",
|
"VESSEL": "سفينة",
|
||||||
"AID TO NAVIGATION": "مساعدة ملاحية",
|
|
||||||
"VIRTUAL": "افتراضي",
|
|
||||||
"POS: LIVE": "الموضع: مباشر",
|
"POS: LIVE": "الموضع: مباشر",
|
||||||
"POS: {time}Z": "الموضع: {time}Z",
|
"POS: {time}Z": "الموضع: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
@@ -3955,12 +3953,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
"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": "إعادة ضبط العرض"
|
|
||||||
}
|
}
|
||||||
@@ -416,8 +416,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "SCHIFF",
|
"VESSEL": "SCHIFF",
|
||||||
"AID TO NAVIGATION": "SEEZEICHEN",
|
|
||||||
"VIRTUAL": "VIRTUELL",
|
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||||
@@ -3607,12 +3605,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
"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",
|
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -650,8 +650,6 @@
|
|||||||
"TANKER": "PETROLERO",
|
"TANKER": "PETROLERO",
|
||||||
"OTHER": "OTRO",
|
"OTHER": "OTRO",
|
||||||
"VESSEL": "BUQUE",
|
"VESSEL": "BUQUE",
|
||||||
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
|
||||||
"VIRTUAL": "VIRTUAL",
|
|
||||||
"POS: LIVE": "POS: EN VIVO",
|
"POS: LIVE": "POS: EN VIVO",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
@@ -3694,12 +3692,5 @@
|
|||||||
"500–1,000 m": "500–1000 m",
|
"500–1,000 m": "500–1000 m",
|
||||||
"1,000–2,000 m": "1000–2000 m",
|
"1,000–2,000 m": "1000–2000 m",
|
||||||
"2,000–3,000 m": "2000–3000 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"
|
|
||||||
}
|
}
|
||||||
@@ -1291,8 +1291,6 @@
|
|||||||
"AIS: {name}": "AIS : {name}",
|
"AIS: {name}": "AIS : {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||||
"VESSEL": "NAVIRE",
|
"VESSEL": "NAVIRE",
|
||||||
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
|
||||||
"VIRTUAL": "VIRTUEL",
|
|
||||||
"{speed}KT": "{speed} ND",
|
"{speed}KT": "{speed} ND",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS : DIRECT",
|
"POS: LIVE": "POS : DIRECT",
|
||||||
@@ -3694,12 +3692,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
"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",
|
"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}",
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -1149,8 +1149,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "אונייה",
|
"VESSEL": "אונייה",
|
||||||
"AID TO NAVIGATION": "עזר ניווט",
|
|
||||||
"VIRTUAL": "וירטואלי",
|
|
||||||
"{speed}KT": "{speed} קשר",
|
"{speed}KT": "{speed} קשר",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: חי",
|
"POS: LIVE": "POS: חי",
|
||||||
@@ -3694,12 +3692,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
"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": "איפוס תצוגה"
|
|
||||||
}
|
}
|
||||||
@@ -1291,8 +1291,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||||
"VESSEL": "NAVE",
|
"VESSEL": "NAVE",
|
||||||
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
|
||||||
"VIRTUAL": "VIRTUALE",
|
|
||||||
"{speed}KT": "{speed}KT",
|
"{speed}KT": "{speed}KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
@@ -3694,12 +3692,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
"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",
|
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -1253,8 +1253,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
"AID TO NAVIGATION": "航路標識",
|
|
||||||
"VIRTUAL": "仮想",
|
|
||||||
"{speed}KT": "{speed} kt",
|
"{speed}KT": "{speed} kt",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: ライブ",
|
"POS: LIVE": "POS: ライブ",
|
||||||
@@ -3520,12 +3518,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
"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": "ビューをリセット"
|
|
||||||
}
|
}
|
||||||
@@ -1289,8 +1289,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "EMBARCAÇÃO",
|
"VESSEL": "EMBARCAÇÃO",
|
||||||
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
|
||||||
"VIRTUAL": "VIRTUAL",
|
|
||||||
"{speed}KT": "{speed} KT",
|
"{speed}KT": "{speed} KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: AO VIVO",
|
"POS: LIVE": "POS: AO VIVO",
|
||||||
@@ -3694,12 +3692,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
"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",
|
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -394,8 +394,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
|
||||||
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
|
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||||
@@ -3781,12 +3779,5 @@
|
|||||||
"500–1,000 m": "500–1000 м",
|
"500–1,000 m": "500–1000 м",
|
||||||
"1,000–2,000 m": "1000–2000 м",
|
"1,000–2,000 m": "1000–2000 м",
|
||||||
"2,000–3,000 m": "2000–3000 м",
|
"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": "Сбросить вид"
|
|
||||||
}
|
}
|
||||||
@@ -382,8 +382,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "FARTYG",
|
"VESSEL": "FARTYG",
|
||||||
"AID TO NAVIGATION": "SJÖMÄRKE",
|
|
||||||
"VIRTUAL": "VIRTUELL",
|
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||||
@@ -3607,12 +3605,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
"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",
|
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
"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"
|
|
||||||
}
|
}
|
||||||
@@ -1309,8 +1309,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
|
||||||
"VIRTUAL": "ВІРТУАЛЬНИЙ",
|
|
||||||
"{speed}KT": "{speed} ВУЗ",
|
"{speed}KT": "{speed} ВУЗ",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||||
@@ -3781,12 +3779,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
"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": "Скинути вигляд"
|
|
||||||
}
|
}
|
||||||
@@ -1229,8 +1229,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
"AID TO NAVIGATION": "助航标志",
|
|
||||||
"VIRTUAL": "虚拟",
|
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:实时",
|
"POS: LIVE": "POS:实时",
|
||||||
@@ -3520,12 +3518,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
"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": "重置视图"
|
|
||||||
}
|
}
|
||||||
@@ -1224,8 +1224,6 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
"AID TO NAVIGATION": "助航標誌",
|
|
||||||
"VIRTUAL": "虛擬",
|
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:實時",
|
"POS: LIVE": "POS:實時",
|
||||||
@@ -3520,12 +3518,5 @@
|
|||||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
"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": "重置視圖"
|
|
||||||
}
|
}
|
||||||
@@ -411,8 +411,7 @@
|
|||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional",
|
"Regional",
|
||||||
"{datasets} · {tiles} · {packs}",
|
"{datasets} · {tiles} · {packs}"
|
||||||
"VIRTUAL"
|
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -1068,8 +1067,7 @@
|
|||||||
"ICAO: {id}",
|
"ICAO: {id}",
|
||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional",
|
"Regional"
|
||||||
"VIRTUAL"
|
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
// Station ground heights. A city's stations are all drawn at once at the
|
|
||||||
// fallback height; their ground heights are found afterwards a few
|
|
||||||
// milliseconds at a time, so a city of hundreds of docks never holds the main
|
|
||||||
// thread for the length of hundreds of depth renders. Each case drives the
|
|
||||||
// real rendering part (and the real lifecycle where it tears down) over a
|
|
||||||
// scene whose height reads are counted and timed.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createState } from './state.js';
|
|
||||||
import { createModel } from './model.js';
|
|
||||||
import { createRendering } from './rendering.js';
|
|
||||||
import { createLifecycle } from './lifecycle.js';
|
|
||||||
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
|
|
||||||
import { createTerrainSurface } from '../../services/terrainSurface.js';
|
|
||||||
|
|
||||||
globalThis.document ??= {
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Milliseconds one fake depth render holds the thread. */
|
|
||||||
const SAMPLE_COST_MS = 1;
|
|
||||||
/** The ground under a point, as the fake scene and globe both report it. */
|
|
||||||
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
|
|
||||||
|
|
||||||
function busyWait(ms) {
|
|
||||||
const until = performance.now() + ms;
|
|
||||||
while (performance.now() < until);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `count` stations on a line through Boston. */
|
|
||||||
function stations(count, prefix = 's') {
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
stations: Array.from({ length: count }, (_, i) => ({
|
|
||||||
station_id: `${prefix}${i}`,
|
|
||||||
name: `Dock ${i}`,
|
|
||||||
lat: 42.3 + i * 1e-4,
|
|
||||||
lon: -71.1 + i * 1e-4,
|
|
||||||
capacity: 10,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rendering part over a scene that counts its height reads.
|
|
||||||
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
|
|
||||||
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
|
|
||||||
* `shared`: the application's terrain surface is supplied;
|
|
||||||
* `unloaded`: latitudes whose terrain tile is not drawn.
|
|
||||||
*/
|
|
||||||
function makeLayer({
|
|
||||||
terrainGlobe = false,
|
|
||||||
shared = false,
|
|
||||||
unloaded = () => false,
|
|
||||||
} = {}) {
|
|
||||||
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
|
|
||||||
// sampleHeight calls in this turn of the event loop, and the most in any
|
|
||||||
// turn so far. Two numbers, not a list: a slow run has a great many turns.
|
|
||||||
let thisTurn = 0;
|
|
||||||
let mostInATurn = 0;
|
|
||||||
let ticking = true;
|
|
||||||
const tick = () => {
|
|
||||||
if (!ticking) return;
|
|
||||||
thisTurn = 0;
|
|
||||||
setImmediate(tick);
|
|
||||||
};
|
|
||||||
setImmediate(tick);
|
|
||||||
|
|
||||||
const degrees = (carto) => [
|
|
||||||
Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
];
|
|
||||||
const viewer = {
|
|
||||||
camera: {
|
|
||||||
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
|
|
||||||
changed: { addEventListener() {}, removeEventListener() {} },
|
|
||||||
},
|
|
||||||
scene: {
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
sampleHeight(carto) {
|
|
||||||
calls.sampleHeight += 1;
|
|
||||||
thisTurn += 1;
|
|
||||||
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
|
|
||||||
busyWait(SAMPLE_COST_MS);
|
|
||||||
return groundAt(...degrees(carto));
|
|
||||||
},
|
|
||||||
globe: {
|
|
||||||
show: terrainGlobe,
|
|
||||||
tilesLoaded: true,
|
|
||||||
getHeight(carto) {
|
|
||||||
calls.getHeight += 1;
|
|
||||||
const [lat, lon] = degrees(carto);
|
|
||||||
return unloaded(lat) ? undefined : groundAt(lat, lon);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
primitives: { add() {}, remove() {} },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const surface = createTerrainSurface();
|
|
||||||
const services = {
|
|
||||||
render: {
|
|
||||||
governorRequestRender() {
|
|
||||||
calls.render += 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlays: {
|
|
||||||
setOverlayEntries() {},
|
|
||||||
setOverlaySourceVisible() {},
|
|
||||||
clearOverlaySource() {},
|
|
||||||
},
|
|
||||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
|
||||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
|
||||||
};
|
|
||||||
if (shared) {
|
|
||||||
services.ground = {
|
|
||||||
terrainSurface: {
|
|
||||||
...surface,
|
|
||||||
height(...args) {
|
|
||||||
calls.sharedHeight += 1;
|
|
||||||
return surface.height(...args);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const state = createState({ services });
|
|
||||||
const parts = {};
|
|
||||||
const context = { state, services, parts, source: {} };
|
|
||||||
parts.model = createModel(context);
|
|
||||||
parts.rendering = createRendering(context);
|
|
||||||
parts.selection = {
|
|
||||||
_installClickHandler() {},
|
|
||||||
_clearSelection() {},
|
|
||||||
_onKeyDown() {},
|
|
||||||
};
|
|
||||||
parts.ingestion = { abortAllInFlight() {} };
|
|
||||||
// Leaves the points alone, so a test of disable() sees what disable() stops.
|
|
||||||
parts.viewport = {
|
|
||||||
onCameraChanged() {},
|
|
||||||
deactivateAllCities() {},
|
|
||||||
runProximityCheck: async () => {},
|
|
||||||
};
|
|
||||||
parts.lifecycle = createLifecycle(context);
|
|
||||||
parts.lifecycle.methods.init(viewer);
|
|
||||||
state._pointCollection = {
|
|
||||||
show: true,
|
|
||||||
add: (options) => ({ ...options }),
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightOf = (key) =>
|
|
||||||
Cesium.Cartographic.fromCartesian(
|
|
||||||
state._stationRenderMap.get(key).point.position,
|
|
||||||
).height;
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
parts,
|
|
||||||
viewer,
|
|
||||||
calls,
|
|
||||||
rendering: parts.rendering,
|
|
||||||
lifecycle: parts.lifecycle.methods,
|
|
||||||
heightOf,
|
|
||||||
add(cityId, count) {
|
|
||||||
const info = parts.model.parseStationInformation(stations(count, cityId));
|
|
||||||
parts.rendering.ensureCityPoints(cityId, info);
|
|
||||||
return info;
|
|
||||||
},
|
|
||||||
/** Wait until every queued station has its height, or fail. */
|
|
||||||
async refined() {
|
|
||||||
const deadline = performance.now() + 20_000;
|
|
||||||
while (parts.rendering.pendingHeightCount() > 0) {
|
|
||||||
assert.ok(performance.now() < deadline, 'refinement finished');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** The most sampleHeight calls made without yielding to the loop. */
|
|
||||||
longestBurst() {
|
|
||||||
return mostInATurn;
|
|
||||||
},
|
|
||||||
done() {
|
|
||||||
ticking = false;
|
|
||||||
parts.rendering.stopHeightRefinement();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
test('a city of stations is drawn at once without sampling heights', (t) => {
|
|
||||||
const layer = makeLayer();
|
|
||||||
t.after(layer.done);
|
|
||||||
const started = performance.now();
|
|
||||||
layer.add('boston', 300);
|
|
||||||
const spent = performance.now() - started;
|
|
||||||
|
|
||||||
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
|
|
||||||
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
|
|
||||||
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
|
|
||||||
'drawn at the fallback height',
|
|
||||||
);
|
|
||||||
assert.equal(layer.rendering.pendingHeightCount(), 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
|
|
||||||
const layer = makeLayer();
|
|
||||||
t.after(layer.done);
|
|
||||||
const info = layer.add('boston', 120);
|
|
||||||
const rendersBefore = layer.calls.render;
|
|
||||||
await layer.refined();
|
|
||||||
|
|
||||||
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
|
|
||||||
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
|
|
||||||
assert.ok(
|
|
||||||
layer.longestBurst() <= 10,
|
|
||||||
`longest burst was ${layer.longestBurst()} samples`,
|
|
||||||
);
|
|
||||||
for (const station of info.values()) {
|
|
||||||
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
|
||||||
const actual = layer.heightOf(`boston:${station.stationId}`);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(actual - expected) < 1e-3,
|
|
||||||
`${station.stationId}: ${actual} vs ${expected}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
|
|
||||||
const layer = makeLayer({ terrainGlobe: true, shared: true });
|
|
||||||
t.after(layer.done);
|
|
||||||
const info = layer.add('boston', 200);
|
|
||||||
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
|
|
||||||
await layer.refined();
|
|
||||||
|
|
||||||
assert.equal(layer.calls.sampleHeight, 0);
|
|
||||||
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
|
|
||||||
assert.equal(layer.calls.getHeight, 200);
|
|
||||||
for (const station of info.values()) {
|
|
||||||
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
|
||||||
const actual = layer.heightOf(`boston:${station.stationId}`);
|
|
||||||
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
|
|
||||||
const layer = makeLayer({ terrainGlobe: true });
|
|
||||||
t.after(layer.done);
|
|
||||||
layer.add('boston', 50);
|
|
||||||
await layer.refined();
|
|
||||||
assert.equal(layer.calls.sampleHeight, 0);
|
|
||||||
assert.equal(layer.calls.getHeight, 50);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(
|
|
||||||
layer.heightOf('boston:boston7') -
|
|
||||||
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
|
|
||||||
) < 1e-3,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
|
|
||||||
// Stations 10 and up lie on a tile the globe is not drawing.
|
|
||||||
const layer = makeLayer({
|
|
||||||
terrainGlobe: true,
|
|
||||||
shared: true,
|
|
||||||
unloaded: (lat) => lat > 42.30095,
|
|
||||||
});
|
|
||||||
t.after(layer.done);
|
|
||||||
layer.add('boston', 20);
|
|
||||||
await layer.refined();
|
|
||||||
assert.equal(layer.calls.sampleHeight, 10);
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(
|
|
||||||
layer.heightOf('boston:boston15') -
|
|
||||||
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
|
|
||||||
) < 1e-3,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removing a city stops its refinement and leaves the other running', async (t) => {
|
|
||||||
const layer = makeLayer();
|
|
||||||
t.after(layer.done);
|
|
||||||
layer.add('boston', 40);
|
|
||||||
layer.add('toronto', 40);
|
|
||||||
layer.rendering.removeCityPoints('boston');
|
|
||||||
assert.equal(layer.rendering.pendingHeightCount(), 40);
|
|
||||||
await layer.refined();
|
|
||||||
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
|
|
||||||
assert.ok(layer.heightOf('toronto:toronto3') > 50);
|
|
||||||
|
|
||||||
layer.add('boston', 40);
|
|
||||||
layer.rendering.removeCityPoints('boston');
|
|
||||||
layer.rendering.removeCityPoints('toronto');
|
|
||||||
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
|
||||||
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
|
||||||
await pause(60);
|
|
||||||
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disabling the layer stops refinement and leaves no timer', async (t) => {
|
|
||||||
const layer = makeLayer();
|
|
||||||
t.after(layer.done);
|
|
||||||
layer.lifecycle.enable(layer.viewer);
|
|
||||||
layer.add('boston', 200);
|
|
||||||
await pause(30);
|
|
||||||
const partWay = layer.calls.sampleHeight;
|
|
||||||
assert.ok(partWay < 200, 'still refining');
|
|
||||||
layer.lifecycle.disable(layer.viewer);
|
|
||||||
|
|
||||||
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
|
||||||
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
|
||||||
await pause(60);
|
|
||||||
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a station selected before its height arrives is re-selected at it', async (t) => {
|
|
||||||
const layer = makeLayer();
|
|
||||||
t.after(layer.done);
|
|
||||||
layer.add('boston', 3);
|
|
||||||
const reselected = [];
|
|
||||||
layer.state._selectedKey = 'boston:boston1';
|
|
||||||
// The selection part's select path; here it records the key.
|
|
||||||
layer.parts.selection._selectStation = (key) => reselected.push(key);
|
|
||||||
await layer.refined();
|
|
||||||
assert.deepEqual(reselected, ['boston:boston1']);
|
|
||||||
});
|
|
||||||
@@ -44,7 +44,6 @@ export function createLifecycle({
|
|||||||
layerState._inFlightInfo = new Map();
|
layerState._inFlightInfo = new Map();
|
||||||
layerState._inFlightStatus = new Map();
|
layerState._inFlightStatus = new Map();
|
||||||
layerState._stationRenderMap = new Map();
|
layerState._stationRenderMap = new Map();
|
||||||
parts.rendering?.stopHeightRefinement?.();
|
|
||||||
layerState._clickHandler = null;
|
layerState._clickHandler = null;
|
||||||
layerState._selectedKey = null;
|
layerState._selectedKey = null;
|
||||||
layerState._selectedEntity = null;
|
layerState._selectedEntity = null;
|
||||||
@@ -135,7 +134,6 @@ export function createLifecycle({
|
|||||||
|
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
parts.viewport.deactivateAllCities();
|
parts.viewport.deactivateAllCities();
|
||||||
parts.rendering?.stopHeightRefinement?.();
|
|
||||||
layerState._cityRuntime.clear();
|
layerState._cityRuntime.clear();
|
||||||
layerState._pointCollection.show = false;
|
layerState._pointCollection.show = false;
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
@@ -167,7 +165,6 @@ export function createLifecycle({
|
|||||||
layerState._cameraChangedAttached = false;
|
layerState._cameraChangedAttached = false;
|
||||||
}
|
}
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
parts.rendering?.stopHeightRefinement?.();
|
|
||||||
if (layerState._pointCollection) {
|
if (layerState._pointCollection) {
|
||||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||||
layerState._pointCollection = null;
|
layerState._pointCollection = null;
|
||||||
|
|||||||
@@ -50,17 +50,6 @@ export const DEFAULT_CAPACITY = 15;
|
|||||||
|
|
||||||
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
||||||
|
|
||||||
/**
|
|
||||||
* Milliseconds one step may spend finding station ground heights. A height
|
|
||||||
* the terrain globe cannot give is a depth render of a millisecond or more.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const HEIGHT_REFINE_BUDGET_MS = 4;
|
|
||||||
|
|
||||||
/** Milliseconds between those steps, about a frame. */
|
|
||||||
|
|
||||||
export const HEIGHT_REFINE_INTERVAL_MS = 16;
|
|
||||||
|
|
||||||
/** Hard cap on total rendered station points across all cities. */
|
/** Hard cap on total rendered station points across all cities. */
|
||||||
|
|
||||||
export const MAX_TOTAL_POINTS = 8000;
|
export const MAX_TOTAL_POINTS = 8000;
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ import {
|
|||||||
POINT_HEIGHT_OFFSET_M,
|
POINT_HEIGHT_OFFSET_M,
|
||||||
MAX_TOTAL_POINTS,
|
MAX_TOTAL_POINTS,
|
||||||
COLOR_OUTLINE,
|
COLOR_OUTLINE,
|
||||||
HEIGHT_REFINE_BUDGET_MS,
|
|
||||||
HEIGHT_REFINE_INTERVAL_MS,
|
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import {
|
|
||||||
createTerrainSurface,
|
|
||||||
TERRAIN_NEED,
|
|
||||||
} from '../../services/terrainSurface.js';
|
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -26,14 +20,6 @@ export function createRendering({
|
|||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { governorRequestRender } = services.render;
|
const { governorRequestRender } = services.render;
|
||||||
// The terrain globe's loaded surface: the application's shared reader, or
|
|
||||||
// the layer's own when it is built without one.
|
|
||||||
const terrainSurface =
|
|
||||||
services.ground?.terrainSurface || createTerrainSurface();
|
|
||||||
/** Stations drawn at the fallback height, waiting for their ground height. */
|
|
||||||
let heightQueue = [];
|
|
||||||
let heightTimer = null;
|
|
||||||
const heightPoint = new Cesium.Cartographic();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective dock capacity for a station.
|
* Resolve the effective dock capacity for a station.
|
||||||
@@ -101,10 +87,8 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Cartesian3 position for a station at the fallback height. Its
|
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
||||||
* ground height is found afterwards (refineHeights): a city's stations are
|
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
||||||
* drawn at once, and finding hundreds of heights in the same task held the
|
|
||||||
* main thread for most of a second.
|
|
||||||
* @param {Object} station - Station info object with lat/lon.
|
* @param {Object} station - Station info object with lat/lon.
|
||||||
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
||||||
*/
|
*/
|
||||||
@@ -113,78 +97,18 @@ export function createRendering({
|
|||||||
const lon = Number(station?.lon);
|
const lon = Number(station?.lon);
|
||||||
const lat = Number(station?.lat);
|
const lat = Number(station?.lat);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
let height = POINT_HEIGHT_OFFSET_M;
|
||||||
* The ground height under a station, or undefined when the scene cannot say.
|
// Sample terrain height so points sit on ground rather than at ellipsoid level
|
||||||
* A terrain globe answers from its loaded surface, which is cheap. Where it
|
if (layerState._viewer?.scene?.sampleHeightSupported) {
|
||||||
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
|
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||||
* at the point) the scene renders the depth there, at a millisecond or more
|
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
||||||
* a point.
|
if (Number.isFinite(sampled)) {
|
||||||
*/
|
height = sampled + POINT_HEIGHT_OFFSET_M;
|
||||||
|
}
|
||||||
function sampleGroundHeight(lat, lon) {
|
|
||||||
const viewer = layerState._viewer;
|
|
||||||
const scene = viewer?.scene;
|
|
||||||
if (!scene) return undefined;
|
|
||||||
// A station is a marker: its one read is not left for a later window.
|
|
||||||
// The step's own time budget bounds what a pass spends.
|
|
||||||
const terrain = terrainSurface.height(
|
|
||||||
viewer,
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
TERRAIN_NEED.MARKER,
|
|
||||||
);
|
|
||||||
if (Number.isFinite(terrain)) return terrain;
|
|
||||||
if (!scene.sampleHeightSupported) return undefined;
|
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
|
|
||||||
const sampled = scene.sampleHeight(heightPoint);
|
|
||||||
return Number.isFinite(sampled) ? sampled : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move queued stations onto the ground for a few milliseconds, then leave
|
|
||||||
* the rest to the next step. A station the scene has no height for keeps
|
|
||||||
* the fallback height, as it did when heights were read while drawing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function refineHeights() {
|
|
||||||
heightTimer = null;
|
|
||||||
const started = performance.now();
|
|
||||||
let moved = false;
|
|
||||||
while (
|
|
||||||
heightQueue.length > 0 &&
|
|
||||||
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
|
|
||||||
) {
|
|
||||||
const { record, lat, lon } = heightQueue.pop();
|
|
||||||
const ground = sampleGroundHeight(lat, lon);
|
|
||||||
if (ground === undefined) continue;
|
|
||||||
record.point.position = Cesium.Cartesian3.fromDegrees(
|
|
||||||
lon,
|
|
||||||
lat,
|
|
||||||
ground + POINT_HEIGHT_OFFSET_M,
|
|
||||||
);
|
|
||||||
moved = true;
|
|
||||||
// The highlight and card of a selected station copy its position.
|
|
||||||
if (layerState._selectedKey === record.key)
|
|
||||||
parts.selection._selectStation(record.key);
|
|
||||||
}
|
}
|
||||||
if (moved) governorRequestRender('bikeshare-heights');
|
|
||||||
scheduleHeightRefinement();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleHeightRefinement() {
|
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||||
if (heightTimer !== null || heightQueue.length === 0) return;
|
|
||||||
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop every queued station and the pending step. */
|
|
||||||
|
|
||||||
function stopHeightRefinement() {
|
|
||||||
heightQueue = [];
|
|
||||||
clearTimeout(heightTimer);
|
|
||||||
heightTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,7 +178,7 @@ export function createRendering({
|
|||||||
id: key,
|
id: key,
|
||||||
});
|
});
|
||||||
|
|
||||||
const record = {
|
layerState._stationRenderMap.set(key, {
|
||||||
key,
|
key,
|
||||||
cityId,
|
cityId,
|
||||||
stationId: station.stationId,
|
stationId: station.stationId,
|
||||||
@@ -267,19 +191,12 @@ export function createRendering({
|
|||||||
isInstalled: station.isInstalled,
|
isInstalled: station.isInstalled,
|
||||||
isRenting: station.isRenting,
|
isRenting: station.isRenting,
|
||||||
isReturning: station.isReturning,
|
isReturning: station.isReturning,
|
||||||
};
|
|
||||||
layerState._stationRenderMap.set(key, record);
|
|
||||||
heightQueue.push({
|
|
||||||
record,
|
|
||||||
lat: Number(station.lat),
|
|
||||||
lon: Number(station.lon),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.stationKeys.add(key);
|
runtime.stationKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
scheduleHeightRefinement();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,12 +224,6 @@ export function createRendering({
|
|||||||
layerState._stationRenderMap.delete(key);
|
layerState._stationRenderMap.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Its stations no longer wait for a height.
|
|
||||||
heightQueue = heightQueue.filter(
|
|
||||||
({ record }) => layerState._stationRenderMap.get(record.key) === record,
|
|
||||||
);
|
|
||||||
if (heightQueue.length === 0) stopHeightRefinement();
|
|
||||||
|
|
||||||
layerState._cityRuntime.delete(cityId);
|
layerState._cityRuntime.delete(cityId);
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
}
|
}
|
||||||
@@ -369,10 +280,5 @@ export function createRendering({
|
|||||||
ensureCityPoints,
|
ensureCityPoints,
|
||||||
removeCityPoints,
|
removeCityPoints,
|
||||||
applyStatusToPoints,
|
applyStatusToPoints,
|
||||||
stopHeightRefinement,
|
|
||||||
/** Stations still waiting for their ground height. */
|
|
||||||
pendingHeightCount: () => heightQueue.length,
|
|
||||||
/** Whether a refinement step is waiting on a timer. */
|
|
||||||
heightRefinementScheduled: () => heightTimer !== null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js';
|
||||||
aircraftCallsignRule,
|
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js';
|
||||||
aircraftModelByCallsign,
|
|
||||||
aircraftModelSpec,
|
|
||||||
fleetModelSet,
|
|
||||||
} from '../../data/fleetModels.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an aircraft draws with before the fleet model set has loaded: no
|
* What an aircraft draws with before the fleet model set has loaded: no
|
||||||
@@ -21,6 +17,7 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
|||||||
bellyM: 0,
|
bellyM: 0,
|
||||||
elevationM: 0,
|
elevationM: 0,
|
||||||
tinted: false,
|
tinted: false,
|
||||||
|
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||||
visualCenterNative: Object.freeze([0, 0, 0]),
|
visualCenterNative: Object.freeze([0, 0, 0]),
|
||||||
trailAnchorNative: Object.freeze([0, 0, 0]),
|
trailAnchorNative: Object.freeze([0, 0, 0]),
|
||||||
key: 'unloaded',
|
key: 'unloaded',
|
||||||
@@ -28,14 +25,12 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The fleet model, life-size scale and measured geometry an aircraft draws
|
* The fleet model, life-size scale and measured geometry an aircraft draws
|
||||||
* with, chosen by its ICAO type and emitter category, and by its callsign
|
* with, chosen by its ICAO type and emitter category (`fleetModels.js`).
|
||||||
* for the types that pick by callsign (`fleetModels.js`).
|
|
||||||
* Shared by the Flights, Military Flights and Local ADS-B layers so all three
|
* 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
|
* 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.
|
* clearance: how far a grounded aircraft's origin stands above the ground.
|
||||||
* @param {{typeCode?: string|null, type?: string|null,
|
* @param {{typeCode?: string|null, type?: string|null,
|
||||||
* category?: string|number|null,
|
* category?: string|number|null}|null|undefined} aircraft
|
||||||
* callsign?: string|null}|null|undefined} aircraft
|
|
||||||
* @param {object|null} [set] The loaded fleet model set.
|
* @param {object|null} [set] The loaded fleet model set.
|
||||||
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
|
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
|
||||||
* the set has loaded.
|
* the set has loaded.
|
||||||
@@ -45,17 +40,17 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
|||||||
// Layers ask on every tick; each type and category is resolved once per set.
|
// Layers ask on every tick; each type and category is resolved once per set.
|
||||||
let cache = specCaches.get(set);
|
let cache = specCaches.get(set);
|
||||||
if (!cache) specCaches.set(set, (cache = new Map()));
|
if (!cache) specCaches.set(set, (cache = new Map()));
|
||||||
const key = aircraftModelSpecCacheKey(aircraft, set);
|
const key = aircraftModelSpecCacheKey(aircraft);
|
||||||
let layerSpec = cache.get(key);
|
let layerSpec = cache.get(key);
|
||||||
if (!layerSpec) {
|
if (!layerSpec) {
|
||||||
const spec = aircraftModelSpec(set, {
|
const spec = aircraftModelSpec(set, {
|
||||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||||
category: aircraft?.category ?? null,
|
category: aircraft?.category ?? null,
|
||||||
callsign: aircraft?.callsign ?? null,
|
|
||||||
});
|
});
|
||||||
layerSpec = Object.freeze({
|
layerSpec = Object.freeze({
|
||||||
...spec,
|
...spec,
|
||||||
bellyM: spec.clearanceM,
|
bellyM: spec.clearanceM,
|
||||||
|
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||||
});
|
});
|
||||||
cache.set(key, layerSpec);
|
cache.set(key, layerSpec);
|
||||||
}
|
}
|
||||||
@@ -66,18 +61,10 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
|||||||
const specCaches = new WeakMap();
|
const specCaches = new WeakMap();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache key of an aircraft's model spec: what selects its model. For a type
|
* Cache key of an aircraft's model spec: what selects its model.
|
||||||
* 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|undefined} aircraft
|
||||||
* @param {object|null} [set] The loaded fleet model set.
|
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) {
|
export function aircraftModelSpecCacheKey(aircraft) {
|
||||||
const type = aircraft?.typeCode ?? aircraft?.type ?? '';
|
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`;
|
||||||
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'}`;
|
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,10 @@ export const MODEL_ALL_KEEP_M = 450000;
|
|||||||
|
|
||||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||||
// every fleet model is nose -X: opposite Cesium heading 0
|
// every fleet model is nose -X: opposite Cesium heading 0
|
||||||
|
// Owner launch-polish direction: models should read as clean light silhouettes,
|
||||||
|
// with only a weak diffuse contribution from the models' own colours.
|
||||||
|
|
||||||
|
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||||
|
|
||||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import {
|
|||||||
} from '../../data/renderAltitude.js';
|
} from '../../data/renderAltitude.js';
|
||||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import {
|
|
||||||
aircraftCallsignRule,
|
|
||||||
aircraftModelSpec,
|
|
||||||
fleetModelSet,
|
|
||||||
} from '../../data/fleetModels.js';
|
|
||||||
import {
|
import {
|
||||||
approxDistanceKm,
|
approxDistanceKm,
|
||||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||||
@@ -26,7 +21,6 @@ export class FlightRecords {
|
|||||||
cachedGroundFloor,
|
cachedGroundFloor,
|
||||||
floorAltitudeM,
|
floorAltitudeM,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
fleetModels = fleetModelSet,
|
|
||||||
}) {
|
}) {
|
||||||
this.services = {
|
this.services = {
|
||||||
geoidHeight,
|
geoidHeight,
|
||||||
@@ -35,8 +29,6 @@ export class FlightRecords {
|
|||||||
};
|
};
|
||||||
/** The time records are judged at: the layer's clock. */
|
/** The time records are judged at: the layer's clock. */
|
||||||
this.now = now;
|
this.now = now;
|
||||||
/** The loaded fleet model set, or null before it loads. */
|
|
||||||
this.fleetModels = fleetModels;
|
|
||||||
this.data = new Map();
|
this.data = new Map();
|
||||||
// Facts about an airframe, not a moment, kept across a reset.
|
// Facts about an airframe, not a moment, kept across a reset.
|
||||||
this.airframes = new Map();
|
this.airframes = new Map();
|
||||||
@@ -100,6 +92,7 @@ export class FlightRecords {
|
|||||||
verticalRateMps: vertical_rate,
|
verticalRateMps: vertical_rate,
|
||||||
} = observation;
|
} = observation;
|
||||||
|
|
||||||
|
const onGround = on_ground === true;
|
||||||
this.missingPolls.delete(icao24);
|
this.missingPolls.delete(icao24);
|
||||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||||
// aircraft it still positions — hold last-known-good instead of
|
// aircraft it still positions — hold last-known-good instead of
|
||||||
@@ -109,27 +102,6 @@ export class FlightRecords {
|
|||||||
// The airframe's facts: this record's, or those kept across a reset.
|
// The airframe's facts: this record's, or those kept across a reset.
|
||||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||||
this.airframes.delete(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
|
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||||
// airborne default (a parked plane must never float).
|
// airborne default (a parked plane must never float).
|
||||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||||
@@ -347,9 +319,8 @@ export class FlightRecords {
|
|||||||
prevMeta?.lastContactEpochMs,
|
prevMeta?.lastContactEpochMs,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
// adsbdb enrichment — written by the enrichment callbacks, carried
|
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||||
// across polls; a ground vehicle's or station's from the feed:
|
typeCode: airframe?.typeCode ?? null,
|
||||||
typeCode,
|
|
||||||
typeName: airframe?.typeName ?? null,
|
typeName: airframe?.typeName ?? null,
|
||||||
registration: airframe?.registration ?? null,
|
registration: airframe?.registration ?? null,
|
||||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||||
@@ -437,33 +408,3 @@ export class FlightRecords {
|
|||||||
this.geoidNCache.delete(id);
|
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,28 +1,13 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { FlightRecords } from './records.js';
|
||||||
|
|
||||||
const FLEET = path.resolve(
|
function records() {
|
||||||
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({
|
return new FlightRecords({
|
||||||
geoidHeight: () => 40,
|
geoidHeight: () => 40,
|
||||||
cachedGroundFloor: () => null,
|
cachedGroundFloor: () => null,
|
||||||
floorAltitudeM: (altitude) => altitude,
|
floorAltitudeM: (altitude) => altitude,
|
||||||
approxDistanceKm: () => 0,
|
approxDistanceKm: () => 0,
|
||||||
fleetModels,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const view = () => ({
|
const view = () => ({
|
||||||
@@ -275,47 +260,3 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged
|
|||||||
store.forget('abc123');
|
store.forget('abc123');
|
||||||
assert.equal(store.fixRepeated.has('abc123'), false);
|
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);
|
|
||||||
});
|
|
||||||
@@ -45,10 +45,7 @@ import {
|
|||||||
STALE_ALPHA,
|
STALE_ALPHA,
|
||||||
displayDelayMs,
|
displayDelayMs,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import {
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
|
||||||
aircraftModelBlend,
|
|
||||||
} from '../../data/aircraftModelLighting.js';
|
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -195,12 +192,11 @@ export function createRendering({
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Model colour: the mesh's own (white), tinted cyan while tracked and red
|
/** Model tint, mirroring the billboard color rules. */
|
||||||
* while broadcasting an emergency. */
|
|
||||||
|
|
||||||
function _modelColor(icao24) {
|
function _modelColor(icao24) {
|
||||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||||
return _isEmergency(icao24) ? EMERGENCY_TINT : Cesium.Color.WHITE;
|
return _fleetBillboardColor(icao24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
@@ -530,7 +526,10 @@ export function createRendering({
|
|||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||||
...aircraftModelBlend(flightState._irBoost),
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// Launch presentation keeps the code-side tint dominant for every approved
|
||||||
|
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
|
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
||||||
customShader: flightState._irBoost
|
customShader: flightState._irBoost
|
||||||
? flightState._IR_UNLIT_SHADER
|
? flightState._IR_UNLIT_SHADER
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -749,7 +748,10 @@ export function createRendering({
|
|||||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||||
scale: trackedSpec.scale,
|
scale: trackedSpec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
||||||
...aircraftModelBlend(flightState._irBoost),
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||||
|
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
|
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||||
customShader: flightState._irBoost
|
customShader: flightState._irBoost
|
||||||
? flightState._IR_UNLIT_SHADER
|
? flightState._IR_UNLIT_SHADER
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -700,6 +700,7 @@ export function createLocalAdsbLayer({
|
|||||||
if (viewer.scene?.primitives) {
|
if (viewer.scene?.primitives) {
|
||||||
models = createLocalAdsbModels({
|
models = createLocalAdsbModels({
|
||||||
viewer,
|
viewer,
|
||||||
|
color,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||||||
...(loadModel ? { loadModel } : {}),
|
...(loadModel ? { loadModel } : {}),
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o
|
|||||||
assert.equal(queries.length, 2, 'each key is asked for once');
|
assert.equal(queries.length, 2, 'each key is asked for once');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mesh colours', async (t) => {
|
test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => {
|
||||||
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
|
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
|
||||||
const clock = { now: 100_000 };
|
const clock = { now: 100_000 };
|
||||||
const loads = [];
|
const loads = [];
|
||||||
@@ -548,10 +548,13 @@ test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mes
|
|||||||
assert.equal(model.scale, spec.scale, 'drawn life size');
|
assert.equal(model.scale, spec.scale, 'drawn life size');
|
||||||
assert.equal(model.id, 'local-adsb:a0b702');
|
assert.equal(model.id, 'local-adsb:a0b702');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Cesium.Color.equals(model.color, Cesium.Color.WHITE),
|
Cesium.Color.equals(
|
||||||
'the mesh draws its own colours',
|
model.color,
|
||||||
|
Cesium.Color.fromCssColorString('#ff4fd8'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.HIGHLIGHT);
|
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX);
|
||||||
|
assert.equal(model.colorBlendAmount, 0.94);
|
||||||
assert.equal(model.show, true);
|
assert.equal(model.show, true);
|
||||||
const heli = sources[0].entities.getById('local-adsb:a0b702');
|
const heli = sources[0].entities.getById('local-adsb:a0b702');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -16,16 +16,12 @@ import {
|
|||||||
MODEL_PROX_ADD_M,
|
MODEL_PROX_ADD_M,
|
||||||
MODEL_PROX_KEEP_M,
|
MODEL_PROX_KEEP_M,
|
||||||
} from '../flights/policy.js';
|
} from '../flights/policy.js';
|
||||||
import {
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
|
||||||
aircraftModelBlend,
|
|
||||||
} from '../../data/aircraftModelLighting.js';
|
|
||||||
|
|
||||||
const specOf = (marker) =>
|
const specOf = (marker) =>
|
||||||
aircraftLayerModelSpec({
|
aircraftLayerModelSpec({
|
||||||
typeCode: marker?.typeCode,
|
typeCode: marker?.typeCode,
|
||||||
category: marker?.category,
|
category: marker?.category,
|
||||||
callsign: marker?.record?.callsign,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,14 +30,15 @@ const specOf = (marker) =>
|
|||||||
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
||||||
* the same proximity/all caps and add/keep radii, the same visible-first
|
* the same proximity/all caps and add/keep radii, the same visible-first
|
||||||
* eligibility (`selectModelEligible`), the same fleet model per type, life
|
* eligibility (`selectModelEligible`), the same fleet model per type, life
|
||||||
* size (`aircraftLayerModelSpec`) and the same colours: the mesh's own
|
* size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here
|
||||||
* (`aircraftModelBlend`). Each model owns its matrix. A
|
* magenta, as Flights tints military amber). Each model owns its matrix. A
|
||||||
* billboard stays the visual until its model is loaded AND placed; a grounded
|
* billboard stays the visual until its model is loaded AND placed; a grounded
|
||||||
* aircraft's model rides the one-shot ground snap plus the class belly
|
* aircraft's model rides the one-shot ground snap plus the class belly
|
||||||
* offset, and without a resolved ground it stays a billboard. Every fleet
|
* offset, and without a resolved ground it stays a billboard. Every fleet
|
||||||
* model loads once, up front, when the layer starts (`loadFleetModelSet`).
|
* model loads once, up front, when the layer starts (`loadFleetModelSet`).
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @param {object} options.viewer Cesium viewer.
|
* @param {object} options.viewer Cesium viewer.
|
||||||
|
* @param {Cesium.Color} options.color Model tint.
|
||||||
* @param {(url: string) => string} options.resolveAsset
|
* @param {(url: string) => string} options.resolveAsset
|
||||||
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
||||||
* @param {(options: object) => Promise<object>} [options.loadModel]
|
* @param {(options: object) => Promise<object>} [options.loadModel]
|
||||||
@@ -49,6 +46,7 @@ const specOf = (marker) =>
|
|||||||
*/
|
*/
|
||||||
export function createLocalAdsbModels({
|
export function createLocalAdsbModels({
|
||||||
viewer,
|
viewer,
|
||||||
|
color,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
groundSnap = null,
|
groundSnap = null,
|
||||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||||
@@ -105,8 +103,9 @@ export function createLocalAdsbModels({
|
|||||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: Cesium.Color.WHITE,
|
color,
|
||||||
...aircraftModelBlend(false),
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
colorBlendAmount: spec.blendAmount,
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
{ resolveAsset, load: loadModel },
|
{ resolveAsset, load: loadModel },
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ export const MODEL_ALL_KEEP_M = 450000;
|
|||||||
|
|
||||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||||
// every fleet model is nose -X: opposite Cesium heading 0
|
// every fleet model is nose -X: opposite Cesium heading 0
|
||||||
|
// Preserve the layer's amber identity while reducing the models' own colours
|
||||||
|
// to a weak diffuse hint, matching civilian launch presentation.
|
||||||
|
|
||||||
|
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||||
|
|
||||||
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
|
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
MODEL_PROX_KEEP_M,
|
MODEL_PROX_KEEP_M,
|
||||||
IR_RELOAD_BATCH,
|
IR_RELOAD_BATCH,
|
||||||
MODEL_MIN_PX,
|
MODEL_MIN_PX,
|
||||||
|
MODEL_COLOR_BLEND_AMOUNT,
|
||||||
TRACKED_MODEL_MIN_PX,
|
TRACKED_MODEL_MIN_PX,
|
||||||
TRACKED_MODEL_MAX_PX,
|
TRACKED_MODEL_MAX_PX,
|
||||||
FLEET_DR_INTERVAL_MS,
|
FLEET_DR_INTERVAL_MS,
|
||||||
@@ -42,10 +43,7 @@ import {
|
|||||||
COURSE_MAX_DPS,
|
COURSE_MAX_DPS,
|
||||||
STALE_ALPHA,
|
STALE_ALPHA,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import {
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
|
||||||
aircraftModelBlend,
|
|
||||||
} from '../../data/aircraftModelLighting.js';
|
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -83,6 +81,7 @@ export function createRendering({
|
|||||||
const base = aircraftLayerModelSpec(meta);
|
const base = aircraftLayerModelSpec(meta);
|
||||||
spec = Object.freeze({
|
spec = Object.freeze({
|
||||||
...base,
|
...base,
|
||||||
|
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||||
});
|
});
|
||||||
// Not cached before the model set loads: the answer changes once it has.
|
// Not cached before the model set loads: the answer changes once it has.
|
||||||
@@ -153,15 +152,11 @@ export function createRendering({
|
|||||||
const _iconKind = (icao24, klass) =>
|
const _iconKind = (icao24, klass) =>
|
||||||
tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
|
tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
|
||||||
|
|
||||||
/** Model colour: the mesh's own (white), tinted amber while tracked and red
|
/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */
|
||||||
* while broadcasting an emergency. */
|
|
||||||
|
|
||||||
function _modelColor(icao24) {
|
function _modelColor(icao24) {
|
||||||
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
||||||
const info = flightState.records.data.get(icao24);
|
return _fleetBillboardColor(icao24);
|
||||||
return isEmergency(info?.emergency)
|
|
||||||
? EMERGENCY_ICON_COLOR
|
|
||||||
: Cesium.Color.WHITE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
||||||
@@ -448,7 +443,9 @@ export function createRendering({
|
|||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||||
...aircraftModelBlend(flightState._irBoost),
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
|
||||||
|
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
|
||||||
customShader: flightState._irBoost
|
customShader: flightState._irBoost
|
||||||
? flightState._IR_UNLIT_SHADER
|
? flightState._IR_UNLIT_SHADER
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -665,7 +662,9 @@ export function createRendering({
|
|||||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||||
scale: trackedSpec.scale,
|
scale: trackedSpec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
|
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
|
||||||
...aircraftModelBlend(flightState._irBoost),
|
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||||
|
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
|
||||||
|
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
|
||||||
customShader: flightState._irBoost
|
customShader: flightState._irBoost
|
||||||
? flightState._IR_UNLIT_SHADER
|
? flightState._IR_UNLIT_SHADER
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
// 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
+1
-7
@@ -91,13 +91,7 @@ export function createControls({ state, parts, timetable }) {
|
|||||||
return true;
|
return true;
|
||||||
state._categories = next;
|
state._categories = next;
|
||||||
const selected = state._trains.get(state._selectedKey);
|
const selected = state._trains.get(state._selectedKey);
|
||||||
// A held train keeps the selection until its view lets it go
|
if (selected && next[selected.category] === false)
|
||||||
// (cockpitSubject.js).
|
|
||||||
if (
|
|
||||||
selected &&
|
|
||||||
next[selected.category] === false &&
|
|
||||||
selected.key !== state._cockpitSubjectKey
|
|
||||||
)
|
|
||||||
parts.selection.clearSelection();
|
parts.selection.clearSelection();
|
||||||
parts.rendering.applyCategoryVisibility();
|
parts.rendering.applyCategoryVisibility();
|
||||||
state._revision += 1;
|
state._revision += 1;
|
||||||
|
|||||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user