mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d49d3d10cc | ||
|
|
6f830a2e70 | ||
|
|
e53cfef1d1 | ||
|
|
5d733eb07b | ||
|
|
7451c9b372 | ||
|
|
7578fcc9a1 | ||
|
|
1f20b609ee | ||
|
|
0cb8207285 | ||
|
|
c54ead9432 | ||
|
|
d0b614d88e | ||
|
|
9374e0228b | ||
|
|
235fe94a89 | ||
|
|
605bf5b564 | ||
|
|
85415bda97 | ||
|
|
8da632cc2d | ||
|
|
4debb49fe0 | ||
|
|
4568b9ff12 | ||
|
|
129b35e470 | ||
|
|
9261279639 | ||
|
|
81f3602134 | ||
|
|
422a11d9a4 | ||
|
|
9cc2a1811b | ||
|
|
b090937e5f | ||
|
|
59ffa87535 | ||
|
|
b080857c35 | ||
|
|
7ee047961b | ||
|
|
f0a8b1193a | ||
|
|
dd15223109 | ||
|
|
96cefa28b3 | ||
|
|
39c89d563b | ||
|
|
250f0e20c3 | ||
|
|
52507a0755 | ||
|
|
9ae121c7c0 | ||
|
|
6c7fe48f40 | ||
|
|
279aedb2f4 | ||
|
|
f24abfc88f | ||
|
|
58776a6976 | ||
|
|
391e33b0d5 | ||
|
|
deb42b38f7 | ||
|
|
fd06272e37 | ||
|
|
a3611caddb | ||
|
|
4d81d3be3a | ||
|
|
c2c196ab1e | ||
|
|
8cb096fc21 | ||
|
|
3934be9ff9 | ||
|
|
c73c5c4eb6 | ||
|
|
491c50ed18 | ||
|
|
c5cf579174 | ||
|
|
332551ec84 | ||
|
|
6e655b801d | ||
|
|
083da40d88 | ||
|
|
16d163a886 | ||
|
|
c653b3878d | ||
|
|
53cbd55181 | ||
|
|
a91a89ec1c | ||
|
|
c43bf103d6 | ||
|
|
6c55daf9ac | ||
|
|
9b46692c64 | ||
|
|
4bb7b411e9 | ||
|
|
9d01f8df77 | ||
|
|
f0ee1b95ca | ||
|
|
f29b0b2639 | ||
|
|
7f18943ec4 | ||
|
|
0325805eb0 | ||
|
|
73bd952736 | ||
|
|
7df581765d | ||
|
|
974023510f | ||
|
|
92d34bc4cc | ||
|
|
546ac22708 | ||
|
|
432a29ba59 | ||
|
|
a9d390c165 | ||
|
|
df41c0affb | ||
|
|
d42b6fe764 | ||
|
|
fbc1a9a0ec | ||
|
|
22f43aab01 | ||
|
|
a7d722a1f4 | ||
|
|
0584292405 | ||
|
|
09f3380203 | ||
|
|
c24352ff5e | ||
|
|
feaf59776c | ||
|
|
4ae67fcb35 | ||
|
|
fe7590d66a | ||
|
|
557fe7169a | ||
|
|
a17346be6f | ||
|
|
294ccc0ee8 | ||
|
|
a4f321a9ef | ||
|
|
6fbc98952f | ||
|
|
4c33552105 | ||
|
|
1e4060f6ba | ||
|
|
57d87d2bbf | ||
|
|
01a74fceec | ||
|
|
f75179eea0 | ||
|
|
29cf9ff8f6 | ||
|
|
897a74ea27 | ||
|
|
bd1da596b8 | ||
|
|
6976cbc941 | ||
|
|
771ed98e3b | ||
|
|
282ad6c5f6 | ||
|
|
d1f4d1acdc | ||
|
|
ebaa439739 | ||
|
|
265074aa54 | ||
|
|
c763c38193 | ||
|
|
2448662574 | ||
|
|
d9e34f10c6 | ||
|
|
02a8442e6e | ||
|
|
628de8386d | ||
|
|
abfc85e03d | ||
|
|
fbec1488ac |
No files matched your search
+1
-1
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
||||
AISSTREAM_API_KEY=
|
||||
# Optional AISStream subscription filters.
|
||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
|
||||
# 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
|
||||
# default worldwide subscription — a narrow filter above can be legitimately
|
||||
|
||||
+2
-5
@@ -564,11 +564,8 @@ Global Forecast System (GFS). The `/api/wind` server-side proxy selects the
|
||||
latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only
|
||||
the `UGRD`/`VGRD` 10 m GRIB2 messages, optionally adding `TMP` at 2 m or
|
||||
`PRMSL` at mean sea level from the same run and forecast time. It decodes the
|
||||
selected messages with ecCodes (WASM) and resamples them to a compact grid (1° by
|
||||
default) on a worker thread, so a refresh does not stall other API routes, and
|
||||
serves a manifest plus Float32 U/V and optional scalar values. The sampled wind
|
||||
of the current forecast file is kept, so choosing a color field fetches only
|
||||
that field. It is keyless, cached
|
||||
selected messages with ecCodes (WASM), resamples to a compact grid (1° by
|
||||
default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached
|
||||
per cycle for an hour, and is a **forecast, not an observation**: the particles
|
||||
show model flow, not measured wind. The renderer bakes bounded curves through
|
||||
the sampled field and animates their phase on the GPU, with a canvas fallback.
|
||||
|
||||
@@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
|
||||
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
|
||||
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
|
||||
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- When the globe stops drawing after a render error or a lost WebGL context,
|
||||
an alert dialog says so instead of leaving a frozen or black globe behind
|
||||
working controls. Retry restarts drawing (Reload after a lost context), and
|
||||
Reset view reopens the app without the view restored from the address bar.
|
||||
Cesium's "Rendering has stopped" panel no longer appears in the app
|
||||
(German, @gekh, #1579).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Let an application add its own Cockpit subjects through
|
||||
`services.cockpitSubjects`. A provider supplies the subject's position,
|
||||
heading and HUD (in the page's language, via `getLocale` and
|
||||
`onLocaleChange` from `./i18n`); the camera can follow it without the
|
||||
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
|
||||
banner, a veil (off under reduced motion) and a bottom progress line. The
|
||||
Rail and Transit layers expose read and hold APIs so a selected train or
|
||||
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
|
||||
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
|
||||
provider. GEV itself registers none, and entry to the built-in aircraft,
|
||||
satellite and vessel views is unchanged; a provider's subject takes entry
|
||||
when it was selected after them (Denis Groshev, @ZloyS, #1686).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Draw a vessel whose feed sends no length at its ship type's typical size, so
|
||||
a cruise ship or container ship no longer shows as its type's smallest model
|
||||
(samehkhamis, #1715).
|
||||
@@ -1,4 +0,0 @@
|
||||
- The Wind server no longer blocks every other request while it decodes a
|
||||
forecast. Decoding runs on a worker thread (up to 400 ms of blocking down to
|
||||
about 4 ms), and choosing an overlay after wind has loaded fetches only the
|
||||
overlay's field (mazy1998, #1717).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Collapse the CCTV panel when the layer is switched off. It stayed open with
|
||||
an empty frame and a meta line that still described the last camera; the
|
||||
meta line of a disabled layer now reads the catalog count. Contacts, Space
|
||||
Missions and Clear selected data layers leave the panel as it is
|
||||
(asturksever, #1723).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Draw airport ground vehicles and ground stations as what they are instead of
|
||||
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
|
||||
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
|
||||
radio mast, and one with no altitude stands on the ground instead of at the
|
||||
10 km airborne default (samehkhamis, #1729).
|
||||
@@ -0,0 +1,4 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,11 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,10 @@
|
||||
- Draw transit vehicles as their kind, not one of five: trolleybuses, cable
|
||||
cars, funiculars, gondolas and monorails are modes of their own, with their
|
||||
own icon, colour, card word and fleet model. A vehicle record may carry its
|
||||
route's GTFS `route_type` (`routeType`), which settles its mode before the
|
||||
feed's route hints and picks the finer model (a coach for 200, a high-speed
|
||||
set for 101). Prague's trolleybuses and Petřín funicular, and Bucharest,
|
||||
Cluj and Turin's trams, trolleybuses and metro lines, now read as such from
|
||||
the route ids in each operator's routes.txt. Le Havre's funicular, which its
|
||||
routes.txt types as a bus, is a named correction that outranks the route
|
||||
type (samehkhamis, #1734).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Move Visual to the left rail, after Data Layers, Utilities and Authoring, and
|
||||
lead the right column with Context; an open Context or CCTV panel takes the
|
||||
column's full height. A switch in each panel header moves that side's panels
|
||||
over their rail to the screen edge, and the scroll wheel at a screen edge
|
||||
steps through that side's panels (manjunath22466, samehkhamis, #1735).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Fix three time readings on the world timeline. A scrub near now goes live
|
||||
only within two minutes of now, however long the track: with a layer whose
|
||||
source covers years, any time in the last few weeks used to snap to live.
|
||||
Fire cards and fire details count a detection's age from the end of the
|
||||
24 hours shown, and from now only when live, so a window that ended months
|
||||
ago reads "new 3h" rather than "new 105d". Weather and satellite cloud
|
||||
frames stay shown, and the latest reads late, for at least one and a half
|
||||
of the feed's own frame spacings, so a feed whose frames come an hour apart
|
||||
no longer goes blank for half of every hour (samehkhamis, #1736).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Let a host application decide where live contacts are drawn.
|
||||
`createApplicationCatalog({ placement })` takes a placement provider whose
|
||||
`layer(kind)` hands Flights, Military Flights, AIS Vessels, Transit and Rail
|
||||
a `place(id, wallMs)` (and optionally `observe(id, fix)`, `forget(id)`):
|
||||
each layer tells it every live fix it accepts and draws a contact where it
|
||||
places it. Without one, or for a contact it does not place, every layer
|
||||
moves as before (samehkhamis, #1737).
|
||||
@@ -230,3 +230,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
loader with `node --import=<core package>/build/register-vue`.
|
||||
|
||||
### Cockpit subjects
|
||||
|
||||
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
|
||||
selected AIS vessel. An application adds kinds of its own with
|
||||
`services.cockpitSubjects` on `createApplicationControls`: a list of
|
||||
providers, checked once when the controls are built
|
||||
(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
|
||||
that throws when read (a getter), is left out with a warning and the rest are
|
||||
kept. A provider's subject takes entry when it was selected after every
|
||||
built-in subject present (a tracked aircraft or satellite, a selected vessel),
|
||||
whose own fixed order is unchanged, and needs the provider's `layerId` on; the
|
||||
provider then holds its subject, hands the camera a frame and the generic HUD
|
||||
a view model on every update, and releases it on exit. Provider methods are
|
||||
synchronous and are called on the provider, so a class instance's methods keep
|
||||
their `this`; one that throws or returns a promise is treated as having
|
||||
failed. What a method returns may carry getters: one that throws when read
|
||||
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
|
||||
the last; a worded member (labels, entry face, look) that throws keeps its
|
||||
last good words.
|
||||
|
||||
```js
|
||||
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
|
||||
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
|
||||
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: {
|
||||
view: 'Fahrtansicht',
|
||||
exit: 'Fahrtansicht verlassen',
|
||||
exitText: 'VERLASSEN',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
|
||||
const ride = {
|
||||
kind: 'example-ride',
|
||||
layerId: 'rail',
|
||||
entryFace: { icon: 'train', label: 'RIDE' },
|
||||
// A function is read again when the page's language changes.
|
||||
labels: () => words[getLocale()] ?? words.en,
|
||||
candidate() {
|
||||
const train = rail.getSelectedTrain();
|
||||
return (
|
||||
train && {
|
||||
id: train.key,
|
||||
name: train.label,
|
||||
selectedAt: train.selectedAtMs,
|
||||
}
|
||||
);
|
||||
},
|
||||
// Only a train bound to its line can be ridden: read() needs its path.
|
||||
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
|
||||
hold: (id) => rail.setCockpitSubject(id),
|
||||
read(nowMs, ctx) {
|
||||
const train = rail.getCockpitSubject();
|
||||
if (!train?.path) return null; // ends the view
|
||||
const point = pointAtS(train.path.line, train.path.s);
|
||||
return {
|
||||
id: train.key,
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
heightM: train.position.railHeightM, // null stands it on the shared floor
|
||||
name: train.label,
|
||||
headingDeg:
|
||||
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
|
||||
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
|
||||
// The layer already moves the train smoothly: sit on it.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5 },
|
||||
};
|
||||
},
|
||||
hud: (frame) => ({ title: frame.name }),
|
||||
release(id, { restore }) {
|
||||
rail.setCockpitSubject(null);
|
||||
if (restore) rail.focusTrain(id);
|
||||
},
|
||||
};
|
||||
|
||||
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
|
||||
```
|
||||
|
||||
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
|
||||
Core translates only its own strings, so a provider words its labels, entry
|
||||
face and HUD for the page's language (labels may be functions, as above; the
|
||||
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
|
||||
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
|
||||
for a provider placing a train along its line. The frame, HUD view and
|
||||
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
|
||||
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
||||
`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in
|
||||
it changed is the one read before, so `read` may take one on every update; a
|
||||
train's `path.line` is a frozen copy of its line, for `pointAtS` and
|
||||
`projectOnPath`. The `layers/rail/paths` export and the
|
||||
Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
|
||||
`getCockpitSubject` and the snapshots they return, `path.line` among them)
|
||||
are experimental and may change without notice.
|
||||
|
||||
A candidate's `selectedAt` is a token that changes each time the user selects
|
||||
the subject, even the same one again, so entry counts it as selected then; a
|
||||
new `id` under the same token is the same selection (a train's selection
|
||||
moving to another feed's copy of it). The Rail and Transit snapshots give one,
|
||||
`selectedAtMs`: when the user last selected the train or vehicle by a click or
|
||||
`focusTrain`/`focusVehicle`. It is null after a deselection and for one
|
||||
selected only by a hold, since a hold is not the user's selection.
|
||||
|
||||
A frame with `heightM: null` stands on the shared floor, or where it last
|
||||
stood while that floor is cold. If no height has been known since entry, the
|
||||
view waits, showing ACQUIRING SURFACE, for at most five seconds
|
||||
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject.
|
||||
|
||||
`hold(id)` returns `true` once it holds the subject, or `false` to refuse
|
||||
entry; any other answer, `undefined` included, refuses entry too, with one
|
||||
warning. A refused hold, and one that throws or returns a promise, is followed
|
||||
by `release(id, { restore: false })`, again once a returned promise settles,
|
||||
so `release` must be harmless for a subject it does not hold and when called
|
||||
more than once.
|
||||
|
||||
Leaving the view does not track again an aircraft that was tracked before
|
||||
entry, as with the bridge and orbital views: when the user leaves by the exit
|
||||
control, the map goes back to the ridden subject through
|
||||
`release(id, { restore: true })`.
|
||||
+218
-9
@@ -365,6 +365,25 @@ The source factories are exported at `layers/directions/source` and
|
||||
`layers/recent-imagery/source`. Configuration is applied at construction time;
|
||||
this does not introduce live source replacement.
|
||||
|
||||
Application catalog composition also accepts an optional `placement`, a
|
||||
host-supplied placement provider (`src/data/placementProvider.js`): an object
|
||||
whose `layer(kind)` returns, for `aircraft`, `military`, `vessels`, `transit`
|
||||
or `rail`, either null or `{ place(id, wallMs), observe?(id, fix),
|
||||
forget?(id) }`. While live, each of those layers tells it every fix it accepts
|
||||
(time, position, the height it draws the fix at, reported speed, course and
|
||||
ground state) and draws a contact where `place` puts it: position, height
|
||||
(the layer's own when none is given), course and speed. A null answer, a
|
||||
provider that throws, a kind it leaves out, or no provider at all keeps the
|
||||
layer's own motion, unchanged; following the world clock, the layers never
|
||||
ask it. Transit and rail vehicles a provider places are advanced every frame
|
||||
in view while their placed position changes; one it holds still lets the
|
||||
render loop idle and is asked again each second, moving on once its placement
|
||||
does. A placed transit vehicle is drawn at its placed point, and a placed
|
||||
train where it is placed rather than on its bound railway line. Live vessels
|
||||
a provider places move on between refreshes: redrawn in place at most every
|
||||
250 ms, with a frame asked for each second while the provider places any
|
||||
drawn vessel, and none once the layer is off.
|
||||
|
||||
Application catalog composition accepts an optional `streetLevelProviders` list.
|
||||
An explicit list replaces the default Mapillary provider; an empty list removes
|
||||
all imagery providers. Without that option, Mapillary is registered only when
|
||||
@@ -627,6 +646,36 @@ panel that holds its row, and `set_panel_open` reaches it as
|
||||
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
|
||||
it sits collapsed in the Layers sheet under Data Layers.
|
||||
|
||||
## Side rails and the screen edge — November 14, 2026
|
||||
|
||||
Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last
|
||||
panel of the left column, after Data Layers, Utilities and Authoring, and
|
||||
opens from the left rail like them (`PanelLayoutController`, falling back to
|
||||
the right column in a host without a left stack). Context leads the right
|
||||
column, followed by CCTV, Street Level and the other right panels. An open
|
||||
Context or CCTV panel takes the column's full height; the other right panels
|
||||
keep the content-sized layout and its hysteresis band. With nothing open, a
|
||||
rail's icons and their names show as two glass pieces side by side; once a
|
||||
panel opens, the icons stay in place and a highlight slides to the open one.
|
||||
|
||||
Each collapsible panel header has an edge switch (`UiPanelEdge.vue`,
|
||||
`src/ui/panelEdge.js`): it moves that side's panels over their rail to the
|
||||
screen edge, with the rail as a row of icons above the open panel, and back.
|
||||
The choice is per side, kept in `localStorage` (`gev.panelEdge`), and applies
|
||||
on the desktop shell only: phones and Cockpit keep their own layouts and do
|
||||
not show the switch.
|
||||
|
||||
Scrolling the wheel at a screen edge (`src/ui/railEdgeScroll.js`, from the
|
||||
edge to just past the rail, at most 84 px) steps that side's rail to its next
|
||||
or previous panel instead of zooming the globe, wrapping round at either end.
|
||||
One gesture is one step, trackpad coasting included, with at least half a
|
||||
second between steps; a step that comes too soon is held until then and is
|
||||
dropped if the pointer leaves the strip. Panels, controls, dialogs and the
|
||||
world timeline in the strip keep their own wheel. The strip is off on phones
|
||||
and touch screens, while the rails are hidden (clean view, recording), and it
|
||||
follows the rails' sides in a right-to-left language. On a phone, Visual is in
|
||||
the More sheet with the dock's Visual Presets, as before.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
@@ -2208,6 +2257,83 @@ the subject (tracking stopped, Satellites off) or tracking another target
|
||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||
covers entry, camera, zoom, look, HUD and exit.
|
||||
|
||||
### Application subjects
|
||||
|
||||
An application can add its own first-person subjects beside the aircraft,
|
||||
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
|
||||
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
|
||||
the coordinator; a malformed one, or one with a member that throws when read,
|
||||
is left out with a warning and the rest are kept). A provider names
|
||||
its layer, an entry face and labels in the page's language, and supplies four
|
||||
operations: `candidate()` (what entry would ride now), `hold(id)`,
|
||||
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
|
||||
view model), with `release(id, {restore})` on exit. Entry needs the provider's
|
||||
layer on, no Context change in flight and the provider's own
|
||||
`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
|
||||
the reason, while a provider's view is in use.
|
||||
|
||||
The built-in kinds keep their fixed entry order (a tracked aircraft, then the
|
||||
tracked satellite, then the selected vessel), so without providers entry is
|
||||
unchanged. A provider's candidate takes entry only when it was selected after
|
||||
every built-in subject present, by when the Cockpit first saw it or saw it
|
||||
selected again (its `selectedAt` changing): a train picked after an aircraft
|
||||
was tracked takes entry, and an aircraft tracked after it takes it back.
|
||||
Providers' candidates are ordered most recently selected first, then by
|
||||
priority; one first seen together with a built-in subject comes after it. The
|
||||
first in that order decides alone: when its gate is shut there is no entry
|
||||
rather than the next subject's.
|
||||
|
||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||
frame gives none, or where it last stood while that floor is cold; with
|
||||
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
|
||||
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
|
||||
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
|
||||
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
||||
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
||||
layer that already moves it smoothly; a frame with no `speedMps` is always
|
||||
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
|
||||
camera every rendered frame. A frame that is null, malformed or for another
|
||||
subject ends the view (`restoreTracking: false`), as an aircraft tracked
|
||||
meanwhile and the provider's layer turning off do; the exit control asks the
|
||||
provider to restore its subject. The generic HUD shows the provider's kicker,
|
||||
title, meta line, speed with its unit and tag, readout cards, always-visible
|
||||
source rows, banner, route card and Live Signals rows, a veil that darkens the
|
||||
picture under the HUD (with passing light streaks, which stop under reduced
|
||||
motion), arrow-key look-around when the provider asks for it, and a progress
|
||||
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
|
||||
labels never overlap). Core translates none of the provider's text: a host
|
||||
words it for the page's language (`gods-eye-view/i18n`:
|
||||
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
|
||||
functions, read again when they are shown and on a change of language, as the
|
||||
HUD view is.
|
||||
|
||||
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
|
||||
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
||||
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
||||
reported or derived, position source, feed credit and stops, and
|
||||
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
|
||||
even again; null after a deselection and for a train only a hold selected),
|
||||
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
|
||||
feed held, clicks and Escape left to the view; the held copy stays the one
|
||||
drawn of its train while held, another feed's copy hidden, and it keeps
|
||||
moving, still selected, when its category is turned off) and
|
||||
`focusTrain(key)` (false for a train the layer does not draw); its pure path
|
||||
module is exported as `gods-eye-view/layers/rail/paths`. That export
|
||||
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
|
||||
the one read before, and its `path.line` is a frozen copy. Transit
|
||||
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
|
||||
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
|
||||
out of the camera's view, and its sprite, 3D model, card, trail and detection
|
||||
box are hidden; released, it is out of play until a visibility sweep finds it in
|
||||
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
|
||||
it (the drawn marker's height while it is placed, else its segment's two
|
||||
reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the
|
||||
way the drawn train runs: once playback has placed it, a report that runs back
|
||||
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
|
||||
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
|
||||
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
|
||||
cover them.
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
@@ -2229,6 +2355,17 @@ shown without a fade once it is decoded (or after one second if its decode never
|
||||
settles), so a new camera's preview is blank only while its frame loads. Disposal
|
||||
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
|
||||
|
||||
The panel opens when a camera becomes active and collapses when the layer is
|
||||
switched off, from its own switch or anywhere else. The panel chrome applies
|
||||
the collapse, so a panel the desktop accordion only folded has its saved and
|
||||
shared state closed too. Clear selected data layers and Context isolation
|
||||
(Contacts, Space Missions) switch the layer off without the user asking: the
|
||||
panel and its share preference stay as they are, and the layer comes back to an
|
||||
open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
|
||||
while `_clearLayersOutsideContextMode` switches the layer off): the user
|
||||
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
|
||||
meta line reads the catalog count, not the last active camera.
|
||||
|
||||
## UI disposal
|
||||
|
||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||
@@ -2443,6 +2580,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
|
||||
detection is the backstop for when no probe context can be created. Applied
|
||||
during viewer construction, before any tile builds a draw command.
|
||||
|
||||
## Render failure notice
|
||||
|
||||
When the globe stops drawing, the scene chrome says so instead of leaving a
|
||||
black globe behind working controls. `installRenderFailureNotice()`
|
||||
(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
|
||||
`webglcontextlost`, logs the error, and publishes a notice that
|
||||
`RenderFailure.vue` shows as an alert dialog with focus on its first action.
|
||||
The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
|
||||
"Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
|
||||
keeps it by default for other callers.
|
||||
|
||||
- **Retry** restarts the render loop. The notice stays until a frame starts
|
||||
and ends without an error: Cesium raises `postRender` after a failed frame
|
||||
too.
|
||||
- **Reload**, after a lost context, reloads the page; the address keeps the
|
||||
view.
|
||||
- **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
|
||||
path; the query, such as `?lang=`, is kept.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- An error thrown outside Scene's error handling (a frame listener, `onTick`,
|
||||
a resize) stops the loop without a `renderError`. The notice wraps the
|
||||
widget's `render()` and `resize()` and raises it as one, once even with
|
||||
`rethrowRenderErrors`.
|
||||
- A throwing listener leaves its Cesium event mid-raise, with listener changes
|
||||
only queued. The wrapper finishes those raises first (`finishRaise()` over
|
||||
`frameEvents()`), so a layer turned off or on meanwhile behaves.
|
||||
- A render error marks the loop stopped while its next frame is still queued.
|
||||
The wrapper keeps the loop marked running, so a Retry or a shown tab
|
||||
(`src/app/tools.js`) before that frame starts no second loop.
|
||||
- After a lost context, later render errors are only logged, so the notice
|
||||
keeps saying why. The listener neither prevents nor restores the loss.
|
||||
- When the globe draws again, focus goes back where it was, or to the globe's
|
||||
canvas, which the map's keys read as the map; the canvas is focusable only
|
||||
until focus leaves or the globe is pressed. Focus the user moved stays put.
|
||||
- The notice sits above floating panels such as the Québec camera viewer, and
|
||||
its buttons are 44 px tall on touch screens.
|
||||
|
||||
An inline conversation panel never reloads or navigates its host's page: it
|
||||
offers Retry after a render error only, and no notice after a lost context,
|
||||
where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`).
|
||||
Its timer (`keepPanelRendering()`) draws only while the render loop is on and
|
||||
logs nothing itself, so each render error is logged once, by the notice. An
|
||||
error a timer frame throws is caught there: it has already been raised as a
|
||||
render error.
|
||||
|
||||
## Map Source control ownership
|
||||
|
||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||
@@ -3290,7 +3474,8 @@ reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 ho
|
||||
to a set time, naming its end as `at` (epoch ms): the row then reads
|
||||
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
|
||||
`get_active_fires` says "in the 24 hours to" that time and reports it as
|
||||
`window_end`. The dev proxy never sets `at`.
|
||||
`window_end`, and card and detail ages count back from that time instead of
|
||||
now. The dev proxy never sets `at`.
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line.
|
||||
@@ -5733,7 +5918,26 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
|
||||
Ontario — Toronto (TTC surface vehicles; the subway publishes no
|
||||
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
|
||||
Burlington, Barrie and Kingston. Mode silhouettes
|
||||
identify buses, trams, subways, trains, ferries and unclassified vehicles.
|
||||
identify buses, trolleybuses, trams, cable cars, subways, monorails, trains,
|
||||
funiculars, gondolas (aerial lifts), ferries and unclassified vehicles.
|
||||
|
||||
A vehicle's mode comes from, in order: the feed's fleet-number hint
|
||||
(`vehicleMode`, which knows a bus standing in on a streetcar route); the feed's
|
||||
correction for a route its own routes.txt mistypes (`routeCorrections`: Le
|
||||
Havre's funicular `8-FUNI`, which LiA's routes.txt types as a bus); the GTFS
|
||||
`route_type` of its route, when its record carries one as `routeType` (basic
|
||||
0–12 or extended 100–1799, each extended type taken as the basic mode it is a
|
||||
kind of: 200 coach is a bus, 101 high-speed is rail, 405 is a monorail); the
|
||||
feed's route-id hint (`routeMode`); and the feed's default (`transitModeFor`,
|
||||
`src/data/transitFeeds.js`). The GTFS-Realtime decoder carries no route type, so
|
||||
a record from the bundled proxy is classified by the hints alone. Route hints
|
||||
read from each operator's routes.txt cover Prague's trolleybus lines and Petřín
|
||||
funicular, and Bucharest, Cluj-Napoca and Turin's trams, trolleybuses and metro
|
||||
lines (and Turin's Sassi–Superga rack tramway, typed a funicular in its
|
||||
routes.txt). Le Havre's funicular is a correction, not read from routes.txt: its
|
||||
route hint and its correction both say funicular. Vilnius publishes no route ids in its positions, so
|
||||
its trolleybuses read as buses. Retained history classifies its epochs the same
|
||||
way, so a trail joins the live vehicle.
|
||||
|
||||
Retained transit history uses the same outlier quarantine as live observations and
|
||||
preserves the active live bracket. Rejected reports do not publish route or mode
|
||||
@@ -5799,9 +6003,10 @@ External halos are 1/1.5 px in normal, 1.25/1.25 px in CRT and 2/2 px in sensors
|
||||
final unpadded display size. Each mode has an outline with a front (a nose, a
|
||||
bow or a chamfered cab) for a vehicle with a known course and a symmetric
|
||||
outline for one without, which would otherwise sit at rotation 0 and point
|
||||
north; a vehicle switches to the pointed glyph when a course arrives. Six modes, two sizes, three
|
||||
profiles and the two outlines cap the raster cache at 72 variants. Normal mode colours are bus `#5EF08A`, tram
|
||||
`#FFC24A`, subway `#FF4538`, rail `#D9A6FF`, ferry `#5FD6FF`, and unknown
|
||||
north; a vehicle switches to the pointed glyph when a course arrives. Eleven modes, two sizes, three
|
||||
profiles and the two outlines cap the raster cache at 132 variants. Normal mode colours are bus `#5EF08A`,
|
||||
trolleybus `#B6F04A`, tram `#FFC24A`, cable car `#FF8F4A`, subway `#FF4538`, monorail `#FF6FB8`, rail
|
||||
`#D9A6FF`, funicular `#E8C98A`, gondola `#7FA8FF`, ferry `#5FD6FF`, and unknown
|
||||
`#D8DDE5`. Selection keeps the mode colour. Sensors reuse each normal silhouette's exact
|
||||
outer path, filled opaque white without interior panel lines, with an opaque
|
||||
`#05080C` halo. Only the external padding grows; the unpadded silhouette remains
|
||||
@@ -7294,9 +7499,9 @@ pins and share links already supply complete boxes.
|
||||
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
|
||||
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||
can retain a bounded retry delay of up to 30 seconds.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
|
||||
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
||||
- 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.
|
||||
- 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 route's GTFS `route_type` when its record carries one and that type is a kind of the vehicle's mode (`types.json` `transit.route_types`: 200 a coach, 101 a high-speed set, 106 a regional DMU, 710 a double-decker), else its layer mode (`transit.modes`: bus, trolleybus, tram, cable car, subway, monorail, rail, funicular, gondola or ferry; a vehicle of `unknown` mode keeps its icon); a fleet hint that overrules the route (a bus on a tram route) leaves the type unused. A monorail and a gondola are raised by their `transit.elevation` lifts. A train's model follows its rail category and mode (rail, metro or tram). The bundled GTFS-Realtime proxy carries no route type, so its vehicles draw their mode's model; a source that adds `routeType` reaches the finer ones. 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.
|
||||
- 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.
|
||||
- **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.
|
||||
@@ -8226,7 +8431,11 @@ clock drives all enabled observation rows. Earlier/Later use the sorted union of
|
||||
non-suspended products' advertised times; each product selects its nearest frame
|
||||
at or before the requested UTC time, never a future frame or an interpolation.
|
||||
The maximum gap is 30 minutes for radar, regional infrared and lightning, and
|
||||
3 hours for hourly global infrared. A missing eligible frame hides that product
|
||||
3 hours for hourly global infrared, but never less than one and a half of the
|
||||
feed's median frame spacing over its latest frames, so a regional feed whose
|
||||
frames come an hour apart holds each for 90 minutes; the latest frame reads late
|
||||
after the same floor (20 minutes for radar and regional infrared, 45 for
|
||||
lightning, 4 hours for global infrared). A missing eligible frame hides that product
|
||||
and reports the gap; rows show actual displayed UTC time with synced/nearest labels.
|
||||
Manifest rollover preserves the requested history time, even when frames expire.
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
},
|
||||
"./sources/rail-service": "./src/sources/railService.js",
|
||||
"./layers/rail/source": "./src/layers/rail/source.js",
|
||||
"./layers/rail/paths": "./src/data/railPaths.js",
|
||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||
"./server/providers/mapillary": {
|
||||
"node": "./server/providers/mapillary.js"
|
||||
|
||||
@@ -19,6 +19,12 @@ How the layers use them:
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -726,18 +726,18 @@
|
||||
"file": "other/car.glb",
|
||||
"min": [
|
||||
-2.31,
|
||||
-0.906,
|
||||
0.019
|
||||
-1.075,
|
||||
0.018
|
||||
],
|
||||
"max": [
|
||||
2.3,
|
||||
0.906,
|
||||
1.948
|
||||
2.31,
|
||||
1.075,
|
||||
1.84
|
||||
],
|
||||
"length": 4.61,
|
||||
"beam": 1.812,
|
||||
"length": 4.62,
|
||||
"beam": 2.15,
|
||||
"clearance": 0.0,
|
||||
"tris": 206,
|
||||
"tris": 736,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"car_carrier": {
|
||||
@@ -1124,19 +1124,19 @@
|
||||
"category": "other",
|
||||
"file": "other/fire_truck.glb",
|
||||
"min": [
|
||||
-5.51,
|
||||
-1.5,
|
||||
0.027
|
||||
-6.408,
|
||||
-1.507,
|
||||
0.029
|
||||
],
|
||||
"max": [
|
||||
5.5,
|
||||
1.5,
|
||||
4.08
|
||||
5.51,
|
||||
1.507,
|
||||
3.64
|
||||
],
|
||||
"length": 11.01,
|
||||
"beam": 3.0,
|
||||
"length": 11.918,
|
||||
"beam": 3.014,
|
||||
"clearance": 0.0,
|
||||
"tris": 314,
|
||||
"tris": 1016,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"fishing_boat": {
|
||||
@@ -1751,19 +1751,19 @@
|
||||
"category": "other",
|
||||
"file": "other/pushback.glb",
|
||||
"min": [
|
||||
-3.0,
|
||||
-1.45,
|
||||
0.024
|
||||
-3.45,
|
||||
-1.398,
|
||||
0.025
|
||||
],
|
||||
"max": [
|
||||
3.0,
|
||||
1.45,
|
||||
2.1
|
||||
3.05,
|
||||
1.398,
|
||||
2.14
|
||||
],
|
||||
"length": 6.0,
|
||||
"beam": 2.9,
|
||||
"length": 6.5,
|
||||
"beam": 2.795,
|
||||
"clearance": 0.0,
|
||||
"tris": 176,
|
||||
"tris": 490,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"r44": {
|
||||
@@ -1785,6 +1785,25 @@
|
||||
"tris": 1172,
|
||||
"icon": "heli"
|
||||
},
|
||||
"radio_mast": {
|
||||
"category": "other",
|
||||
"file": "other/radio_mast.glb",
|
||||
"min": [
|
||||
-0.7,
|
||||
-0.815,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
1.2,
|
||||
0.815,
|
||||
11.8
|
||||
],
|
||||
"length": 1.9,
|
||||
"beam": 1.63,
|
||||
"clearance": 0.0,
|
||||
"tris": 940,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"regional_dmu": {
|
||||
"category": "transit",
|
||||
"file": "transit/regional_dmu.glb",
|
||||
@@ -2284,18 +2303,18 @@
|
||||
"file": "other/van.glb",
|
||||
"min": [
|
||||
-2.76,
|
||||
-1.0,
|
||||
0.019
|
||||
-1.22,
|
||||
0.018
|
||||
],
|
||||
"max": [
|
||||
2.75,
|
||||
1.0,
|
||||
2.438
|
||||
2.76,
|
||||
1.22,
|
||||
2.56
|
||||
],
|
||||
"length": 5.51,
|
||||
"beam": 2.0,
|
||||
"length": 5.52,
|
||||
"beam": 2.44,
|
||||
"clearance": 0.0,
|
||||
"tris": 206,
|
||||
"tris": 736,
|
||||
"icon": "vehicle"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -983,6 +983,14 @@
|
||||
"SHIP": [
|
||||
"blimp",
|
||||
75.0
|
||||
],
|
||||
"ZVEH": [
|
||||
"car",
|
||||
4.61
|
||||
],
|
||||
"GRND": [
|
||||
"radio_mast",
|
||||
1.9
|
||||
]
|
||||
},
|
||||
"helicopters": {
|
||||
@@ -1143,12 +1151,41 @@
|
||||
5.0
|
||||
]
|
||||
},
|
||||
"aircraft_callsign": {
|
||||
"ZVEH": [
|
||||
{
|
||||
"prefixes": [
|
||||
"FIRE"
|
||||
],
|
||||
"model": "fire_truck",
|
||||
"length": 11.01
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"PUSH",
|
||||
"TUG"
|
||||
],
|
||||
"model": "pushback",
|
||||
"length": 6.0
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"DEICER",
|
||||
"SQUID",
|
||||
"SQB"
|
||||
],
|
||||
"model": "van",
|
||||
"length": 5.51
|
||||
}
|
||||
]
|
||||
},
|
||||
"vessels": [
|
||||
{
|
||||
"types": [
|
||||
70,
|
||||
79
|
||||
],
|
||||
"typical_len": 300,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1188,6 +1225,7 @@
|
||||
80,
|
||||
89
|
||||
],
|
||||
"typical_len": 250,
|
||||
"rules": [
|
||||
{
|
||||
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
||||
@@ -1214,6 +1252,7 @@
|
||||
60,
|
||||
69
|
||||
],
|
||||
"typical_len": 140,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1243,6 +1282,7 @@
|
||||
37,
|
||||
37
|
||||
],
|
||||
"typical_len": 19,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 35,
|
||||
@@ -1273,6 +1313,7 @@
|
||||
30,
|
||||
30
|
||||
],
|
||||
"typical_len": 24,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 15,
|
||||
@@ -1343,6 +1384,7 @@
|
||||
55,
|
||||
55
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 25,
|
||||
@@ -1358,6 +1400,7 @@
|
||||
40,
|
||||
49
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 20,
|
||||
@@ -1373,6 +1416,7 @@
|
||||
33,
|
||||
33
|
||||
],
|
||||
"typical_len": 100,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 50,
|
||||
|
||||
@@ -557,6 +557,13 @@
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/ui/frameRateView.test.mjs",
|
||||
"src/ui/renderFailure.test.mjs",
|
||||
"src/ui/renderFailureView.test.mjs",
|
||||
"src/ui/renderFailureLoopErrors.test.mjs",
|
||||
"src/ui/renderFailureEventRetry.test.mjs",
|
||||
"src/app/renderFailureInlineRetry.test.mjs",
|
||||
"src/ui/renderFailureSecondLoop.test.mjs",
|
||||
"src/ui/renderLoopFixture.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
@@ -1429,8 +1436,28 @@
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitSubjectProviders.test.mjs",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitProgress.test.mjs",
|
||||
"src/ui/cockpitSubjectView.test.mjs",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/cockpitSubject.test.mjs",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/data/fleetModels.test.mjs",
|
||||
"src/data/fleetModelInstances.test.mjs",
|
||||
"src/testSupport/fleetGlb.mjs",
|
||||
"scripts/measure-fleet-trail-anchors.mjs"
|
||||
"src/testSupport/fleetModelFakes.mjs",
|
||||
"scripts/measure-fleet-trail-anchors.mjs",
|
||||
"src/ui/panelEdge.js",
|
||||
"src/ui/railEdgeScroll.js",
|
||||
"src/ui/railEdgeScroll.test.mjs",
|
||||
"src/ui/kit/UiPanelEdge.vue",
|
||||
"src/ui/styles/rail-edge.css",
|
||||
"src/ui/styles/rail-split.css",
|
||||
"src/ui/styles/left-tools.css",
|
||||
"src/ui/styles/panel-edge.css",
|
||||
"src/data/placementProvider.test.mjs",
|
||||
"src/layers/vessels/placement.test.mjs"
|
||||
]
|
||||
@@ -22,6 +22,9 @@
|
||||
"src/ui/vueRuntime.js",
|
||||
"src/ui/views/index.js",
|
||||
"src/ui/views/CockpitView.vue",
|
||||
"src/ui/views/panels/CockpitProgress.vue",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/ui/views/CommandDockView.vue",
|
||||
"src/ui/views/ContextView.vue",
|
||||
"src/ui/views/panels/RadioPanel.vue",
|
||||
@@ -151,6 +154,8 @@
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/rail/RailPanel.vue",
|
||||
"src/ui/kit/UiPanel.vue",
|
||||
"src/ui/kit/UiPanelEdge.vue",
|
||||
"src/ui/panelEdge.js",
|
||||
"src/ui/kit/UiPanelToggle.vue",
|
||||
"src/ui/stores/panelChrome.js",
|
||||
"src/ui/rail/PanelCollapseButton.vue",
|
||||
@@ -163,6 +168,7 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/views/panels/GlobeActions.vue",
|
||||
"src/ui/views/panels/RenderFailure.vue",
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
@@ -247,6 +253,7 @@
|
||||
"src/data/adsbLolFallback.js",
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"server/providers/acartia.js",
|
||||
@@ -1009,6 +1016,8 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/railEdgeScroll.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -1174,6 +1184,8 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1356,6 +1368,7 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
@@ -1505,6 +1518,7 @@
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/fleetTrailAnchors.js",
|
||||
"src/data/fleetModelInstances.js",
|
||||
"src/data/headingDegrees.js",
|
||||
@@ -1574,7 +1588,8 @@
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
"src/data/planespottersPhoto.js",
|
||||
"src/layers/flights/placed.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1584,6 +1599,7 @@
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/layers/flights/policy.js",
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/fleetTrailAnchors.js",
|
||||
"src/data/fleetModelInstances.js",
|
||||
"src/data/headingDegrees.js",
|
||||
@@ -1646,7 +1662,8 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
"src/time/julian.js",
|
||||
"src/layers/flights/placed.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3075,6 +3092,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/railEdgeScroll.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/exclusiveSurfaces.js",
|
||||
"src/ui/localePicker.js",
|
||||
@@ -3115,6 +3133,8 @@
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -3132,6 +3152,7 @@
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/renderFailure.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
@@ -3302,6 +3323,7 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -3328,6 +3350,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -3803,7 +3826,9 @@
|
||||
"src/data/planespottersPhoto.js",
|
||||
"src/ui/stores/dataLibrary.js",
|
||||
"src/data/openVesselPhoto.js",
|
||||
"src/layers/vessels/photos.js"
|
||||
"src/layers/vessels/photos.js",
|
||||
"src/layers/flights/placed.js",
|
||||
"src/data/placementProvider.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4290,6 +4315,7 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -4315,6 +4341,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -4519,7 +4546,9 @@
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js",
|
||||
"src/layers/vessels/photos.js",
|
||||
"src/data/openVesselPhoto.js"
|
||||
"src/data/openVesselPhoto.js",
|
||||
"src/layers/flights/placed.js",
|
||||
"src/data/placementProvider.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4799,7 +4828,10 @@
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js"
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/fleetTrailAnchors.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -5189,6 +5221,7 @@
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/fleetModelInstances.js",
|
||||
@@ -5226,6 +5259,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/movement.js",
|
||||
@@ -5280,6 +5314,7 @@
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/fleetModelInstances.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftModelLighting.js",
|
||||
@@ -5296,6 +5331,7 @@
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -5918,5 +5954,10 @@
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"rail-paths": {
|
||||
"exports": ["./layers/rail/paths"],
|
||||
"modules": ["src/data/railPaths.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,45 @@ try {
|
||||
'#cctv-frame',
|
||||
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
|
||||
);
|
||||
// The address bar follows the share state after a short delay: wait for the
|
||||
// CCTV panel token (1 collapsed, 0 open) the step expects.
|
||||
const panelState = async (expectedToken) => {
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
(token) =>
|
||||
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
|
||||
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
expectedToken,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
return page.evaluate(
|
||||
(shared) => ({
|
||||
collapsed: document
|
||||
.getElementById('cctv-panel')
|
||||
.classList.contains('collapsed'),
|
||||
shared,
|
||||
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
|
||||
}),
|
||||
settled ? expectedToken : null,
|
||||
);
|
||||
};
|
||||
const closedOnDisable = await panelState('1');
|
||||
check(
|
||||
'disable collapses the panel and its share preference',
|
||||
closedOnDisable.collapsed && closedOnDisable.shared === '1',
|
||||
JSON.stringify(closedOnDisable),
|
||||
);
|
||||
// The switch sits inside the collapsed panel: reopen it from the right rail.
|
||||
await page.click('[data-rail-item="cctv-panel"]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById('cctv-panel').classList.contains('collapsed'),
|
||||
);
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -351,6 +390,42 @@ try {
|
||||
'disable clears the preview and re-enable reacquires the camera frame',
|
||||
disabledCleanly,
|
||||
);
|
||||
// Contacts switches CCTV off to isolate itself and restores it on the way
|
||||
// out: the open panel and its share preference survive the round trip.
|
||||
const cameraBeforeContacts = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId,
|
||||
);
|
||||
const entered = await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode('flights'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const duringContacts = await panelState('0');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode(null),
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const afterContacts = await panelState('0');
|
||||
check(
|
||||
'Contacts isolation keeps the open CCTV panel and restores its camera',
|
||||
entered?.ok === true &&
|
||||
!duringContacts.collapsed &&
|
||||
duringContacts.shared === '0' &&
|
||||
!afterContacts.collapsed &&
|
||||
afterContacts.shared === '0' &&
|
||||
(await page.evaluate(
|
||||
(cameraId) =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId === cameraId,
|
||||
cameraBeforeContacts,
|
||||
)),
|
||||
JSON.stringify({ entered, duringContacts, afterContacts }),
|
||||
);
|
||||
await page.$eval('#cctv-frame', (image) =>
|
||||
image.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
|
||||
@@ -202,3 +202,25 @@ test('a feed fleet-number mode outranks its route mode in the epoch context', (t
|
||||
assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram');
|
||||
assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram');
|
||||
});
|
||||
test('the epoch mode is the live mode: a route type, else hints, else the default', (t) => {
|
||||
const h = history(t);
|
||||
const pid = {
|
||||
id: 'pid',
|
||||
historyRetention: true,
|
||||
defaultMode: 'bus',
|
||||
routeMode: (route) => (route === 'L22' ? 'tram' : 'unknown'),
|
||||
};
|
||||
h.ingest(
|
||||
pid,
|
||||
[
|
||||
row(100000, 'a', { routeId: 'L58', routeType: 11 }),
|
||||
row(100000, 'b', { routeId: 'L22' }),
|
||||
row(100000, 'c', { routeId: 'L9999' }),
|
||||
],
|
||||
100000,
|
||||
);
|
||||
assert.equal(h.get('pid', 'a', 100000).epochs[0].mode, 'trolleybus');
|
||||
assert.equal(h.get('pid', 'b', 100000).epochs[0].mode, 'tram');
|
||||
// A hint that knows nothing leaves the default, as the live layer does.
|
||||
assert.equal(h.get('pid', 'c', 100000).epochs[0].mode, 'bus');
|
||||
});
|
||||
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
||||
'ExtendedClassBPositionReport',
|
||||
'ShipStaticData',
|
||||
'StaticDataReport',
|
||||
// Buoys, beacons and other aids to navigation (AIS message 21).
|
||||
'AidsToNavigationReport',
|
||||
];
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||
import {
|
||||
aidToNavigationTypeName,
|
||||
isAidToNavigation,
|
||||
} from '../../../src/data/vesselTypes.js';
|
||||
export const AISSTREAM_CACHE_MAX = 50000;
|
||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||
|
||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||
const aidReport = messageType === 'AidsToNavigationReport';
|
||||
_aisStreamVessels.set(mmsi, {
|
||||
lat,
|
||||
lon,
|
||||
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
||||
name:
|
||||
(aidReport && aidNameFromAis(message)) ||
|
||||
vesselNameFromAis(metadata, message, staticData) ||
|
||||
`MMSI ${mmsi}`,
|
||||
mmsi,
|
||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||
type: vesselTypeFromAis(message, staticData),
|
||||
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
||||
// 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),
|
||||
length: staticData.length ?? null,
|
||||
beam: staticData.beam ?? null,
|
||||
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||
// trail spacing and dead reckoning depend on true fix epochs.
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -276,6 +291,15 @@ 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 = {}) {
|
||||
return stringValue(
|
||||
message.Type ??
|
||||
@@ -285,14 +309,25 @@ 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) {
|
||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||
const rows = [];
|
||||
const vessels = [];
|
||||
const aids = [];
|
||||
for (const row of _aisStreamVessels.values()) {
|
||||
if (row._updatedAt >= cutoff) rows.push(row);
|
||||
if (row._updatedAt >= cutoff)
|
||||
(isAidToNavigation(row) ? aids : vessels).push(row);
|
||||
}
|
||||
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
||||
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
||||
vessels.sort(newestFirst);
|
||||
if (vessels.length < maxRows) aids.sort(newestFirst);
|
||||
return vessels
|
||||
.concat(aids)
|
||||
.slice(0, maxRows)
|
||||
.map(({ _updatedAt, ...row }) => row);
|
||||
}
|
||||
|
||||
function pruneAisStreamCache() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchGfsWind } from './wind/gfs.js';
|
||||
import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribInWorker } from './wind/decoder.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
|
||||
|
||||
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
|
||||
@@ -11,7 +11,7 @@ const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
timeoutMs = 40_000,
|
||||
@@ -22,8 +22,6 @@ export function windProxy({
|
||||
const loadings = new Map();
|
||||
const grids = new Map();
|
||||
const attempts = new Map();
|
||||
// Sampled U/V of the last forecast object per model (see loadWeatherSnapshot).
|
||||
const windCache = new Map();
|
||||
const unavailable = (model, overlay) => ({
|
||||
manifest: {
|
||||
model,
|
||||
@@ -56,7 +54,6 @@ export function windProxy({
|
||||
targetDx,
|
||||
overlay,
|
||||
signal: controller.signal,
|
||||
windCache,
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
const { grid, cycle } = value;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// Worker-thread entry for the wind provider, started by ./decoder.js. Node
|
||||
// loads this file from disk, so it may only import files that ship beside it.
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { serveWindDecodes } from './decode.js';
|
||||
|
||||
serveWindDecodes(parentPort);
|
||||
@@ -1,5 +1,3 @@
|
||||
import { sampleWindField } from './grid.js';
|
||||
|
||||
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
|
||||
let eccodesPromise = null;
|
||||
function loadEccodes() {
|
||||
@@ -51,18 +49,3 @@ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
||||
eccodes.writeFile(file, new Uint8Array(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker side of ./decoder.js: decode each posted message, validate and sample
|
||||
* it to the display grid, and transfer back only the samples.
|
||||
*/
|
||||
export function serveWindDecodes(port, decodeImpl = decodeWindGribMessage) {
|
||||
port.on('message', async ({ id, bytes, targetDx }) => {
|
||||
try {
|
||||
const field = sampleWindField(await decodeImpl(bytes), targetDx);
|
||||
port.postMessage({ id, field }, [field.samples.buffer]);
|
||||
} catch (error) {
|
||||
port.postMessage({ id, error: String(error?.message ?? error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
const WORKER_URL = new URL('./decode-worker.js', import.meta.url);
|
||||
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Decode GRIB messages on one lazily started worker thread.
|
||||
*
|
||||
* ecCodes decodes a 0.25° message synchronously (~1.04 M doubles), and
|
||||
* validating and resampling those values is synchronous too, so on the server
|
||||
* thread each refresh stalled every other API route. The worker does all three
|
||||
* and transfers back only the display-grid samples.
|
||||
*
|
||||
* The worker is referenced only while a job is pending, so it never keeps the
|
||||
* process alive. A crash, an exit or a job that outlives `jobTimeoutMs`
|
||||
* rejects every pending job and retires the worker; the next decode starts a
|
||||
* new one.
|
||||
*
|
||||
* @param {{workerUrl?: URL, jobTimeoutMs?: number}} [options]
|
||||
* @returns {{decode: Function, close: Function}}
|
||||
*/
|
||||
export function createWindDecoder({
|
||||
workerUrl = WORKER_URL,
|
||||
jobTimeoutMs = 30_000,
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let nextId = 1;
|
||||
const retire = (state, error) => {
|
||||
if (active === state) active = null;
|
||||
const jobs = [...state.pending.values()];
|
||||
state.pending.clear();
|
||||
for (const job of jobs) job.reject(error);
|
||||
void state.worker.terminate();
|
||||
};
|
||||
const start = () => {
|
||||
const state = {
|
||||
worker: new Worker(workerUrl),
|
||||
pending: new Map(),
|
||||
};
|
||||
state.worker.unref();
|
||||
state.worker.on('message', ({ id, field, error }) => {
|
||||
const job = state.pending.get(id);
|
||||
if (!job) return; // Already rejected by an abort.
|
||||
if (error === undefined) job.resolve(field);
|
||||
else job.reject(new Error(error));
|
||||
});
|
||||
state.worker.on('error', (cause) =>
|
||||
retire(state, new Error('Wind decode worker failed', { cause })),
|
||||
);
|
||||
state.worker.on('exit', () =>
|
||||
retire(state, new Error('Wind decode worker exited')),
|
||||
);
|
||||
return state;
|
||||
};
|
||||
/**
|
||||
* Decode one message and sample it to `targetDx` (see `sampleWindField`).
|
||||
* An aborted `signal` rejects at once; the worker finishes the bounded job
|
||||
* and its result is dropped.
|
||||
*/
|
||||
const decode = (buffer, { targetDx = 1, signal } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (buffer.byteLength > MAX_MESSAGE_BYTES)
|
||||
throw new Error('Wind GRIB exceeds byte budget');
|
||||
signal?.throwIfAborted();
|
||||
// A private copy: transferring the caller's (possibly pooled) Buffer
|
||||
// would detach memory it still owns.
|
||||
const bytes = new Uint8Array(buffer.byteLength);
|
||||
bytes.set(buffer);
|
||||
const state = (active ??= start());
|
||||
const id = nextId++;
|
||||
const settle = (finish) => (value) => {
|
||||
if (state.pending.get(id) === job) state.pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (state.pending.size === 0) state.worker.unref();
|
||||
finish(value);
|
||||
};
|
||||
const job = { resolve: settle(resolve), reject: settle(reject) };
|
||||
const abort = () => job.reject(signal.reason);
|
||||
const timer = setTimeout(
|
||||
() => retire(state, new Error('Wind decode timed out')),
|
||||
jobTimeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
state.pending.set(id, job);
|
||||
state.worker.ref();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
state.worker.postMessage({ id, bytes, targetDx }, [bytes.buffer]);
|
||||
});
|
||||
return {
|
||||
decode,
|
||||
/** Stop the worker; pending jobs reject and a later decode restarts it. */
|
||||
close() {
|
||||
if (active) retire(active, new Error('Wind decode worker closed'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let shared = null;
|
||||
|
||||
/**
|
||||
* Default decoder for the wind provider: one worker for the whole process,
|
||||
* started by the first wind request, however many `windProxy` instances exist.
|
||||
*/
|
||||
export function decodeWindGribInWorker(buffer, options) {
|
||||
shared ??= createWindDecoder();
|
||||
return shared.decode(buffer, options);
|
||||
}
|
||||
@@ -125,7 +125,6 @@ export async function loadOptionalScalar({
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
targetDx = 1,
|
||||
timeoutMs = 12_000,
|
||||
}) {
|
||||
if (!range) return undefined;
|
||||
@@ -141,13 +140,7 @@ export async function loadOptionalScalar({
|
||||
fetchImpl,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Sampling here keeps an unusable companion on the same wind-only path as
|
||||
// an undecodable one. The decoder gets the signal so a timeout or a
|
||||
// cancelled refresh stops waiting for a decode that runs off this thread.
|
||||
const value = sampleWindField(
|
||||
await decodeImpl(bytes, { targetDx, signal: controller.signal }),
|
||||
targetDx,
|
||||
);
|
||||
const value = await decodeImpl(bytes);
|
||||
controller.signal.throwIfAborted();
|
||||
return value;
|
||||
} catch {
|
||||
@@ -165,80 +158,17 @@ import {
|
||||
selectLatestGfsCycle,
|
||||
nearestGfsStep,
|
||||
} from './catalog.js';
|
||||
import { decodeWindGribInWorker } from './decoder.js';
|
||||
import {
|
||||
resampleWeatherSnapshot,
|
||||
sampleWindField,
|
||||
weatherScalarMetadata,
|
||||
} from './grid.js';
|
||||
|
||||
/**
|
||||
* Load U, V and the optional companion from one forecast object and resample
|
||||
* them to the display grid.
|
||||
*
|
||||
* `windCache` (a Map owned by one `windProxy`) retains the sampled U/V of the
|
||||
* last object loaded per model. A later request for the same object and byte
|
||||
* ranges, such as an overlay change or a refresh within the same forecast
|
||||
* step, then fetches and decodes only the companion. It holds two Float64
|
||||
* display grids per model (2 x 65,160 x 8 B = 1.04 MB at 1 degree) and is
|
||||
* written only after the pair has passed every wind check.
|
||||
*/
|
||||
export async function loadWeatherSnapshot({
|
||||
model,
|
||||
url,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
}) {
|
||||
const key = [
|
||||
url,
|
||||
`${ranges.u.start}-${ranges.u.end}`,
|
||||
`${ranges.v.start}-${ranges.v.end}`,
|
||||
targetDx,
|
||||
].join('|');
|
||||
const kept = windCache?.get(model);
|
||||
const wind = kept?.key === key ? kept : null;
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
sampleWindField(
|
||||
await decodeImpl(await fetchRange({ url, ...range, fetchImpl, signal }), {
|
||||
targetDx,
|
||||
signal,
|
||||
}),
|
||||
targetDx,
|
||||
);
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
wind ? wind.u : load(ranges.u),
|
||||
wind ? wind.v : load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
targetDx,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
if (!wind) windCache?.set(model, { key, u, v });
|
||||
return fields;
|
||||
}
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
|
||||
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
|
||||
export async function fetchGfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
windCache,
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
@@ -264,18 +194,22 @@ export async function fetchGfsWind({
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
const fields = await loadWeatherSnapshot({
|
||||
model: 'gfs',
|
||||
url: base,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
});
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(await fetchRange({ url: base, ...range, fetchImpl, signal }));
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: base,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
|
||||
+37
-129
@@ -48,132 +48,37 @@ export function resampleWindGrid({
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
}
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
const sampled = (values) => ({
|
||||
...geometry,
|
||||
dx,
|
||||
dy,
|
||||
samples: sampleValues(values, geometry, dx, dy),
|
||||
});
|
||||
return composeWindGrid({
|
||||
u: sampled(u),
|
||||
v: sampled(v),
|
||||
...(scalar === undefined ? {} : { scalar: sampled(scalar) }),
|
||||
scalarScale,
|
||||
scalarOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/** Display-grid shape for a target spacing, under the point budget. */
|
||||
function displayShape(dx, dy) {
|
||||
const nx = Math.round(360 / dx);
|
||||
const ny = Math.round(180 / dy) + 1;
|
||||
if (nx * ny > 1_000_000)
|
||||
throw new Error('Resampled wind grid exceeds point budget');
|
||||
return { nx, ny };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilinearly sample one validated source field onto the display grid. The
|
||||
* result stays in doubles so a caller can scale it before narrowing to
|
||||
* Float32, exactly as if it had sampled, scaled and stored in one step.
|
||||
*/
|
||||
function sampleValues(source, { ni, nj, lo1, la1, di, dj }, dx, dy) {
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const samples = new Float64Array(nx * ny);
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
const lat = 90 - y * dy;
|
||||
const sy = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const y0 = Math.floor(sy);
|
||||
const outU = new Float32Array(nx * ny);
|
||||
const outV = new Float32Array(nx * ny);
|
||||
const outScalar = scalar === undefined ? null : new Float32Array(nx * ny);
|
||||
const sample = (source, lon, lat) => {
|
||||
const x = ((lon - lo1) / di + ni) % ni;
|
||||
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const x0 = Math.floor(x);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const y0 = Math.floor(y);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fy = sy - y0;
|
||||
for (let x = 0; x < nx; x += 1) {
|
||||
const lon = x * dx;
|
||||
const sx = ((lon - lo1) / di + ni) % ni;
|
||||
const x0 = Math.floor(sx);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const fx = sx - x0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom =
|
||||
source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
samples[y * nx + x] = top * (1 - fy) + bottom * fy;
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one decoded GRIB field and reduce it to display-grid samples.
|
||||
*
|
||||
* This is the expensive per-message step after the decode (a 0.25° message is
|
||||
* ~1.04 M doubles to check), so the decode worker runs it and transfers only
|
||||
* the result. A field that already carries samples for `targetDx` passes
|
||||
* through, which lets the same call normalise worker output, injected decoders
|
||||
* and the retained wind of the current cycle.
|
||||
*
|
||||
* @param {object} field - `decodeWindGribMessage` output, or a sampled field.
|
||||
* @param {number} [targetDx] - Display-grid spacing in degrees.
|
||||
* @returns {{ni: number, nj: number, lo1: number, la1: number, di: number,
|
||||
* dj: number, units: (string|undefined), dx: number, dy: number,
|
||||
* samples: Float64Array}}
|
||||
*/
|
||||
export function sampleWindField(field, targetDx = 1) {
|
||||
if (!field) throw new Error('Mismatched weather grid geometry');
|
||||
if (
|
||||
field.samples instanceof Float64Array &&
|
||||
field.dx === targetDx &&
|
||||
field.dy === targetDx
|
||||
)
|
||||
return field;
|
||||
const { ni, nj, lo1, la1, di, dj, values, units } = field;
|
||||
if (
|
||||
!Number.isInteger(ni) ||
|
||||
!Number.isInteger(nj) ||
|
||||
ni < 1 ||
|
||||
nj < 1 ||
|
||||
ni * nj > 2_000_000 ||
|
||||
values?.length !== ni * nj ||
|
||||
![lo1, la1, di, dj, targetDx].every(Number.isFinite) ||
|
||||
di <= 0 ||
|
||||
dj <= 0 ||
|
||||
targetDx < 0.25 ||
|
||||
targetDx > 180
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
return {
|
||||
...geometry,
|
||||
units,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
samples: sampleValues(values, geometry, targetDx, targetDx),
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
return top * (1 - fy) + bottom * fy;
|
||||
};
|
||||
}
|
||||
|
||||
/** Narrow sampled components that share one display grid to Float32. */
|
||||
function composeWindGrid({ u, v, scalar, scalarScale = 1, scalarOffset = 0 }) {
|
||||
const { dx, dy } = u;
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const components = [u, v, ...(scalar ? [scalar] : [])];
|
||||
if (
|
||||
components.some(
|
||||
(field) =>
|
||||
field.dx !== dx ||
|
||||
field.dy !== dy ||
|
||||
!(field.samples instanceof Float64Array) ||
|
||||
field.samples.length !== nx * ny,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
const outU = new Float32Array(u.samples);
|
||||
const outV = new Float32Array(v.samples);
|
||||
let outScalar = null;
|
||||
if (scalar) {
|
||||
outScalar = new Float32Array(nx * ny);
|
||||
for (let index = 0; index < outScalar.length; index += 1)
|
||||
outScalar[index] = scalar.samples[index] * scalarScale + scalarOffset;
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
for (let x = 0; x < nx; x += 1) {
|
||||
const index = y * nx + x;
|
||||
const lon = x * dx;
|
||||
const lat = 90 - y * dy;
|
||||
outU[index] = sample(u, lon, lat);
|
||||
outV[index] = sample(v, lon, lat);
|
||||
if (outScalar)
|
||||
outScalar[index] =
|
||||
sample(scalar, lon, lat) * scalarScale + scalarOffset;
|
||||
}
|
||||
}
|
||||
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
|
||||
if (!values.every(Number.isFinite))
|
||||
@@ -225,17 +130,20 @@ export function resampleWeatherFields({
|
||||
throw new Error('Mismatched weather grid geometry');
|
||||
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
|
||||
throw new Error('Unexpected weather scalar units');
|
||||
// Raw fields are validated and sampled here; worker output and the retained
|
||||
// wind of this cycle already are, so only the cheap narrowing is left.
|
||||
const [sampledU, sampledV, sampledScalar] = fields.map((field) =>
|
||||
sampleWindField(field, targetDx),
|
||||
);
|
||||
const grid = composeWindGrid({
|
||||
u: sampledU,
|
||||
v: sampledV,
|
||||
const grid = resampleWindGrid({
|
||||
u: u.values,
|
||||
v: v.values,
|
||||
ni: u.ni,
|
||||
nj: u.nj,
|
||||
lo1: u.lo1,
|
||||
la1: u.la1,
|
||||
di: u.di,
|
||||
dj: u.dj,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
...(metadata
|
||||
? {
|
||||
scalar: sampledScalar,
|
||||
scalar: scalar.values,
|
||||
scalarScale: overlay === 'pressure' ? 0.01 : 1,
|
||||
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchText, loadWeatherSnapshot } from './gfs.js';
|
||||
import { decodeWindGribInWorker } from './decoder.js';
|
||||
import { weatherScalarMetadata } from './grid.js';
|
||||
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
|
||||
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
|
||||
const IFS_BASE = 'https://data.ecmwf.int/forecasts';
|
||||
@@ -110,10 +110,9 @@ export async function fetchIfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
windCache,
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
@@ -138,18 +137,24 @@ export async function fetchIfsWind({
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
const fields = await loadWeatherSnapshot({
|
||||
model: 'ifs',
|
||||
url: urls.grib,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
});
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(
|
||||
await fetchRange({ url: urls.grib, ...range, fetchImpl, signal }),
|
||||
);
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: urls.grib,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
|
||||
@@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
}
|
||||
});
|
||||
|
||||
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.27);
|
||||
|
||||
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
|
||||
// draws again and the control that had focus is gone.
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
|
||||
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { layerPlacement } from '../data/placementProvider.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createApplicationFlights } from './layers/flights.js';
|
||||
@@ -63,6 +64,9 @@ export const APPLICATION_LAYER_METADATA = Object.freeze([
|
||||
/** Construct the current catalog without choosing any source provider.
|
||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
||||
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||
* `placement`, a host-supplied placement provider (../data/placementProvider.js),
|
||||
* places live aircraft, vessels, transit and trains; without one each layer
|
||||
* uses its own motion.
|
||||
*/
|
||||
export function createApplicationCatalog({
|
||||
surface,
|
||||
@@ -74,6 +78,7 @@ export function createApplicationCatalog({
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
placement = null,
|
||||
}) {
|
||||
if (!signal?.addEventListener)
|
||||
throw new TypeError('An application lifetime signal is required');
|
||||
@@ -97,17 +102,20 @@ export function createApplicationCatalog({
|
||||
const flights = createApplicationFlights({
|
||||
surface,
|
||||
source: sources.flights,
|
||||
placement: layerPlacement(placement, 'aircraft'),
|
||||
militaryRegistry,
|
||||
resolveAsset,
|
||||
});
|
||||
const military = createApplicationMilitary({
|
||||
surface,
|
||||
source: sources.military,
|
||||
placement: layerPlacement(placement, 'military'),
|
||||
militaryRegistry,
|
||||
resolveAsset,
|
||||
});
|
||||
const vessels = createApplicationVessels({
|
||||
source: sources.vessels,
|
||||
placement: layerPlacement(placement, 'vessels'),
|
||||
options: vesselOptions,
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
@@ -172,6 +180,7 @@ export function createApplicationCatalog({
|
||||
createApplicationTransit({
|
||||
surface,
|
||||
source: sources.transit,
|
||||
placement: layerPlacement(placement, 'transit'),
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
@@ -210,6 +219,7 @@ export function createApplicationCatalog({
|
||||
createApplicationRail({
|
||||
surface,
|
||||
source: sources.rail,
|
||||
placement: layerPlacement(placement, 'rail'),
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
|
||||
Vendored
+2
@@ -11,6 +11,8 @@ import { t } from '../i18n/index.js';
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that offers its own language choice (`switchLocale` from
|
||||
* the `./i18n` entry).
|
||||
* `services.cockpitSubjects` adds the application's own first-person subjects
|
||||
* to the Cockpit (src/cockpitSubjectProviders.js).
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
|
||||
+11
-28
@@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const PANEL_FRAME_MS = 33;
|
||||
const MISSED_FRAMES_MS = 250;
|
||||
|
||||
/** A render error's details, including the plain objects workers report. */
|
||||
function describeError(error) {
|
||||
if (error instanceof Error)
|
||||
return `${error.name}: ${error.message}\n${error.stack}`;
|
||||
try {
|
||||
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the globe drawing in an inline panel. Some hosts report a panel on
|
||||
* screen as hidden, which stops the browser's animation frames and with them
|
||||
@@ -122,33 +111,27 @@ export function keepPanelRendering(
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
// Cesium stops drawing after a render error, and so does the timer.
|
||||
// Report the error in full: workers report plain objects, which Cesium
|
||||
// prints as [object Object].
|
||||
let failed = false;
|
||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||
(_scene, error) => {
|
||||
failed = true;
|
||||
console.error(
|
||||
"[God's Eye View panel] render error:",
|
||||
describeError(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
// Cesium stops its render loop after a render error, and the timer stops
|
||||
// with it; Retry restarts both. The render failure notice logs the error
|
||||
// (src/ui/renderFailure.js).
|
||||
const timer = windowRef.setInterval(() => {
|
||||
if (
|
||||
failed ||
|
||||
viewer.useDefaultRenderLoop === false ||
|
||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||
viewer.isDestroyed?.()
|
||||
)
|
||||
return;
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
try {
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
} catch {
|
||||
// Raised as a render error by now (src/ui/renderFailure.js), which
|
||||
// stops this timer; thrown on, it would only reach the host page.
|
||||
}
|
||||
}, PANEL_FRAME_MS);
|
||||
return () => {
|
||||
windowRef.clearInterval(timer);
|
||||
windowRef.cancelAnimationFrame(frameRequest);
|
||||
removeErrorListener?.();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+14
-27
@@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||||
assert.equal(frame, null);
|
||||
});
|
||||
|
||||
test('a panel reports a render error in full and stops drawing', () => {
|
||||
let listener = null;
|
||||
test('a panel stops drawing while its render loop is stopped', () => {
|
||||
let tick = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
@@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => {
|
||||
clearInterval() {},
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = {
|
||||
resize() {},
|
||||
render: () => drawn.push('render'),
|
||||
scene: {
|
||||
renderError: {
|
||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const logged = [];
|
||||
const original = console.error;
|
||||
console.error = (...args) => logged.push(args.join(' '));
|
||||
try {
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
stop();
|
||||
assert.equal(listener, null);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
const viewer = { resize() {}, render: () => drawn.push('render') };
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
// Cesium stops its render loop after a render error.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
// Retry restarts it, and the timer draws again.
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
tick();
|
||||
assert.deepEqual(drawn, ['render']);
|
||||
stop();
|
||||
});
|
||||
|
||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||
|
||||
@@ -16,9 +16,11 @@ import { fleetModelServices } from './fleetModels.js';
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
|
||||
* preference, under which nearby vessels are drawn as fleet models.
|
||||
* `placement`: a host-supplied placement for its live vessels, or null.
|
||||
*/
|
||||
export function createApplicationVessels({
|
||||
source,
|
||||
placement = null,
|
||||
options = {},
|
||||
displayParams = null,
|
||||
resolveAsset,
|
||||
@@ -38,6 +40,7 @@ export function createApplicationVessels({
|
||||
worldFocus,
|
||||
render,
|
||||
readouts,
|
||||
placement,
|
||||
fleet: fleetModelServices({
|
||||
display: displayParams,
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
|
||||
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as recession from '../../data/aircraftRecession.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.
|
||||
* `placement`: a host-supplied placement for its live aircraft, or null. */
|
||||
export function createApplicationFlights({
|
||||
surface,
|
||||
source,
|
||||
militaryRegistry,
|
||||
placement = null,
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
}) {
|
||||
@@ -41,6 +43,7 @@ export function createApplicationFlights({
|
||||
render,
|
||||
groundSnap,
|
||||
recession,
|
||||
placement,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import * as context from '../../data/contextStore.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as recession from '../../data/aircraftRecession.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.
|
||||
* `placement`: a host-supplied placement for its live aircraft, or null. */
|
||||
export function createApplicationMilitary({
|
||||
surface,
|
||||
source,
|
||||
militaryRegistry,
|
||||
placement = null,
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
}) {
|
||||
@@ -41,6 +43,7 @@ export function createApplicationMilitary({
|
||||
render,
|
||||
groundSnap,
|
||||
recession,
|
||||
placement,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
* ground floor, the same floor Transit and the aircraft stand on.
|
||||
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
|
||||
* which nearby trains are drawn as fleet models.
|
||||
* @param {{surface: object, source?: object,
|
||||
* `placement` is a host-supplied placement for its live trains, or null.
|
||||
* @param {{surface: object, source?: object, placement?: object|null,
|
||||
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createApplicationRail({
|
||||
surface,
|
||||
source,
|
||||
placement = null,
|
||||
displayParams = null,
|
||||
resolveAsset,
|
||||
}) {
|
||||
@@ -36,6 +38,7 @@ export function createApplicationRail({
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
placement,
|
||||
credits: { registerDynamicCredit, railFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
||||
|
||||
@@ -23,13 +23,15 @@ import {
|
||||
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
|
||||
* which nearby vehicles are drawn as fleet models.
|
||||
*
|
||||
* @param {{surface: object, source?: object,
|
||||
* `placement` is a host-supplied placement for its live vehicles, or null.
|
||||
* @param {{surface: object, source?: object, placement?: object|null,
|
||||
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createApplicationTransit({
|
||||
surface,
|
||||
source,
|
||||
placement = null,
|
||||
displayParams = null,
|
||||
resolveAsset,
|
||||
}) {
|
||||
@@ -43,6 +45,7 @@ export function createApplicationTransit({
|
||||
sprites,
|
||||
picking,
|
||||
overlays,
|
||||
placement,
|
||||
credits: { registerDynamicCredit, transitFeedCredit },
|
||||
ground: {
|
||||
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Retry in an inline panel whose host stops animation frames, so the panel
|
||||
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
|
||||
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { keepPanelRendering } from './embed.js';
|
||||
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
|
||||
|
||||
function setup() {
|
||||
let tick = null;
|
||||
let clock = 0;
|
||||
// The host reports the panel hidden: its own animation frames never arrive.
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval() {},
|
||||
};
|
||||
const previousError = console.error;
|
||||
console.error = () => {};
|
||||
const state = { failure: null, drawn: 0 };
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
initializeFrame() {},
|
||||
// Scene.render: a failing primitive update raises renderError
|
||||
// (tryAndCatchError) and returns; a failing listener throws.
|
||||
render() {
|
||||
state.drawn += 1;
|
||||
this.preUpdate.raiseEvent(this);
|
||||
if (state.failure === 'caught')
|
||||
this.renderError.raiseEvent(this, new Error('boom'));
|
||||
if (state.failure === 'thrown') throw new Error('boom');
|
||||
this.postRender.raiseEvent(this);
|
||||
},
|
||||
};
|
||||
const { frames, widget, viewer, store, runFrames, restore } =
|
||||
renderLoopFixture(scene, {
|
||||
viewer: {
|
||||
isDestroyed: () => false,
|
||||
render: () => widget.render(),
|
||||
resize: () => widget.resize(),
|
||||
},
|
||||
notice: { allowReload: false },
|
||||
});
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
const timerFrame = () => {
|
||||
clock += 400;
|
||||
tick();
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
state,
|
||||
store,
|
||||
timerFrame,
|
||||
animationFrames: runFrames,
|
||||
restore: () => {
|
||||
stop();
|
||||
restore();
|
||||
console.error = previousError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const failure of ['caught', 'thrown']) {
|
||||
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
// The host stops animation frames with the loop's next one queued.
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = failure;
|
||||
timerFrame(); // an error thrown here does not reach the host page
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(typeof store.renderFailure.retry, 'function');
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
const before = state.drawn;
|
||||
timerFrame();
|
||||
assert.ok(state.drawn > before, 'the panel draws again after Retry');
|
||||
assert.equal(
|
||||
store.renderFailure,
|
||||
null,
|
||||
'a clean frame clears the notice',
|
||||
);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
// The host resumes frames: the globe draws once a frame.
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
state.failure = 'caught';
|
||||
animationFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
timerFrame();
|
||||
assert.equal(store.renderFailure, null);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { isEmbeddedInline } from './embed.js';
|
||||
import { installRenderFailureNotice } from '../ui/renderFailure.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
@@ -55,11 +57,17 @@ export async function createApplicationScene({
|
||||
const viewer = createApplicationViewer({
|
||||
container: 'cesiumContainer',
|
||||
creditContainer,
|
||||
// The render failure notice below replaces Cesium's dead-end panel.
|
||||
showRenderLoopErrors: false,
|
||||
});
|
||||
defer(() => {
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
// An inline panel must not reload or navigate its host's page.
|
||||
defer(
|
||||
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
|
||||
);
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
|
||||
+16
-4
@@ -375,8 +375,15 @@ export function installGlobeInViewRecovery(
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
/**
|
||||
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
|
||||
* false leaves render errors to the caller's own notice instead of Cesium's
|
||||
* panel.
|
||||
*/
|
||||
export function applicationViewerOptions(
|
||||
creditContainer,
|
||||
{ showRenderLoopErrors = true } = {},
|
||||
) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
@@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) {
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
showRenderLoopErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
export function createApplicationViewer({
|
||||
container,
|
||||
creditContainer,
|
||||
showRenderLoopErrors,
|
||||
}) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer),
|
||||
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
|
||||
+68
-2
@@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) {
|
||||
* then uses this amount to converge on the layer's delayed display position.
|
||||
* Capping correction below forward speed prevents a late feed fix from
|
||||
* becoming a first-person surge or reversal while still removing drift.
|
||||
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
|
||||
* application subject's own (cockpitSubjectStepSec).
|
||||
*/
|
||||
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
export function cockpitAnchorCorrectionStep(
|
||||
distanceM,
|
||||
speedMps,
|
||||
dtSec,
|
||||
maxDtSec = 0.1,
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM <= 0 ||
|
||||
@@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
dtSec <= 0
|
||||
)
|
||||
return 0;
|
||||
const dt = Math.min(0.1, dtSec);
|
||||
const dt = Math.min(
|
||||
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
|
||||
dtSec,
|
||||
);
|
||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
||||
const correctionRateMps = Math.max(0.75, speed * 0.22);
|
||||
return Math.min(distanceM, eased, correctionRateMps * dt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seconds an application subject's camera update advances its heading
|
||||
* slew, ground lift and anchor by: the time since the last update, at most
|
||||
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
|
||||
* and eases at its configured rates, while a long stall (a hidden tab) still
|
||||
* moves it no further than one late update would.
|
||||
* @param {number} elapsedMs Time since the last camera update.
|
||||
* @param {number} intervalMs The subject's update interval (0: every frame).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
|
||||
const elapsedSec = Number.isFinite(elapsedMs)
|
||||
? Math.max(0, elapsedMs) / 1000
|
||||
: 0;
|
||||
const intervalSec = Number.isFinite(intervalMs)
|
||||
? Math.max(0, intervalMs) / 1000
|
||||
: 0;
|
||||
return Math.min(intervalSec + 0.1, elapsedSec);
|
||||
}
|
||||
|
||||
/** Return whether a throttled cockpit presentation update is due. */
|
||||
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
||||
@@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
||||
return nowMs - startedMs >= timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How fast the clearance a ground clamp adds under an application subject's
|
||||
* eye comes and goes (m/s).
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_LIFT_MPS = 1.5;
|
||||
|
||||
/**
|
||||
* The lift the ground clamp gives an application subject's eye this update.
|
||||
* The floor is read from coarse cells that refine as tiles load, so the lift
|
||||
* it asks for jumps; a ridden eye takes the clearance part of it at
|
||||
* `rateMps`, while what it needs to stay out of the floor itself (`hardM`)
|
||||
* applies at once.
|
||||
* @param {number|null} previousM The lift given last update; null on entry.
|
||||
* @param {number} targetM The lift the floor and clearance ask for (m).
|
||||
* @param {number} hardM The lift that keeps the eye out of the floor (m).
|
||||
* @param {number} dtSec Seconds since the last update.
|
||||
* @param {number} [rateMps]
|
||||
* @returns {number} At least 0, at least `hardM`.
|
||||
*/
|
||||
export function easeCockpitLift(
|
||||
previousM,
|
||||
targetM,
|
||||
hardM,
|
||||
dtSec,
|
||||
rateMps = COCKPIT_SUBJECT_LIFT_MPS,
|
||||
) {
|
||||
const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0;
|
||||
const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0;
|
||||
if (!Number.isFinite(previousM)) return Math.max(target, hard);
|
||||
const step =
|
||||
Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) *
|
||||
Math.max(0, Number.isFinite(dtSec) ? dtSec : 0);
|
||||
const eased = previousM + Math.max(-step, Math.min(step, target - previousM));
|
||||
return Math.max(0, hard, eased);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the cockpit camera above the shared rendered-surface floor.
|
||||
* Unknown floors preserve the proposed camera height.
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitAltitudeDisplayFt,
|
||||
cockpitGroundSafeHeight,
|
||||
cockpitSubjectStepSec,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
COCKPIT_SUBJECT_LIFT_MPS,
|
||||
easeCockpitLift,
|
||||
compassDivisions,
|
||||
compassMarkerSlot,
|
||||
scrollingCompassDivisions,
|
||||
@@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
|
||||
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
|
||||
});
|
||||
|
||||
test('an application subject on a slow cadence is corrected for its whole step', () => {
|
||||
// The built-in views' step stays capped at 0.1 s...
|
||||
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
|
||||
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
|
||||
// ...while a subject updated every 500 ms is corrected for all of it.
|
||||
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
|
||||
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
|
||||
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
|
||||
});
|
||||
|
||||
test('an application subject steps by its interval, never by a long stall', () => {
|
||||
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
|
||||
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
|
||||
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
|
||||
// A hidden tab's minutes count as one late update.
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
|
||||
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
|
||||
});
|
||||
|
||||
test('cockpit camera clearance never falls below the rendered ground floor', () => {
|
||||
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
|
||||
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
|
||||
@@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||
});
|
||||
|
||||
test('an application subject’s ground lift eases its clearance, never the floor', () => {
|
||||
// On entry the lift asked for applies at once.
|
||||
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
|
||||
// A floor cell that rises by a metre is met at the lift rate...
|
||||
let lift = 0;
|
||||
const frames = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
lift = easeCockpitLift(lift, 1, 0, 0.016);
|
||||
frames.push(lift);
|
||||
}
|
||||
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
|
||||
for (let i = 1; i < frames.length; i += 1)
|
||||
assert.ok(
|
||||
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
|
||||
);
|
||||
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
|
||||
// ...and given back the same way.
|
||||
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
|
||||
// What keeps the eye out of the floor itself is never eased.
|
||||
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
|
||||
assert.equal(
|
||||
easeCockpitLift(0.2, -3, -3, 0.016),
|
||||
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
|
||||
);
|
||||
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @module cockpitProgress
|
||||
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
|
||||
* at a width in pixels: where each tick, label and the marker go.
|
||||
*
|
||||
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
|
||||
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
|
||||
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
|
||||
* with each other at their mean place. A merged tick takes the strongest tone
|
||||
* of those it holds. Labels never overlap: where two would, the one of lower
|
||||
* priority is left out (on a tie, the later one).
|
||||
*
|
||||
* Pure: no DOM.
|
||||
*/
|
||||
|
||||
/** Bare ticks closer than this merge (px). */
|
||||
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
|
||||
/** Room kept between two labels (px). */
|
||||
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
|
||||
|
||||
/** Tones from weakest to strongest, for a merged tick. */
|
||||
const TONE_RANK = Object.freeze([
|
||||
'muted',
|
||||
'normal',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
const stronger = (a, b) =>
|
||||
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
|
||||
|
||||
/** A label's width estimate (px): its longer line in small capitals. */
|
||||
export function estimateProgressLabelPx(label) {
|
||||
const longest = Math.max(
|
||||
String(label?.text ?? '').length,
|
||||
String(label?.detail ?? '').length,
|
||||
);
|
||||
return longest * 6.4 + 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* The progress line at `widthPx`, or null when there is nothing to draw.
|
||||
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
|
||||
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
|
||||
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
|
||||
* @param {number} widthPx
|
||||
* @param {{minTickPx?: number, gapPx?: number,
|
||||
* measure?: (label: object) => number}} [options]
|
||||
* @returns {{ticks: Array<{x: number, tone: string, count: number,
|
||||
* labelled: boolean}>, labels: Array<{x: number, left: number,
|
||||
* width: number, align: string, text: string, detail: string,
|
||||
* tone: string}>, marker: {x: number, dwell: boolean}|null,
|
||||
* travelledX: number, tag: string}|null}
|
||||
*/
|
||||
export function cockpitProgressLayout(
|
||||
progress,
|
||||
widthPx,
|
||||
{
|
||||
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
|
||||
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
|
||||
measure = estimateProgressLabelPx,
|
||||
} = {},
|
||||
) {
|
||||
const ticks = progress?.ticks;
|
||||
if (!Array.isArray(ticks) || ticks.length < 2) return null;
|
||||
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
|
||||
const placed = ticks.map((tick, order) => ({
|
||||
x: tick.at * widthPx,
|
||||
tone: tick.tone,
|
||||
label: tick.label,
|
||||
order,
|
||||
}));
|
||||
const labelled = placed.filter((tick) => tick.label);
|
||||
// Bare ticks beside a labelled one go into it.
|
||||
const kept = labelled.map((tick) => ({
|
||||
x: tick.x,
|
||||
tone: tick.tone,
|
||||
count: 1,
|
||||
labelled: true,
|
||||
label: tick.label,
|
||||
order: tick.order,
|
||||
}));
|
||||
const bare = [];
|
||||
for (const tick of placed) {
|
||||
if (tick.label) continue;
|
||||
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
|
||||
if (host) {
|
||||
host.tone = stronger(host.tone, tick.tone);
|
||||
host.count += 1;
|
||||
} else bare.push(tick);
|
||||
}
|
||||
// Bare ticks in a chain closer than minTickPx merge at their mean place.
|
||||
let cluster = null;
|
||||
const flush = () => {
|
||||
if (!cluster) return;
|
||||
kept.push({
|
||||
x: cluster.sum / cluster.count,
|
||||
tone: cluster.tone,
|
||||
count: cluster.count,
|
||||
labelled: false,
|
||||
label: null,
|
||||
order: cluster.order,
|
||||
});
|
||||
cluster = null;
|
||||
};
|
||||
for (const tick of bare) {
|
||||
if (cluster && tick.x - cluster.last < minTickPx) {
|
||||
cluster.sum += tick.x;
|
||||
cluster.count += 1;
|
||||
cluster.last = tick.x;
|
||||
cluster.tone = stronger(cluster.tone, tick.tone);
|
||||
} else {
|
||||
flush();
|
||||
cluster = {
|
||||
sum: tick.x,
|
||||
count: 1,
|
||||
last: tick.x,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
};
|
||||
}
|
||||
}
|
||||
flush();
|
||||
kept.sort((a, b) => a.x - b.x || a.order - b.order);
|
||||
|
||||
// Labels by priority; one that would overlap a placed label is left out.
|
||||
const boxes = [];
|
||||
const candidates = kept
|
||||
.filter((tick) => tick.labelled)
|
||||
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
|
||||
for (const tick of candidates) {
|
||||
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
|
||||
let left =
|
||||
tick.label.align === 'start'
|
||||
? tick.x
|
||||
: tick.label.align === 'end'
|
||||
? tick.x - width
|
||||
: tick.x - width / 2;
|
||||
left = Math.min(Math.max(0, left), widthPx - width);
|
||||
const right = left + width;
|
||||
if (
|
||||
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
|
||||
)
|
||||
continue;
|
||||
boxes.push({
|
||||
x: tick.x,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
align: tick.label.align,
|
||||
text: tick.label.text,
|
||||
detail: tick.label.detail,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
});
|
||||
}
|
||||
boxes.sort((a, b) => a.left - b.left);
|
||||
const marker = progress.marker
|
||||
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
|
||||
: null;
|
||||
return {
|
||||
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
|
||||
x,
|
||||
tone,
|
||||
count,
|
||||
labelled: isLabelled,
|
||||
})),
|
||||
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
|
||||
x,
|
||||
left,
|
||||
width,
|
||||
align,
|
||||
text,
|
||||
detail,
|
||||
tone,
|
||||
})),
|
||||
marker,
|
||||
travelledX: marker ? marker.x : 0,
|
||||
tag: progress.tag || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
|
||||
// width: close bare ticks merge, labelled ticks keep their place, labels never
|
||||
// overlap (the lower priority gives way) and the part before the marker is
|
||||
// the travelled part.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cockpitProgressLayout } from './cockpitProgress.js';
|
||||
|
||||
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
|
||||
const label = (text, priority, align = 'center', detail = '') => ({
|
||||
text,
|
||||
detail,
|
||||
priority,
|
||||
align,
|
||||
});
|
||||
/** Every label is 50 px wide. */
|
||||
const measure = () => 50;
|
||||
|
||||
test('nothing is drawn without two ticks or a width', () => {
|
||||
assert.equal(cockpitProgressLayout(null, 500), null);
|
||||
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
|
||||
assert.equal(
|
||||
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.1),
|
||||
tick(0.101, null, 'warning'),
|
||||
tick(0.102),
|
||||
tick(0.5),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.3, dwell: false },
|
||||
tag: 'TIMETABLE',
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.ticks.map(({ x, count, tone, labelled }) => [
|
||||
Math.round(x),
|
||||
count,
|
||||
tone,
|
||||
labelled,
|
||||
]),
|
||||
[
|
||||
[0, 1, 'normal', true],
|
||||
[101, 3, 'warning', false],
|
||||
[500, 1, 'normal', false],
|
||||
[1000, 1, 'normal', true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(layout.marker, { x: 300, dwell: false });
|
||||
assert.equal(layout.travelledX, 300);
|
||||
assert.equal(layout.tag, 'TIMETABLE');
|
||||
});
|
||||
|
||||
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.498),
|
||||
tick(0.5, label('Next', 3), 'alert'),
|
||||
tick(0.503, null, 'warning'),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: null,
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
|
||||
assert.equal(next.count, 3);
|
||||
assert.equal(next.labelled, true);
|
||||
assert.equal(next.tone, 'alert');
|
||||
assert.equal(layout.ticks.length, 3);
|
||||
assert.equal(layout.marker, null);
|
||||
assert.equal(layout.travelledX, 0);
|
||||
});
|
||||
|
||||
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('First', 1, 'start')),
|
||||
tick(0.04, label('Previous', 2)),
|
||||
tick(0.5, label('Next', 3)),
|
||||
tick(0.52, label('Close', 0)),
|
||||
tick(1, label('Last', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.2, dwell: true },
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
|
||||
[
|
||||
['Previous', 15],
|
||||
['Next', 475],
|
||||
['Last', 950],
|
||||
],
|
||||
);
|
||||
// The first stop's label would cover the previous stop's: it gives way.
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
|
||||
assert.deepEqual(layout.marker, { x: 200, dwell: true });
|
||||
});
|
||||
|
||||
test('a centred label at an edge is clamped inside the line', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(layout.labels[0].left, 0);
|
||||
const right = cockpitProgressLayout(
|
||||
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(right.labels[0].left, 250);
|
||||
});
|
||||
|
||||
test('a long run of calls still lays out, every one a tick or part of one', () => {
|
||||
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
|
||||
ticks[0].label = label('First', 1, 'start');
|
||||
ticks[59].label = label('Last', 1, 'end');
|
||||
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
|
||||
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
|
||||
const count = (layout) =>
|
||||
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
|
||||
assert.equal(count(narrow), 60);
|
||||
assert.equal(count(wide), 60);
|
||||
assert.ok(narrow.ticks.length < wide.ticks.length);
|
||||
assert.equal(wide.ticks.length, 60);
|
||||
});
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* @module cockpitSubjectProviders
|
||||
* @description Subject providers: kinds of subject an application adds to the
|
||||
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
|
||||
* subject entry would take, holds it while the view rides it, hands the camera
|
||||
* a frame each update and the HUD a view model, and releases it on exit. Core
|
||||
* knows nothing else about it.
|
||||
*
|
||||
* Everything a provider returns is normalized here before the Cockpit uses
|
||||
* it: a malformed provider is left out, a malformed frame ends the view, and a
|
||||
* malformed HUD view is read field by field, never thrown on.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/** The kinds the Cockpit rides itself; no provider may take one. */
|
||||
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
|
||||
'aircraft',
|
||||
'satellite',
|
||||
'vessel',
|
||||
]);
|
||||
|
||||
/** The tones a provider's HUD texts may take. */
|
||||
export const COCKPIT_SUBJECT_TONES = Object.freeze([
|
||||
'normal',
|
||||
'muted',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The heights a frame may give (m, ellipsoidal): below the deepest dry
|
||||
* ground, above anything ridden near it; outside, the frame stands on the
|
||||
* floor.
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
|
||||
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
|
||||
|
||||
/** The most readouts, sources and signals a HUD view shows. */
|
||||
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
|
||||
/** The most ticks a progress line takes. */
|
||||
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
|
||||
|
||||
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
||||
const REQUIRED_METHODS = Object.freeze([
|
||||
'candidate',
|
||||
'hold',
|
||||
'read',
|
||||
'hud',
|
||||
'release',
|
||||
]);
|
||||
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
|
||||
const TONES = new Set(COCKPIT_SUBJECT_TONES);
|
||||
|
||||
const isObject = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
||||
|
||||
/**
|
||||
* `value` as display text: trimmed, at most `max` characters (the last one
|
||||
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
|
||||
* kept whole or left out); '' for anything that is not a string or a number.
|
||||
*/
|
||||
export function cockpitSubjectText(value, max) {
|
||||
if (typeof value !== 'string' && !finite(value)) return '';
|
||||
const text = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= max) return text;
|
||||
let end = max - 1;
|
||||
const last = text.charCodeAt(end - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
||||
return `${text.slice(0, end)}…`;
|
||||
}
|
||||
|
||||
const tone = (value) => (TONES.has(value) ? value : 'normal');
|
||||
|
||||
/** A Material Symbols entry face, or null. */
|
||||
function faceOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const label = cockpitSubjectText(raw.label, 16);
|
||||
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
|
||||
return null;
|
||||
return Object.freeze({ icon: raw.icon, label });
|
||||
}
|
||||
|
||||
/** The labels a provider gives, normalized, or null without the required. */
|
||||
function labelsOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const labels = {
|
||||
view: cockpitSubjectText(raw.view, 48),
|
||||
exit: cockpitSubjectText(raw.exit, 48),
|
||||
exitText: cockpitSubjectText(raw.exitText, 24),
|
||||
kicker: cockpitSubjectText(raw.kicker, 48),
|
||||
};
|
||||
return labels.view && labels.exit && labels.exitText
|
||||
? Object.freeze(labels)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A provider's look, normalized, or null without an aheadLabel. */
|
||||
function lookOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
|
||||
if (!aheadLabel) return null;
|
||||
return Object.freeze({
|
||||
restPitchDeg: finite(raw.restPitchDeg)
|
||||
? clamp(raw.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's worded member `key` as it reads now, normalized: the value,
|
||||
* or what the function it is returns when called on `provider` (so it can
|
||||
* follow the page's language); null when it is malformed or a read throws.
|
||||
* Reading the member, and reading what it gives, runs provider code too (a
|
||||
* getter), so both are guarded as the call is.
|
||||
*/
|
||||
function wordedNow(provider, key, normalize) {
|
||||
try {
|
||||
const value = provider[key];
|
||||
return (
|
||||
normalize(typeof value === 'function' ? value.call(provider) : value) ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider's name in a warning: its kind, cut, or its place in the list. */
|
||||
function providerName(provider, order) {
|
||||
try {
|
||||
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
|
||||
} catch {
|
||||
// A kind getter that throws gives no name.
|
||||
}
|
||||
return `#${order}`;
|
||||
}
|
||||
|
||||
/** Why `provider` cannot be kept, or null. */
|
||||
function refusal(provider, kinds) {
|
||||
if (!isObject(provider)) return 'not an object';
|
||||
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
|
||||
return 'malformed kind';
|
||||
if (RESERVED.has(provider.kind)) return 'reserved kind';
|
||||
if (kinds.has(provider.kind)) return 'repeated kind';
|
||||
if (
|
||||
typeof provider.layerId !== 'string' ||
|
||||
!LAYER_ID_PATTERN.test(provider.layerId)
|
||||
)
|
||||
return 'malformed layer id';
|
||||
for (const method of REQUIRED_METHODS)
|
||||
if (typeof provider[method] !== 'function') return `no ${method}()`;
|
||||
if (
|
||||
provider.isEntryAllowed !== undefined &&
|
||||
typeof provider.isEntryAllowed !== 'function'
|
||||
)
|
||||
return 'isEntryAllowed is not a function';
|
||||
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
|
||||
if (!wordedNow(provider, 'labels', labelsOf))
|
||||
return 'labels need view, exit and exitText';
|
||||
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
|
||||
return 'look needs an aheadLabel';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's subject providers, checked once and frozen, in the order
|
||||
* entry asks them: ascending `priority`, ties in list order. Each has
|
||||
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
|
||||
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
|
||||
* - `priority`: a finite number (default 0);
|
||||
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
|
||||
* most 16 characters, in the page's language;
|
||||
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
|
||||
* button's accessible names, the exit button's text and the default kicker,
|
||||
* in the page's language;
|
||||
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
|
||||
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
|
||||
* ahead button named `aheadLabel`;
|
||||
* `entryFace`, `labels` and `look` may each be a function returning the
|
||||
* object: it is called on the provider again whenever they are shown and
|
||||
* when the page's language changes, so its words can follow it
|
||||
* (`./i18n`). A read that throws or is malformed keeps the last
|
||||
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
|
||||
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
|
||||
* updates the camera every rendered frame;
|
||||
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
|
||||
* positive or Infinity, null for the Cockpit's default;
|
||||
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
||||
* synchronous (createCockpitSubjectCaller) and called on the provider, so
|
||||
* a class instance's methods keep their `this`. `isEntryAllowed` allows
|
||||
* entry only by returning `true`. `hold` returns `true` once it holds the
|
||||
* subject, or `false` to refuse entry; any other answer refuses it too,
|
||||
* with one warning. A refused hold, and one that throws or returns a
|
||||
* promise, is followed by `release(id, {restore: false})`, and by another
|
||||
* once that promise settles; `release` must therefore be harmless for a
|
||||
* subject it does not hold or already gave back.
|
||||
*
|
||||
* A provider that is not so, or whose members throw when read (a getter), is
|
||||
* left out with one warning naming it; the providers after it are kept.
|
||||
* @param {unknown} list
|
||||
* @param {{warn?: (message: string) => void}} [options]
|
||||
* @returns {ReadonlyArray<object>}
|
||||
*/
|
||||
export function cockpitSubjectProviders(
|
||||
list = [],
|
||||
{ warn = (message) => console.warn(message) } = {},
|
||||
) {
|
||||
const kept = [];
|
||||
const kinds = new Set();
|
||||
(Array.isArray(list) ? list : []).forEach((provider, order) => {
|
||||
let reason;
|
||||
let copy = null;
|
||||
try {
|
||||
reason = refusal(provider, kinds);
|
||||
if (!reason) copy = keptProvider(provider, order);
|
||||
} catch {
|
||||
reason = 'a member threw when read';
|
||||
}
|
||||
if (reason) {
|
||||
warn(
|
||||
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
kinds.add(copy.kind);
|
||||
kept.push(copy);
|
||||
});
|
||||
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
|
||||
return Object.freeze(kept);
|
||||
}
|
||||
|
||||
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
|
||||
function keptProvider(provider, order) {
|
||||
// Worded members are read again on each use (see above); the last good
|
||||
// read stands in for one that is malformed or throws.
|
||||
let face = wordedNow(provider, 'entryFace', faceOf);
|
||||
let labels = wordedNow(provider, 'labels', labelsOf);
|
||||
// On or off, and its rest pitch, as first read.
|
||||
let look = wordedNow(provider, 'look', lookOf);
|
||||
const restPitchDeg = look?.restPitchDeg ?? 0;
|
||||
return Object.freeze({
|
||||
kind: provider.kind,
|
||||
layerId: provider.layerId,
|
||||
priority: finite(provider.priority) ? provider.priority : 0,
|
||||
order,
|
||||
get entryFace() {
|
||||
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
|
||||
return face;
|
||||
},
|
||||
get labels() {
|
||||
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
|
||||
return labels;
|
||||
},
|
||||
get look() {
|
||||
if (!look) return null;
|
||||
const now = wordedNow(provider, 'look', lookOf);
|
||||
if (now)
|
||||
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
|
||||
return look;
|
||||
},
|
||||
cameraIntervalMs:
|
||||
finite(provider.cameraIntervalMs) &&
|
||||
provider.cameraIntervalMs >= 0 &&
|
||||
provider.cameraIntervalMs <= 1000
|
||||
? provider.cameraIntervalMs
|
||||
: null,
|
||||
headingSlewDps:
|
||||
provider.headingSlewDps === Infinity ||
|
||||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
|
||||
? provider.headingSlewDps
|
||||
: null,
|
||||
// Bound, so a method that uses `this` (a class instance's, say) still
|
||||
// reads its own provider when called on this copy.
|
||||
candidate: provider.candidate.bind(provider),
|
||||
isEntryAllowed: provider.isEntryAllowed
|
||||
? provider.isEntryAllowed.bind(provider)
|
||||
: () => true,
|
||||
hold: provider.hold.bind(provider),
|
||||
read: provider.read.bind(provider),
|
||||
hud: provider.hud.bind(provider),
|
||||
release: provider.release.bind(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
|
||||
* usable id. `name` falls back to the id; `face` is null unless well formed;
|
||||
* `selectedAt` (a number or a short string, else null) is a token the
|
||||
* provider changes each time its subject is selected, even again: a
|
||||
* subject selected after the built-in ones takes entry (entryCandidates).
|
||||
* Null too when reading `raw` throws (a getter runs provider code).
|
||||
* @param {unknown} raw What `candidate()` returned.
|
||||
*/
|
||||
export function cockpitSubjectCandidate(raw) {
|
||||
try {
|
||||
return candidateOf(raw);
|
||||
} catch {
|
||||
// A getter on what candidate() returned threw: no candidate.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateOf(raw) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string') return null;
|
||||
const id = raw.id.trim();
|
||||
if (!id || id.length > 128) return null;
|
||||
const selectedAt =
|
||||
finite(raw.selectedAt) ||
|
||||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
|
||||
? raw.selectedAt
|
||||
: null;
|
||||
return Object.freeze({
|
||||
id,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
|
||||
face: faceOf(raw.face),
|
||||
selectedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera frame from a provider, normalized, or null when it cannot place
|
||||
* the camera (the view then ends): not an object, an id other than `heldId`,
|
||||
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
|
||||
* read of it that throws (a getter runs provider code).
|
||||
* A `heightM` that is not finite or lies outside
|
||||
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
|
||||
* the eye then stands on the shared floor, or where it last stood while that
|
||||
* is cold. The anchor is `'inertial'` only when asked for and the frame
|
||||
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
|
||||
* trimmed, as `cockpitSubjectCandidate` trims it.
|
||||
* @param {unknown} raw What `read()` returned.
|
||||
* @param {string} heldId The subject the view holds.
|
||||
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
|
||||
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
|
||||
* pitchDeg, minClearanceM}}`.
|
||||
*/
|
||||
export function cockpitSubjectFrame(raw, heldId) {
|
||||
try {
|
||||
return frameOf(raw, heldId);
|
||||
} catch {
|
||||
// A getter on what read() returned threw: as a malformed frame.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function frameOf(raw, heldId) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
|
||||
return null;
|
||||
const latitude = raw.latitude;
|
||||
const longitude = raw.longitude;
|
||||
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
|
||||
return null;
|
||||
const eye = isObject(raw.eye) ? raw.eye : null;
|
||||
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
|
||||
const heading = (value) =>
|
||||
finite(value) ? ((value % 360) + 360) % 360 : null;
|
||||
const headingDeg = heading(raw.headingDeg);
|
||||
const speedMps =
|
||||
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
|
||||
return {
|
||||
id: heldId,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
|
||||
latitude,
|
||||
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
|
||||
heightM:
|
||||
finite(raw.heightM) &&
|
||||
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
|
||||
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
|
||||
? raw.heightM
|
||||
: null,
|
||||
headingDeg,
|
||||
courseDeg: heading(raw.courseDeg) ?? headingDeg,
|
||||
speedMps,
|
||||
stale: raw.stale === true,
|
||||
groundClamp: raw.groundClamp !== false,
|
||||
// An inertial anchor runs on at the frame's speed; with none it would
|
||||
// close on a moving subject at the correction's floor and fall behind.
|
||||
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
|
||||
eye: {
|
||||
upM: eye.upM,
|
||||
forwardM:
|
||||
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
|
||||
? eye.forwardM
|
||||
: 0,
|
||||
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
|
||||
minClearanceM:
|
||||
finite(eye.minClearanceM) && eye.minClearanceM >= 0
|
||||
? eye.minClearanceM
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
|
||||
function rowsOf(raw, max, row) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const entry of raw) {
|
||||
if (rows.length >= max) break;
|
||||
if (!isObject(entry)) continue;
|
||||
const id = typeof entry.id === 'string' ? entry.id : '';
|
||||
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
|
||||
const read = row(entry);
|
||||
if (!read) continue;
|
||||
ids.add(id);
|
||||
rows.push({ id, ...read });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A progress line, or null without at least two ticks. */
|
||||
function progressOf(raw) {
|
||||
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
|
||||
const ticks = [];
|
||||
for (const tick of raw.ticks) {
|
||||
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
|
||||
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
|
||||
continue;
|
||||
let label = null;
|
||||
if (isObject(tick.label)) {
|
||||
const text = cockpitSubjectText(tick.label.text, 40);
|
||||
if (text)
|
||||
label = {
|
||||
text,
|
||||
detail: cockpitSubjectText(tick.label.detail, 64),
|
||||
priority: finite(tick.label.priority)
|
||||
? clamp(Math.round(tick.label.priority), 0, 9)
|
||||
: 0,
|
||||
align: ['start', 'center', 'end'].includes(tick.label.align)
|
||||
? tick.label.align
|
||||
: 'center',
|
||||
};
|
||||
}
|
||||
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
|
||||
}
|
||||
if (ticks.length < 2) return null;
|
||||
ticks.sort((a, b) => a.at - b.at);
|
||||
const marker =
|
||||
isObject(raw.marker) && finite(raw.marker.at)
|
||||
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
|
||||
: null;
|
||||
return {
|
||||
ticks,
|
||||
marker,
|
||||
tag: cockpitSubjectText(raw.tag, 16),
|
||||
summary: cockpitSubjectText(raw.summary, 160),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's HUD view model, normalized; never throws. Texts are coerced
|
||||
* and cut to their caps, lists to their lengths, tones to
|
||||
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
|
||||
* Null when reading `raw` throws (a getter runs provider code), so the caller
|
||||
* keeps the view it has, as when `hud()` itself throws.
|
||||
* All text is shown as given: the provider words it in the page's language.
|
||||
* @param {unknown} raw What `hud()` returned.
|
||||
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
|
||||
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
|
||||
* sources: [{id, label, text, tone}],
|
||||
* route: {title, from, to, status}|null,
|
||||
* progress: {ticks: [{at, tone, label: {text, detail, priority,
|
||||
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
|
||||
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
|
||||
* lookReadout, signals: [{id, tone, title, detail}]}`.
|
||||
*/
|
||||
export function cockpitSubjectHudView(raw) {
|
||||
try {
|
||||
return hudViewOf(raw);
|
||||
} catch {
|
||||
// A getter on what hud() returned threw: the caller keeps its last view.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hudViewOf(raw) {
|
||||
const view = isObject(raw) ? raw : {};
|
||||
const meta = isObject(view.meta) ? view.meta : {};
|
||||
const speed =
|
||||
isObject(view.speed) &&
|
||||
(view.speed.value === null ||
|
||||
(finite(view.speed.value) && view.speed.value >= 0))
|
||||
? {
|
||||
value:
|
||||
view.speed.value === null
|
||||
? null
|
||||
: Math.min(view.speed.value, 99_999),
|
||||
unit: cockpitSubjectText(view.speed.unit, 12),
|
||||
tag: cockpitSubjectText(view.speed.tag, 8),
|
||||
tone: tone(view.speed.tone),
|
||||
}
|
||||
: null;
|
||||
const route = isObject(view.route)
|
||||
? {
|
||||
title: cockpitSubjectText(view.route.title, 32),
|
||||
from: cockpitSubjectText(view.route.from, 48),
|
||||
to: cockpitSubjectText(view.route.to, 48),
|
||||
status: cockpitSubjectText(view.route.status, 64),
|
||||
}
|
||||
: null;
|
||||
const bannerTitle = isObject(view.banner)
|
||||
? cockpitSubjectText(view.banner.title, 48)
|
||||
: '';
|
||||
const veil =
|
||||
isObject(view.veil) && finite(view.veil.level)
|
||||
? {
|
||||
level: clamp(view.veil.level, 0, 1),
|
||||
streaks: view.veil.streaks === true,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
kicker: cockpitSubjectText(view.kicker, 48),
|
||||
title: cockpitSubjectText(view.title, 64),
|
||||
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
|
||||
speed,
|
||||
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
|
||||
const label = cockpitSubjectText(row.label, 24);
|
||||
const value = cockpitSubjectText(row.value, 48);
|
||||
return label || value
|
||||
? {
|
||||
label,
|
||||
value,
|
||||
detail: cockpitSubjectText(row.detail, 80),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
|
||||
const text = cockpitSubjectText(row.text, 120);
|
||||
return text
|
||||
? {
|
||||
label: cockpitSubjectText(row.label, 16),
|
||||
text,
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
route: route && (route.from || route.to) ? route : null,
|
||||
progress: progressOf(view.progress),
|
||||
banner: bannerTitle
|
||||
? {
|
||||
title: bannerTitle,
|
||||
detail: cockpitSubjectText(view.banner.detail, 96),
|
||||
tone: tone(view.banner.tone),
|
||||
}
|
||||
: null,
|
||||
veil: veil && veil.level > 0 ? veil : null,
|
||||
lookReadout: cockpitSubjectText(view.lookReadout, 48),
|
||||
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
|
||||
const title = cockpitSubjectText(row.title, 48);
|
||||
return title
|
||||
? {
|
||||
title,
|
||||
detail: cockpitSubjectText(row.detail, 96),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A guarded caller for provider methods: `call(provider, method, args,
|
||||
* fallback)` returns what the method returns, or `fallback` when it throws,
|
||||
* warning once per provider and method.
|
||||
*
|
||||
* Provider methods are synchronous: the Cockpit calls them on its render
|
||||
* cadence and uses the answer at once. One that returns a promise (an async
|
||||
* function, say) breaks that contract: the promise is refused as a throw
|
||||
* would be (its rejection, if any, is swallowed), with one warning, and
|
||||
* `onSettled`, when given, runs once the promise has settled, so a caller
|
||||
* can undo work that lands late.
|
||||
*
|
||||
* `call.warnOnce(provider, method, message, error)` warns on the same
|
||||
* once-per-provider-and-method footing, for an answer the caller finds
|
||||
* malformed.
|
||||
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||
*/
|
||||
export function createCockpitSubjectCaller({
|
||||
warn = (message, error) => console.warn(message, error),
|
||||
} = {}) {
|
||||
const warned = new Set();
|
||||
const warnOnce = (provider, method, message, error) => {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
|
||||
error,
|
||||
);
|
||||
};
|
||||
function call(
|
||||
provider,
|
||||
method,
|
||||
args = [],
|
||||
fallback = null,
|
||||
onSettled = null,
|
||||
) {
|
||||
let result;
|
||||
let thenable;
|
||||
try {
|
||||
result = provider[method](...args);
|
||||
// Reading `then` runs provider code too (a getter may throw), so it is
|
||||
// guarded as the call is.
|
||||
thenable =
|
||||
result !== null &&
|
||||
(typeof result === 'object' || typeof result === 'function') &&
|
||||
typeof result.then === 'function';
|
||||
} catch (error) {
|
||||
warnOnce(provider, method, 'failed', error);
|
||||
return fallback;
|
||||
}
|
||||
if (thenable) {
|
||||
// Its work may still land: `onSettled` runs once it has, either way.
|
||||
const settled = () => {
|
||||
if (typeof onSettled === 'function') onSettled();
|
||||
};
|
||||
Promise.resolve(result).then(settled, settled);
|
||||
warnOnce(
|
||||
provider,
|
||||
method,
|
||||
'returned a promise; provider methods are synchronous',
|
||||
null,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
call.warnOnce = warnOnce;
|
||||
return call;
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
|
||||
// are checked once and what they return is normalized before the Cockpit
|
||||
// uses it: a malformed provider is left out, a malformed frame ends the view,
|
||||
// and a malformed HUD view is read field by field and never thrown on.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
COCKPIT_BUILT_IN_KINDS,
|
||||
COCKPIT_SUBJECT_MAX_READOUTS,
|
||||
COCKPIT_SUBJECT_MAX_TICKS,
|
||||
cockpitSubjectCandidate,
|
||||
cockpitSubjectFrame,
|
||||
cockpitSubjectHudView,
|
||||
cockpitSubjectProviders,
|
||||
cockpitSubjectText,
|
||||
createCockpitSubjectCaller,
|
||||
} from './cockpitSubjectProviders.js';
|
||||
|
||||
const noop = () => null;
|
||||
/** A well-formed provider, with `overrides`. */
|
||||
const provider = (overrides = {}) => ({
|
||||
kind: 'example-kind',
|
||||
layerId: 'example-layer',
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
labels: {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
},
|
||||
candidate: noop,
|
||||
hold: () => true,
|
||||
read: noop,
|
||||
hud: noop,
|
||||
release: noop,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const keep = (list) => {
|
||||
const warnings = [];
|
||||
const kept = cockpitSubjectProviders(list, {
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
return { kept, warnings };
|
||||
};
|
||||
|
||||
test('well-formed providers are kept, frozen, in priority then list order', () => {
|
||||
const { kept, warnings } = keep([
|
||||
provider({ kind: 'late', priority: 5 }),
|
||||
provider({ kind: 'first-of-two', priority: 1 }),
|
||||
provider({ kind: 'second-of-two', priority: 1 }),
|
||||
provider({ kind: 'default-priority' }),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['default-priority', 'first-of-two', 'second-of-two', 'late'],
|
||||
);
|
||||
assert.ok(Object.isFrozen(kept));
|
||||
assert.ok(Object.isFrozen(kept[0]));
|
||||
const [first] = kept;
|
||||
assert.equal(first.priority, 0);
|
||||
assert.equal(first.look, null);
|
||||
assert.equal(first.cameraIntervalMs, null);
|
||||
assert.equal(first.headingSlewDps, null);
|
||||
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
|
||||
assert.deepEqual(first.labels, {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
kicker: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed, reserved and repeated providers are left out with one warning each', () => {
|
||||
const cases = [
|
||||
[null, /not an object/],
|
||||
[provider({ kind: 'X' }), /malformed kind/],
|
||||
[provider({ kind: 'a' }), /malformed kind/],
|
||||
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
|
||||
[provider({ hold: undefined }), /no hold\(\)/],
|
||||
[provider({ read: 'read' }), /no read\(\)/],
|
||||
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
|
||||
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
|
||||
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
|
||||
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
|
||||
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
|
||||
];
|
||||
for (const [entry, reason] of cases) {
|
||||
const { kept, warnings } = keep([entry]);
|
||||
assert.equal(kept.length, 0, String(reason));
|
||||
assert.equal(warnings.length, 1, String(reason));
|
||||
assert.match(warnings[0], reason);
|
||||
}
|
||||
for (const kind of COCKPIT_BUILT_IN_KINDS) {
|
||||
const { kept, warnings } = keep([provider({ kind })]);
|
||||
assert.equal(kept.length, 0, kind);
|
||||
assert.match(warnings[0], /reserved kind/);
|
||||
}
|
||||
const { kept, warnings } = keep([provider(), provider()]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.match(warnings[0], /repeated kind/);
|
||||
assert.deepEqual(keep('not a list').kept, []);
|
||||
});
|
||||
|
||||
test('optional settings are kept when in range and defaulted otherwise', () => {
|
||||
const [ranged] = keep([
|
||||
provider({
|
||||
priority: -2,
|
||||
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
|
||||
cameraIntervalMs: 0,
|
||||
headingSlewDps: Infinity,
|
||||
labels: {
|
||||
view: 'v',
|
||||
exit: 'e',
|
||||
exitText: 'x',
|
||||
kicker: 'K'.repeat(80),
|
||||
},
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(ranged.priority, -2);
|
||||
assert.deepEqual(ranged.look, {
|
||||
restPitchDeg: -90,
|
||||
aheadLabel: 'Look ahead',
|
||||
});
|
||||
assert.equal(ranged.cameraIntervalMs, 0);
|
||||
assert.equal(ranged.headingSlewDps, Infinity);
|
||||
assert.equal(ranged.labels.kicker.length, 48);
|
||||
assert.ok(ranged.labels.kicker.endsWith('…'));
|
||||
const [defaulted] = keep([
|
||||
provider({
|
||||
priority: NaN,
|
||||
cameraIntervalMs: 5000,
|
||||
headingSlewDps: -1,
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(defaulted.priority, 0);
|
||||
assert.equal(defaulted.cameraIntervalMs, null);
|
||||
assert.equal(defaulted.headingSlewDps, null);
|
||||
});
|
||||
|
||||
test('a candidate needs an id; its name and face are read with care', () => {
|
||||
assert.equal(cockpitSubjectCandidate(null), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
|
||||
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
face: null,
|
||||
selectedAt: null,
|
||||
});
|
||||
// A selection token is a number or a short string.
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
|
||||
'click-2',
|
||||
);
|
||||
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
|
||||
null,
|
||||
);
|
||||
const read = cockpitSubjectCandidate({
|
||||
id: 'a1',
|
||||
name: ' Example 12 ',
|
||||
face: { icon: 'tram', label: 'RIDE' },
|
||||
});
|
||||
assert.equal(read.name, 'Example 12');
|
||||
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a frame is the held subject at a finite place with an eye height, else null', () => {
|
||||
const frame = (overrides = {}) => ({
|
||||
id: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
eye: { upM: 3.5 },
|
||||
...overrides,
|
||||
});
|
||||
const read = cockpitSubjectFrame(frame(), 'a1');
|
||||
assert.deepEqual(read, {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
heightM: null,
|
||||
headingDeg: null,
|
||||
courseDeg: null,
|
||||
speedMps: null,
|
||||
stale: false,
|
||||
groundClamp: true,
|
||||
// No speed to run an inertial anchor on: it sits on the subject.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
|
||||
});
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
|
||||
'inertial',
|
||||
'with a speed it runs on',
|
||||
);
|
||||
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
|
||||
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
|
||||
for (const bad of [
|
||||
null,
|
||||
frame({ id: 'other' }),
|
||||
frame({ latitude: 91 }),
|
||||
frame({ latitude: NaN }),
|
||||
frame({ longitude: Infinity }),
|
||||
frame({ eye: null }),
|
||||
frame({ eye: { upM: -1 } }),
|
||||
frame({ eye: { upM: 2000 } }),
|
||||
frame({ eye: { upM: '3' } }),
|
||||
])
|
||||
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
|
||||
const full = cockpitSubjectFrame(
|
||||
frame({
|
||||
name: 'Example',
|
||||
longitude: 190,
|
||||
heightM: 412.5,
|
||||
headingDeg: -10,
|
||||
courseDeg: 725,
|
||||
speedMps: 22,
|
||||
stale: true,
|
||||
groundClamp: false,
|
||||
anchor: 'exact',
|
||||
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
|
||||
}),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(full.longitude, -170);
|
||||
assert.equal(full.heightM, 412.5);
|
||||
// A height outside any plausible ground reads as none.
|
||||
for (const heightM of [-6_400_000, 1e9, Infinity])
|
||||
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
|
||||
-400,
|
||||
);
|
||||
assert.equal(full.headingDeg, 350);
|
||||
assert.equal(full.courseDeg, 5);
|
||||
assert.equal(full.speedMps, 22);
|
||||
assert.equal(full.stale, true);
|
||||
assert.equal(full.groundClamp, false);
|
||||
assert.equal(full.anchor, 'exact');
|
||||
assert.deepEqual(full.eye, {
|
||||
upM: 2,
|
||||
forwardM: 0,
|
||||
pitchDeg: -90,
|
||||
minClearanceM: 1.5,
|
||||
});
|
||||
// Only exactly false turns the ground clamp off; course falls back to heading.
|
||||
const loose = cockpitSubjectFrame(
|
||||
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(loose.groundClamp, true);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
|
||||
'inertial',
|
||||
);
|
||||
assert.equal(loose.courseDeg, 90);
|
||||
assert.equal(loose.speedMps, null);
|
||||
});
|
||||
|
||||
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
|
||||
const empty = cockpitSubjectHudView(null);
|
||||
assert.deepEqual(empty, {
|
||||
kicker: '',
|
||||
title: '',
|
||||
meta: { text: '', tone: 'normal' },
|
||||
speed: null,
|
||||
readouts: [],
|
||||
sources: [],
|
||||
route: null,
|
||||
progress: null,
|
||||
banner: null,
|
||||
veil: null,
|
||||
lookReadout: '',
|
||||
signals: [],
|
||||
});
|
||||
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
|
||||
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
|
||||
const view = cockpitSubjectHudView({
|
||||
kicker: 'LINE · OPERATOR',
|
||||
title: 'Name → Destination',
|
||||
meta: { text: 'LIVE', tone: 'bogus' },
|
||||
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
|
||||
readouts: [
|
||||
...Array.from({ length: 6 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
label: 'NEXT',
|
||||
value: `Stop ${i}`,
|
||||
tone: 'warning',
|
||||
})),
|
||||
],
|
||||
sources: [
|
||||
{ id: 'track', label: 'TRACK', text: 'operator shape' },
|
||||
{ id: 'track', label: 'TRACK', text: 'repeated id' },
|
||||
{ id: 'Bad Id', text: 'refused' },
|
||||
{ id: 'empty', label: 'X', text: '' },
|
||||
],
|
||||
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
|
||||
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
|
||||
veil: { level: 4, streaks: 1 },
|
||||
lookReadout: 'LOOK RIGHT',
|
||||
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
|
||||
});
|
||||
assert.equal(view.meta.tone, 'normal');
|
||||
assert.deepEqual(view.speed, {
|
||||
value: 84.4,
|
||||
unit: 'km/h',
|
||||
tag: 'LIVE',
|
||||
tone: 'live',
|
||||
});
|
||||
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
|
||||
assert.equal(view.readouts[0].tone, 'warning');
|
||||
assert.deepEqual(
|
||||
view.sources.map(({ id }) => id),
|
||||
['track'],
|
||||
);
|
||||
assert.deepEqual(view.route, {
|
||||
title: 'ROUTE',
|
||||
from: 'A',
|
||||
to: 'B',
|
||||
status: '3 stops',
|
||||
});
|
||||
assert.equal(view.banner.tone, 'alert');
|
||||
assert.deepEqual(view.veil, { level: 1, streaks: false });
|
||||
assert.equal(view.lookReadout, 'LOOK RIGHT');
|
||||
assert.equal(view.signals[0].tone, 'warning');
|
||||
// A veil at 0, a speed below 0 and a route with no ends are absent.
|
||||
const absent = cockpitSubjectHudView({
|
||||
veil: { level: 0 },
|
||||
speed: { value: -3 },
|
||||
route: { from: '', to: '' },
|
||||
});
|
||||
assert.equal(absent.veil, null);
|
||||
assert.equal(absent.speed, null);
|
||||
assert.equal(absent.route, null);
|
||||
// A speed of null is a speed not yet known.
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
|
||||
const read = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: [
|
||||
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
|
||||
{ at: 0.5, tone: 'warning' },
|
||||
{ at: 2 },
|
||||
{ at: -0.1 },
|
||||
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
|
||||
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
|
||||
],
|
||||
marker: { at: 1.4, dwell: true },
|
||||
tag: 'TIMETABLE',
|
||||
summary: 'From Start to End',
|
||||
},
|
||||
}).progress;
|
||||
assert.deepEqual(
|
||||
read.ticks.map(({ at }) => at),
|
||||
[0, 0.25, 0.5, 1],
|
||||
);
|
||||
assert.equal(read.ticks[0].label.priority, 9);
|
||||
assert.equal(read.ticks[0].label.align, 'start');
|
||||
assert.equal(read.ticks[1].label, null);
|
||||
assert.equal(read.ticks[2].tone, 'warning');
|
||||
assert.deepEqual(read.marker, { at: 1, dwell: true });
|
||||
assert.equal(read.tag, 'TIMETABLE');
|
||||
assert.equal(read.summary, 'From Start to End');
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
|
||||
null,
|
||||
);
|
||||
const many = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
|
||||
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
|
||||
})),
|
||||
},
|
||||
}).progress;
|
||||
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
|
||||
});
|
||||
|
||||
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
|
||||
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
|
||||
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
|
||||
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
|
||||
assert.equal(cockpitSubjectText({}, 10), '');
|
||||
assert.equal(cockpitSubjectText(NaN, 10), '');
|
||||
});
|
||||
|
||||
test('a cut never splits a surrogate pair', () => {
|
||||
// The cut would fall between the emoji's halves: it is left out whole.
|
||||
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
|
||||
// One that fits before the cut is kept whole.
|
||||
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
|
||||
const lone =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
for (let max = 2; max <= 24; max += 1)
|
||||
assert.equal(
|
||||
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
|
||||
false,
|
||||
`cut at ${max}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a provider method that throws returns the fallback and warns once', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const failing = {
|
||||
kind: 'example-kind',
|
||||
read() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
hud: (value) => value * 2,
|
||||
};
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'hud', [21]), 42);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind read\(\) failed/);
|
||||
});
|
||||
|
||||
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const asynchronous = {
|
||||
kind: 'example-kind',
|
||||
hold: async () => true,
|
||||
read: () => Promise.reject(new Error('late')),
|
||||
};
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'read', [], null), null);
|
||||
assert.equal(warnings.length, 2, 'once per method');
|
||||
assert.match(warnings[0], /hold\(\) returned a promise/);
|
||||
// The rejected promise is not left unhandled.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('an answer whose `then` throws when read is handled as a throw', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const tricky = {
|
||||
kind: 'example-kind',
|
||||
hold: () => ({
|
||||
get then() {
|
||||
throw new Error('no then');
|
||||
},
|
||||
}),
|
||||
};
|
||||
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind hold\(\) failed/);
|
||||
});
|
||||
|
||||
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const ride = {
|
||||
kind: 'example-kind',
|
||||
hold() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
call.warnOnce(ride, 'read', 'is odd');
|
||||
call.warnOnce(ride, 'read', 'is odd again');
|
||||
call(ride, 'hold', ['a1']);
|
||||
call.warnOnce(ride, 'hold', 'is odd');
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider example-kind read() is odd',
|
||||
'[Cockpit] subject provider example-kind hold() failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
|
||||
class Ride {
|
||||
#word = 'RIDE';
|
||||
#held = null;
|
||||
kind = 'example-kind';
|
||||
layerId = 'example-layer';
|
||||
speak(word) {
|
||||
this.#word = word;
|
||||
}
|
||||
entryFace() {
|
||||
return { icon: 'commute', label: this.#word };
|
||||
}
|
||||
get labels() {
|
||||
return {
|
||||
view: `${this.#word} view`,
|
||||
exit: 'Exit ride view',
|
||||
exitText: 'EXIT RIDE',
|
||||
};
|
||||
}
|
||||
look() {
|
||||
return { aheadLabel: `Look ahead, ${this.#word}` };
|
||||
}
|
||||
candidate() {
|
||||
return { id: 'a1', name: this.#word };
|
||||
}
|
||||
isEntryAllowed(candidate) {
|
||||
return candidate.id === 'a1' && this.#held === null;
|
||||
}
|
||||
hold(id) {
|
||||
this.#held = id;
|
||||
return true;
|
||||
}
|
||||
read() {
|
||||
return this.#held;
|
||||
}
|
||||
hud(frame) {
|
||||
return { title: `${this.#word} ${frame}` };
|
||||
}
|
||||
release() {
|
||||
this.#held = null;
|
||||
return 'released';
|
||||
}
|
||||
}
|
||||
const instance = new Ride();
|
||||
const { kept, warnings } = keep([instance]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.labels.view, 'RIDE view');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
|
||||
instance.speak('FAHRT');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
|
||||
// Called on the kept copy, as the Cockpit calls them.
|
||||
const failures = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => failures.push(message),
|
||||
});
|
||||
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
|
||||
assert.equal(call(ride, 'hold', ['a1']), true);
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
|
||||
assert.equal(call(ride, 'read', [0, {}]), 'a1');
|
||||
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
|
||||
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
|
||||
assert.equal(call(ride, 'read', [0, {}]), null);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('labels, entry face and look given as functions are read again on each use', () => {
|
||||
let lang = 'en';
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
|
||||
};
|
||||
const { kept, warnings } = keep([
|
||||
provider({
|
||||
labels: () => words[lang],
|
||||
entryFace: () => ({
|
||||
icon: 'commute',
|
||||
label: { en: 'RIDE', de: 'FAHRT' }[lang],
|
||||
}),
|
||||
look: () => ({
|
||||
restPitchDeg: lang === 'de' ? 40 : -3,
|
||||
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.labels.exitText, 'EXIT RIDE');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead');
|
||||
lang = 'de';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
assert.equal(ride.look.aheadLabel, 'Nach vorn');
|
||||
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
|
||||
// A read that fails or is malformed keeps the last good one.
|
||||
lang = 'xx';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
// Functions that are malformed from the start leave the provider out.
|
||||
const bad = keep([
|
||||
provider({ kind: 'bad-labels', labels: () => null }),
|
||||
provider({
|
||||
kind: 'thrown-face',
|
||||
entryFace: () => {
|
||||
throw new Error('no face');
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(bad.kept, []);
|
||||
assert.equal(bad.warnings.length, 2);
|
||||
});
|
||||
|
||||
/** `target` with a getter for `key` that throws (provider code runs on a read). */
|
||||
const throwingGetter = (target, key) =>
|
||||
Object.defineProperty(target, key, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error(`${key} unavailable`);
|
||||
},
|
||||
});
|
||||
|
||||
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
|
||||
const { kept, warnings } = keep([
|
||||
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
|
||||
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
|
||||
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
|
||||
throwingGetter(provider(), 'kind'),
|
||||
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
|
||||
// Read only once the provider is accepted, so its kind must stay free.
|
||||
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
|
||||
provider({ kind: 'example-kind' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['example-kind'],
|
||||
);
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider thrown-face left out: malformed entry face',
|
||||
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
|
||||
'[Cockpit] subject provider thrown-look left out: a member threw when read',
|
||||
'[Cockpit] subject provider #3 left out: a member threw when read',
|
||||
'[Cockpit] subject provider thrown-method left out: a member threw when read',
|
||||
'[Cockpit] subject provider example-kind left out: a member threw when read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a worded getter that starts throwing keeps the last good words', () => {
|
||||
let failing = false;
|
||||
const words = (value) => () => {
|
||||
if (failing) throw new Error('translations unavailable');
|
||||
return value;
|
||||
};
|
||||
const definition = provider();
|
||||
for (const [key, value] of Object.entries({
|
||||
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
|
||||
}))
|
||||
Object.defineProperty(definition, key, { get: words(value) });
|
||||
const { kept, warnings } = keep([definition]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
failing = true;
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
|
||||
// So does a worded object whose own member throws when read.
|
||||
failing = false;
|
||||
Object.defineProperty(definition, 'labels', {
|
||||
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
|
||||
});
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
});
|
||||
|
||||
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
|
||||
null,
|
||||
);
|
||||
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
|
||||
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
|
||||
null,
|
||||
);
|
||||
// Nothing to read is still the empty view, never null.
|
||||
assert.equal(cockpitSubjectHudView(null).title, '');
|
||||
});
|
||||
@@ -481,6 +481,24 @@ export function orbitEntryAllowed({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an application subject provider's candidate can be ridden. Like the
|
||||
* bridge and orbit it needs only its own layer on, and waits out a Context
|
||||
* change in flight; the provider then has its own say (`isEntryAllowed`).
|
||||
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
|
||||
* providerAllows: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function subjectEntryAllowed({
|
||||
layerEnabled,
|
||||
contextModeChanging,
|
||||
providerAllows,
|
||||
}) {
|
||||
return (
|
||||
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context-mode id → the word the voice tools accept for it.
|
||||
*
|
||||
|
||||
+127
-36
@@ -4,18 +4,31 @@ import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
|
||||
const radioBindings = readRadioSource(
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const contextView = readRadioSource(
|
||||
new URL('./ui/views/ContextView.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const html = await renderApplicationHtml(
|
||||
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
|
||||
for (const id of [
|
||||
'global-context-flights-btn',
|
||||
'global-context-missions-btn',
|
||||
]) {
|
||||
const button = html.match(
|
||||
new RegExp(`<button id="${id}"[\\s\\S]*?</button>`),
|
||||
);
|
||||
assert.ok(button, `${id} is missing`);
|
||||
assert.match(button[0], /role="tab"/);
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
@@ -23,7 +36,10 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
|
||||
// The view keeps both tabs' tabindex static and never disables them; the
|
||||
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
for (const id of [
|
||||
'global-context-flights-btn',
|
||||
'global-context-missions-btn',
|
||||
]) {
|
||||
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
|
||||
assert.ok(tag, `${id} is missing from the view`);
|
||||
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
|
||||
@@ -34,9 +50,22 @@ test('Context activation and Clear All never native-disable tabs and guard repea
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const select = _selectContextMode.toString();
|
||||
const clear = clearSelectedLayers.toString();
|
||||
assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
|
||||
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.equal(
|
||||
(
|
||||
init.match(
|
||||
/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g,
|
||||
) || []
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
select,
|
||||
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
clear,
|
||||
/_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
|
||||
@@ -45,40 +74,62 @@ test('Context tablist retains Left, Right, Home, and End keyboard navigation', (
|
||||
assert.match(initSource, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(initSource, /event\.key === 'Home'/);
|
||||
assert.match(initSource, /event\.key === 'End'/);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(
|
||||
initSource,
|
||||
/contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/,
|
||||
);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
});
|
||||
|
||||
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const focusRule = rules.find(([, selector, body]) => (
|
||||
selector.trim().endsWith('.context-mode-button:focus-visible')
|
||||
&& /outline:\s*2px solid var\(--text-primary\)/.test(body)
|
||||
));
|
||||
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
|
||||
const focusRule = rules.find(
|
||||
([, selector, body]) =>
|
||||
selector.trim().endsWith('.context-mode-button:focus-visible') &&
|
||||
/outline:\s*2px solid var\(--text-primary\)/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
focusRule,
|
||||
'Context focus-visible rule must draw a two-pixel outline',
|
||||
);
|
||||
assert.match(focusRule[2], /outline-offset:\s*-3px/);
|
||||
|
||||
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
|
||||
const activeRule = rules.find(([, selector]) =>
|
||||
selector.trim().endsWith('.context-mode-button.active'),
|
||||
);
|
||||
assert.ok(activeRule, 'Context active-state rule is missing');
|
||||
assert.doesNotMatch(activeRule[2], /outline:\s*none/);
|
||||
});
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
|
||||
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
|
||||
const radio = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
|
||||
);
|
||||
const radioPanel = readFileSync(
|
||||
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
|
||||
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
|
||||
const search = contextView.match(
|
||||
/<button id="installations-search-btn"[^>]*>/,
|
||||
)?.[0];
|
||||
assert.ok(search, 'nearby sites search is missing');
|
||||
assert.doesNotMatch(search, /:disabled|\sdisabled/);
|
||||
assert.match(search, /:aria-busy=/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
|
||||
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
|
||||
const cockpitRadio = readFileSync(
|
||||
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cockpitEnable = cockpitRadio.match(
|
||||
/<UiButton id="cockpit-radio-enable-btn"[^>]*>/,
|
||||
)?.[0];
|
||||
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
|
||||
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
|
||||
assert.match(cockpitEnable, /:aria-busy=/);
|
||||
@@ -89,44 +140,84 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.match(enable, /:aria-busy=/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
// The globe actions view marks the clear button busy, never disabled.
|
||||
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
|
||||
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
|
||||
const globeActions = readFileSync(
|
||||
new URL('./ui/views/panels/GlobeActions.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const clearButton = globeActions.match(
|
||||
/<button id="clear-selected-layers"[^>]*>/,
|
||||
)?.[0];
|
||||
assert.ok(clearButton, 'Clear selected layers button is missing');
|
||||
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
|
||||
assert.match(clearButton, /:aria-busy=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
|
||||
test('Context header holds only its title, edge switch and collapse control', () => {
|
||||
const panel = html.match(
|
||||
/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/,
|
||||
);
|
||||
assert.ok(panel, 'Global Context panel is missing');
|
||||
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
|
||||
const header = panel[0].match(
|
||||
/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/,
|
||||
);
|
||||
assert.ok(header, 'Global Context header is missing');
|
||||
// The kit's own ui-* classes carry no Core styles.
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
|
||||
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
const tags = [
|
||||
...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g),
|
||||
].map(
|
||||
([, tag, cls]) =>
|
||||
`${tag}.${cls
|
||||
.split(' ')
|
||||
.filter((name) => !/^ui-/.test(name))
|
||||
.join(' ')}`,
|
||||
);
|
||||
assert.deepEqual(tags, [
|
||||
'span.panel-title',
|
||||
'span.panel-divider',
|
||||
'button.panel-edge-btn',
|
||||
'span.material-symbols-outlined',
|
||||
'button.panel-collapse-btn',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Context reads as one segmented control with legible type and no halo', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
|
||||
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
|
||||
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
|
||||
const clean = (selector) =>
|
||||
selector
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const rule = (selector) =>
|
||||
rules.find(([, s]) => clean(s) === selector)?.[2] || '';
|
||||
const px = (body, prop) =>
|
||||
Number(
|
||||
body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0,
|
||||
);
|
||||
|
||||
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
|
||||
const modes = rule('.global-context-modes');
|
||||
assert.match(modes, /border:\s*1px solid/);
|
||||
assert.match(modes, /padding:\s*2px/);
|
||||
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
|
||||
assert.ok(
|
||||
px(rule('.context-mode-button'), 'font') >= 10,
|
||||
'mode labels must stay at least 10px',
|
||||
);
|
||||
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
|
||||
const standby = rule('.context-mode-standby');
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.ok(
|
||||
px(standby, 'font') >= 10,
|
||||
'standby guidance must stay at least 10px',
|
||||
);
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
assert.ok(
|
||||
px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10,
|
||||
);
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
rule(
|
||||
":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title",
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
// A parked or slow aircraft reports no ground track, but often its true
|
||||
// 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 [
|
||||
hex,
|
||||
|
||||
@@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
|
||||
assert.equal(normalized.states[0][7], null);
|
||||
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,7 +458,8 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
||||
);
|
||||
flightState._trackedIcao = null;
|
||||
flightState.records.data.set('civil', { emergency: null });
|
||||
assert.equal(rendering._modelColor('civil'), normal);
|
||||
// An ordinary model draws its mesh's own colours, whatever the billboard's.
|
||||
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
@@ -6,12 +8,24 @@
|
||||
* 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
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* 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.
|
||||
* lighting. Without it they 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({
|
||||
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
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* changing the model's colour blend mode or amount.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||
|
||||
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
||||
_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,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
|
||||
assert.equal(row.length, 12);
|
||||
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);
|
||||
});
|
||||
+157
-23
@@ -11,6 +11,9 @@
|
||||
* 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.
|
||||
*
|
||||
* 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`
|
||||
* fetches the JSON and every GLB once, up front, so no model is fetched
|
||||
* mid-session.
|
||||
@@ -18,6 +21,7 @@
|
||||
|
||||
import { classifyAircraft } from './aircraftClass.js';
|
||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||
import { isAidToNavigation } from './vesselTypes.js';
|
||||
|
||||
/** Where the model set is served, relative to the application base. */
|
||||
export const FLEET_MODEL_BASE = '/models/fleet/';
|
||||
@@ -85,16 +89,62 @@ const TEXT_SHIP_TYPES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
|
||||
* emitter category `category`: the type's, else the category's, else
|
||||
* `default_aircraft`.
|
||||
* The buoy an aid to navigation is drawn as, by the mark its type names
|
||||
* ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
|
||||
* that matches. Beacons, lights, RACONs and reference points with no mark of
|
||||
* 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 {string} [type]
|
||||
* @param {string} [category]
|
||||
* @param {string} [callsign]
|
||||
* @returns {{model: string, length: number|null}}
|
||||
*/
|
||||
export function resolveAircraft(types, type, category) {
|
||||
export function resolveAircraft(types, type, category, callsign) {
|
||||
const t = type?.trim().toUpperCase();
|
||||
const index = aircraftCallsignRule(types, t, callsign);
|
||||
if (index >= 0) {
|
||||
const rule = types.aircraft_callsign[t][index];
|
||||
return { model: rule.model, length: rule.length };
|
||||
}
|
||||
const [model, length] =
|
||||
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
|
||||
(category && types.categories[category]) ||
|
||||
@@ -102,6 +152,51 @@ export function resolveAircraft(types, type, category) {
|
||||
return { model, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A callsign as the rules read it: upper case, spaces removed.
|
||||
* @param {string} callsign
|
||||
* @returns {string}
|
||||
*/
|
||||
function callsignKey(callsign) {
|
||||
return String(callsign).toUpperCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of type `type`'s callsign rules (`types.json`'s
|
||||
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
|
||||
* wins) `callsign` matches: its index, -1 when none does or the type has
|
||||
* none. A callsign matches a rule when, upper-cased with spaces removed, it
|
||||
* starts with one of the rule's prefixes.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type] ICAO type.
|
||||
* @param {string|null} [callsign]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function aircraftCallsignRule(types, type, callsign) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const rules = t ? types.aircraft_callsign?.[t] : undefined;
|
||||
if (!rules || !callsign) return -1;
|
||||
const c = callsignKey(callsign);
|
||||
return rules.findIndex((rule) =>
|
||||
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft of ICAO type `type` picks its model by callsign.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function aircraftModelByCallsign(types, type) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return Boolean(t && types.aircraft_callsign?.[t]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reported length as one a hull can have, else undefined (drawn as built).
|
||||
* @param {number} [length]
|
||||
@@ -133,8 +228,9 @@ export function hullBeam(beam) {
|
||||
/**
|
||||
* The model for a vessel of AIS ship type `shipType` and the reported length
|
||||
* and beam (metres) and name: the first rule that matches in the first type
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length, as
|
||||
* built when it is unknown.
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length;
|
||||
* when it is unknown (most live feeds send none), chosen and drawn at the
|
||||
* range's `typical_len`, else as built.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {number} [shipType]
|
||||
* @param {number} [length]
|
||||
@@ -145,13 +241,14 @@ export function hullBeam(beam) {
|
||||
export function resolveVessel(types, shipType, length, beam, name) {
|
||||
const L = hullLength(length) ?? 0;
|
||||
const B = hullBeam(beam) ?? 0;
|
||||
const drawn = L > 0 ? L : null;
|
||||
if (shipType !== undefined && shipType !== null) {
|
||||
for (const group of types.vessels) {
|
||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
||||
const len = L > 0 ? L : (group.typical_len ?? 0);
|
||||
const drawn = len > 0 ? len : null;
|
||||
for (const rule of group.rules) {
|
||||
if (rule.min_len && L < rule.min_len) continue;
|
||||
if (rule.max_len && L > rule.max_len) continue;
|
||||
if (rule.min_len && len < rule.min_len) continue;
|
||||
if (rule.max_len && len > rule.max_len) continue;
|
||||
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
||||
continue;
|
||||
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
||||
@@ -159,7 +256,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { model: types.default_vessel, length: drawn };
|
||||
return { model: types.default_vessel, length: L > 0 ? L : null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,24 +401,32 @@ function specOf(set, resolved, fallback, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The model an aircraft is drawn with. The type's entry first; then, for a
|
||||
* The model an aircraft is drawn with. The type's callsign rules (an airport
|
||||
* vehicle by its callsign), then the type's entry; then, for a
|
||||
* type the map lacks, the model of its class (a fast jet, a helicopter, a
|
||||
* glider) when the type names one; then the emitter category's; then
|
||||
* `default_aircraft`.
|
||||
* @param {{catalog: object, types: object}} set
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null}} aircraft
|
||||
* category?: string|number|null, callsign?: string|null}} aircraft
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
export function aircraftModelSpec(
|
||||
set,
|
||||
{ typeCode, type, category, callsign } = {},
|
||||
) {
|
||||
const { types, catalog } = set;
|
||||
const code = String(typeCode ?? type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const emitter = emitterCategory(category);
|
||||
let resolved = resolveAircraft(types, code, emitter);
|
||||
const sign = callsign ? String(callsign) : undefined;
|
||||
let resolved = resolveAircraft(types, code, emitter, sign);
|
||||
// A callsign rule names the model outright; a type's class only stands in
|
||||
// for a type the map lacks.
|
||||
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
|
||||
const typed = code && (types.aircraft[code] || types.helicopters[code]);
|
||||
if (!typed && code) {
|
||||
if (!ruled && !typed && code) {
|
||||
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
|
||||
if (model && catalog[model])
|
||||
resolved = { model, length: catalog[model].length };
|
||||
@@ -334,25 +439,49 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* family), reported length and beam (metres) and name. An aid to navigation
|
||||
* ({@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 {{type?: string|number|null, length?: number|null,
|
||||
* beam?: number|null, name?: string|null}} vessel
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||
* virtual?: boolean, length?: number|null, beam?: number|null,
|
||||
* name?: string|null}} vessel
|
||||
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
|
||||
* null for a virtual aid
|
||||
*/
|
||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
||||
export function vesselModelSpec(
|
||||
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(
|
||||
set.types,
|
||||
aisShipTypeCode(type),
|
||||
length ?? undefined,
|
||||
beam ?? undefined,
|
||||
reported,
|
||||
hullWidth,
|
||||
name ?? undefined,
|
||||
);
|
||||
return specOf(
|
||||
set,
|
||||
resolved,
|
||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||
{ beam: beam ?? undefined },
|
||||
{ beam: hullWidth },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,6 +510,9 @@ export function namedFleetModels(types) {
|
||||
...pairs(types.aircraft),
|
||||
...pairs(types.helicopters),
|
||||
...pairs(types.categories),
|
||||
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
|
||||
rules.map((rule) => rule.model),
|
||||
),
|
||||
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
|
||||
types.default_aircraft[0],
|
||||
types.default_vessel,
|
||||
@@ -390,6 +522,8 @@ export function namedFleetModels(types) {
|
||||
transit.default[0],
|
||||
...Object.keys(transit.elevation),
|
||||
...Object.values(CLASS_MODELS),
|
||||
...AID_MODELS.map(([, model]) => model),
|
||||
DEFAULT_AID_MODEL,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
DEFAULT_AID_MODEL,
|
||||
FLEET_MODEL_BASE,
|
||||
aidToNavigationModel,
|
||||
aircraftModelSpec,
|
||||
aisShipTypeCode,
|
||||
emitterCategory,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
vesselModelSpec,
|
||||
} from './fleetModels.js';
|
||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||
import { TRANSIT_MODES } from './transitFeeds.js';
|
||||
import {
|
||||
centrelineSegments,
|
||||
distanceToSurface,
|
||||
@@ -51,12 +54,22 @@ const sliceTypes = {
|
||||
A21N: ['a320', 44.51],
|
||||
A319: ['a320', 33.84],
|
||||
B38M: ['b738', 39.52],
|
||||
ZVEH: ['car', 4.61],
|
||||
GRND: ['radio_mast', 1.9],
|
||||
},
|
||||
helicopters: { EC35: ['ec35', 10.2] },
|
||||
aircraft_callsign: {
|
||||
ZVEH: [
|
||||
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
|
||||
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
|
||||
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
|
||||
],
|
||||
},
|
||||
categories: { A1: ['c172', 8.3] },
|
||||
vessels: [
|
||||
{
|
||||
types: [70, 79],
|
||||
typical_len: 300,
|
||||
rules: [
|
||||
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
||||
{
|
||||
@@ -111,6 +124,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
|
||||
});
|
||||
});
|
||||
|
||||
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
|
||||
const zveh = (callsign) =>
|
||||
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
|
||||
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
|
||||
model: 'fire_truck',
|
||||
length: 11.01,
|
||||
});
|
||||
assert.equal(zveh('PUSH 14').model, 'pushback');
|
||||
assert.equal(zveh('TUG3').model, 'pushback');
|
||||
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
|
||||
assert.equal(zveh('SQUID20').model, 'van');
|
||||
assert.equal(zveh('SQB9').model, 'van');
|
||||
assert.equal(zveh('S Q B 9').model, 'van');
|
||||
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
|
||||
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
|
||||
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
|
||||
assert.equal(
|
||||
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
|
||||
'a320',
|
||||
);
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
|
||||
model: 'radio_mast',
|
||||
length: 1.9,
|
||||
});
|
||||
});
|
||||
|
||||
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
|
||||
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
|
||||
assert.equal(
|
||||
@@ -133,6 +173,29 @@ test('vessels: the first matching rule of the first type range with one, else th
|
||||
});
|
||||
});
|
||||
|
||||
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
|
||||
model: 'general_cargo',
|
||||
length: 90,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 31), {
|
||||
model: 'tug',
|
||||
length: null,
|
||||
});
|
||||
// Every passenger, cargo and tanker range of the set has one.
|
||||
for (const code of [60, 70, 80]) {
|
||||
assert.ok(resolveVessel(types, code).length > 100, String(code));
|
||||
}
|
||||
});
|
||||
|
||||
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
|
||||
model: 'tug',
|
||||
@@ -201,6 +264,46 @@ test('transit bodies mark the share of the route colour they take; other models
|
||||
}
|
||||
});
|
||||
|
||||
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
|
||||
const zveh = (callsign) =>
|
||||
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
|
||||
.model;
|
||||
assert.equal(zveh('FIRE 2'), 'fire_truck');
|
||||
assert.equal(zveh('PUSHBACK1'), 'pushback');
|
||||
assert.equal(zveh('TUG 4'), 'pushback');
|
||||
assert.equal(zveh('DEICER 4'), 'van');
|
||||
assert.equal(zveh('SQUID20'), 'van');
|
||||
assert.equal(zveh('SQB9'), 'van');
|
||||
assert.equal(zveh('LEADER 7'), 'car');
|
||||
assert.equal(zveh('AOPS'), 'car');
|
||||
assert.equal(zveh(null), 'car');
|
||||
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
|
||||
assert.equal(mast.model, 'radio_mast');
|
||||
assert.equal(mast.scale, 1);
|
||||
});
|
||||
|
||||
test('a callsign rule outranks the class model of a type the map lacks', () => {
|
||||
const ruled = {
|
||||
...set,
|
||||
types: {
|
||||
...set.types,
|
||||
aircraft_callsign: {
|
||||
...set.types.aircraft_callsign,
|
||||
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
|
||||
'fire_truck',
|
||||
);
|
||||
// No rule matched: the class model, as before.
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
|
||||
'generic_heli',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
|
||||
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
|
||||
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
|
||||
@@ -267,6 +370,12 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
||||
const [sx, sy, sz] = box.scaleXYZ;
|
||||
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
||||
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
|
||||
// A beam with no reported length: the type's typical length, in the model's
|
||||
// own proportions, not a typical-length hull squeezed to that beam.
|
||||
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
|
||||
assert.equal(unsized.model, 'container_ship');
|
||||
const [ux, uy, uz] = unsized.scaleXYZ;
|
||||
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
|
||||
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
|
||||
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
||||
// No type, no size: the generic vessel as built.
|
||||
@@ -275,6 +384,99 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
||||
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('every transit mode but unknown draws its own model, and a route type a finer one', () => {
|
||||
for (const mode of TRANSIT_MODES.filter((m) => m !== 'unknown')) {
|
||||
assert.ok(types.transit.modes[mode], `${mode} has a model`);
|
||||
assert.equal(
|
||||
resolveTransit(types, { mode }).model,
|
||||
types.transit.modes[mode][0],
|
||||
);
|
||||
}
|
||||
for (const [mode, model] of [
|
||||
['trolleybus', 'trolleybus'],
|
||||
['cable_tram', 'cable_tram'],
|
||||
['aerial_lift', 'gondola'],
|
||||
['funicular', 'funicular'],
|
||||
['monorail', 'monorail'],
|
||||
])
|
||||
assert.equal(resolveTransit(types, { mode }).model, model, mode);
|
||||
// A route type outranks the mode: a coach and a high-speed set are still
|
||||
// the bus and rail modes.
|
||||
for (const [routeType, mode, model] of [
|
||||
[200, 'bus', 'coach'],
|
||||
[101, 'rail', 'high_speed'],
|
||||
[106, 'rail', 'regional_dmu'],
|
||||
[710, 'bus', 'double_decker'],
|
||||
])
|
||||
assert.equal(resolveTransit(types, { routeType, mode }).model, model);
|
||||
// A type the table lacks falls back to the mode.
|
||||
assert.equal(
|
||||
resolveTransit(types, { routeType: 300, mode: 'rail' }).model,
|
||||
'commuter_emu',
|
||||
);
|
||||
});
|
||||
|
||||
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
||||
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
||||
model: 'intercity',
|
||||
|
||||
@@ -32,7 +32,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
c172: Object.freeze([4.0507, 0.4481, 0]),
|
||||
c30j: Object.freeze([13.568, 0.9917, 0]),
|
||||
c56x: Object.freeze([7.4627, 0.4526, 0]),
|
||||
car: Object.freeze([2.3, 0.418, 0]),
|
||||
car: Object.freeze([2.1851, 0.301, 0]),
|
||||
cl35: Object.freeze([10.4607, 0.8247, 0]),
|
||||
crj9: Object.freeze([17.8413, 0.8624, 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]),
|
||||
ec35: Object.freeze([5.1003, 0.1471, 0]),
|
||||
ec45: Object.freeze([5.8004, 0.555, 0]),
|
||||
fire_truck: Object.freeze([5.5, 0.6051, 0]),
|
||||
fire_truck: Object.freeze([5.39, 0.4227, 0]),
|
||||
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
|
||||
generic_plane: Object.freeze([16.5228, 0.712, 0]),
|
||||
glf4: Object.freeze([13.4603, 0.8243, 0]),
|
||||
@@ -53,8 +53,9 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
mq9: Object.freeze([5.8498, -0.062, 0]),
|
||||
p28a: Object.freeze([3.3695, 0.3959, 0]),
|
||||
pc12: Object.freeze([6.6416, 0.5288, 0]),
|
||||
pushback: Object.freeze([3, 0.2499, 0]),
|
||||
pushback: Object.freeze([2.9536, 0.388, 0]),
|
||||
r44: Object.freeze([4.4219, -0.3001, 0]),
|
||||
radio_mast: Object.freeze([1.2, 0, 0]),
|
||||
s76: Object.freeze([6.6001, 0.1915, 0]),
|
||||
sr22: Object.freeze([3.6762, 0.2376, 0]),
|
||||
t38: Object.freeze([7.0246, -0.3791, 0]),
|
||||
@@ -62,5 +63,5 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
tower: Object.freeze([1.2952, -0.0007, 0]),
|
||||
ultralight: Object.freeze([3.2271, 0.3259, 0]),
|
||||
v22: Object.freeze([7.9868, 0.2956, 0]),
|
||||
van: Object.freeze([2.75, 0.418, 0]),
|
||||
van: Object.freeze([2.695, 0.2812, 0]),
|
||||
});
|
||||
@@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf
|
||||
);
|
||||
// A grounded aircraft stands on its wheels: its origin the scaled
|
||||
// clearance above the ground, never below it.
|
||||
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
// A model built standing on its origin (a ground vehicle, a ground
|
||||
// station's mast: its lowest point at z 0) has no clearance.
|
||||
if (set.catalog[model].min[2] > -0.05)
|
||||
assert.ok(
|
||||
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
|
||||
`${type}: built standing, clearance ${spec.clearanceM}`,
|
||||
);
|
||||
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
|
||||
}
|
||||
}
|
||||
@@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy
|
||||
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
|
||||
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
|
||||
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
|
||||
'../layers/flights/modelSpec.js'
|
||||
);
|
||||
const tender = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
|
||||
set,
|
||||
);
|
||||
const car = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
|
||||
set,
|
||||
);
|
||||
assert.equal(tender.model, 'fire_truck');
|
||||
assert.equal(car.model, 'car');
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
|
||||
);
|
||||
assert.notEqual(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
);
|
||||
// Keyed by the rule matched, not the callsign: at most one entry per rule
|
||||
// and one for the type's own model.
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
tender,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* A host-supplied placement provider: an application embedding the catalog
|
||||
* may decide where its moving contacts are drawn while live. Each layer that
|
||||
* takes one tells it every fix it accepts (`observe`) and asks it, each time
|
||||
* it draws a contact, where to draw it now (`place`). A layer given none, or
|
||||
* asking about a contact the provider does not place (`null`), draws the
|
||||
* contact with its own motion, exactly as without a provider. A provider
|
||||
* that throws is treated as having no answer for that call.
|
||||
*
|
||||
* Layers consult a provider only while live: following the world clock, the
|
||||
* layer's own motion places every contact.
|
||||
*
|
||||
* @typedef {'aircraft'|'military'|'vessels'|'transit'|'rail'} PlacementKind
|
||||
*
|
||||
* @typedef {object} PlacementFix
|
||||
* @property {number} timeMs The fix's own time, Unix ms.
|
||||
* @property {number} lat
|
||||
* @property {number} lon
|
||||
* @property {number} [altitudeM] The height the layer draws the fix at,
|
||||
* metres above the ellipsoid; NaN when the layer keeps its own height.
|
||||
* @property {number} [speedMps] Reported ground speed, NaN for none.
|
||||
* @property {number} [courseDeg] Reported course over ground, NaN for none.
|
||||
* @property {number} [headingDeg] Reported heading, NaN for none.
|
||||
* @property {boolean} [onGround] Reported on the ground (aircraft).
|
||||
* @property {string} [feedId] The feed the contact belongs to (transit, rail).
|
||||
*
|
||||
* @typedef {object} Placement
|
||||
* @property {number} lat
|
||||
* @property {number} lon
|
||||
* @property {number} altitudeM Metres above the ellipsoid, as the observed
|
||||
* fixes' `altitudeM`; NaN keeps the layer's own height.
|
||||
* @property {number} courseDeg Direction of travel, NaN for none.
|
||||
* @property {number} speedMps NaN for none.
|
||||
* @property {boolean} extrapolating Past the contact's newest fix.
|
||||
*
|
||||
* @typedef {object} LayerPlacement What a layer is handed: never throws.
|
||||
* @property {(id: string, fix: PlacementFix) => void} observe
|
||||
* @property {(id: string, wallMs: number) => Placement|null} place The
|
||||
* answer is one object reused by every call: read it before the next.
|
||||
* @property {(id: string) => void} forget The contact left the layer.
|
||||
*
|
||||
* @typedef {object} PlacementProvider
|
||||
* @property {(kind: PlacementKind) => object|null|undefined} layer The
|
||||
* placement for one kind of contact: an object with `place(id, wallMs)`
|
||||
* and optionally `observe(id, fix)` and `forget(id)`, or null to leave that
|
||||
* kind to its layer's own motion.
|
||||
*/
|
||||
|
||||
/** The kinds of contact a provider may place. */
|
||||
export const PLACEMENT_KINDS = Object.freeze([
|
||||
'aircraft',
|
||||
'military',
|
||||
'vessels',
|
||||
'transit',
|
||||
'rail',
|
||||
]);
|
||||
|
||||
const finiteOr = (value, fallback) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
/**
|
||||
* One kind's placement from a provider, guarded so a layer can call it every
|
||||
* frame: null when there is no provider or it does not place `kind`.
|
||||
* @param {PlacementProvider|null|undefined} provider
|
||||
* @param {PlacementKind} kind
|
||||
* @returns {LayerPlacement|null}
|
||||
*/
|
||||
export function layerPlacement(provider, kind) {
|
||||
if (!PLACEMENT_KINDS.includes(kind))
|
||||
throw new TypeError(`Unknown placement kind: ${kind}`);
|
||||
if (provider == null) return null;
|
||||
if (typeof provider.layer !== 'function')
|
||||
throw new TypeError('A placement provider needs a layer(kind) function');
|
||||
const placement = provider.layer(kind);
|
||||
if (placement == null) return null;
|
||||
if (typeof placement.place !== 'function')
|
||||
throw new TypeError(`The ${kind} placement needs a place(id, wallMs)`);
|
||||
const observe =
|
||||
typeof placement.observe === 'function' ? placement.observe : null;
|
||||
const forget =
|
||||
typeof placement.forget === 'function' ? placement.forget : null;
|
||||
// Asked for every drawn contact every frame: one answer, reused.
|
||||
const answer = {
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
altitudeM: NaN,
|
||||
courseDeg: NaN,
|
||||
speedMps: NaN,
|
||||
extrapolating: false,
|
||||
};
|
||||
return Object.freeze({
|
||||
observe(id, fix) {
|
||||
if (!observe) return;
|
||||
try {
|
||||
observe.call(placement, id, fix);
|
||||
} catch {
|
||||
// A provider that fails to take a fix only answers less.
|
||||
}
|
||||
},
|
||||
place(id, wallMs) {
|
||||
let placed;
|
||||
try {
|
||||
placed = placement.place(id, wallMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!placed ||
|
||||
!Number.isFinite(placed.lat) ||
|
||||
!Number.isFinite(placed.lon) ||
|
||||
Math.abs(placed.lat) > 90
|
||||
)
|
||||
return null;
|
||||
answer.lat = placed.lat;
|
||||
answer.lon = placed.lon;
|
||||
answer.altitudeM = finiteOr(placed.altitudeM, NaN);
|
||||
answer.courseDeg = finiteOr(placed.courseDeg, NaN);
|
||||
answer.speedMps = finiteOr(placed.speedMps, NaN);
|
||||
answer.extrapolating = placed.extrapolating === true;
|
||||
return answer;
|
||||
},
|
||||
forget(id) {
|
||||
if (!forget) return;
|
||||
try {
|
||||
forget.call(placement, id);
|
||||
} catch {
|
||||
// Nothing to undo.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PLACEMENT_KINDS, layerPlacement } from './placementProvider.js';
|
||||
|
||||
test('no provider, or a provider that leaves a kind alone, gives no placement', () => {
|
||||
for (const kind of PLACEMENT_KINDS) {
|
||||
assert.equal(layerPlacement(null, kind), null);
|
||||
assert.equal(layerPlacement(undefined, kind), null);
|
||||
assert.equal(layerPlacement({ layer: () => null }, kind), null);
|
||||
}
|
||||
assert.throws(() => layerPlacement(null, 'satellites'), TypeError);
|
||||
assert.throws(() => layerPlacement({}, 'aircraft'), TypeError);
|
||||
assert.throws(
|
||||
() => layerPlacement({ layer: () => ({ observe() {} }) }, 'aircraft'),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('a placement is asked by id and wall time, and its answer is checked', () => {
|
||||
const asked = [];
|
||||
const seen = [];
|
||||
const forgotten = [];
|
||||
const answers = new Map([
|
||||
[
|
||||
'a',
|
||||
{ lat: 51.5, lon: -0.1, altitudeM: 900, courseDeg: 270, speedMps: 80 },
|
||||
],
|
||||
['partial', { lat: 10, lon: 20, altitudeM: 'high', extrapolating: true }],
|
||||
['off-globe', { lat: 91, lon: 0 }],
|
||||
['nan', { lat: NaN, lon: 0 }],
|
||||
]);
|
||||
const provider = {
|
||||
layer: (kind) =>
|
||||
kind === 'aircraft'
|
||||
? {
|
||||
observe: (id, fix) => seen.push([id, fix.timeMs]),
|
||||
place(id, wallMs) {
|
||||
asked.push([id, wallMs]);
|
||||
return answers.get(id) ?? null;
|
||||
},
|
||||
forget: (id) => forgotten.push(id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
assert.equal(layerPlacement(provider, 'vessels'), null);
|
||||
const placement = layerPlacement(provider, 'aircraft');
|
||||
placement.observe('a', { timeMs: 5, lat: 0, lon: 0 });
|
||||
assert.deepEqual(seen, [['a', 5]]);
|
||||
const placed = placement.place('a', 1000);
|
||||
assert.deepEqual(
|
||||
{ ...placed },
|
||||
{
|
||||
lat: 51.5,
|
||||
lon: -0.1,
|
||||
altitudeM: 900,
|
||||
courseDeg: 270,
|
||||
speedMps: 80,
|
||||
extrapolating: false,
|
||||
},
|
||||
);
|
||||
const partial = placement.place('partial', 2000);
|
||||
assert.equal(partial, placed, 'one answer object, reused');
|
||||
assert.ok(Number.isNaN(partial.altitudeM));
|
||||
assert.ok(Number.isNaN(partial.courseDeg));
|
||||
assert.ok(Number.isNaN(partial.speedMps));
|
||||
assert.equal(partial.extrapolating, true);
|
||||
assert.equal(placement.place('off-globe', 3000), null);
|
||||
assert.equal(placement.place('nan', 3000), null);
|
||||
assert.equal(placement.place('unknown', 3000), null);
|
||||
assert.deepEqual(
|
||||
asked.map(([id]) => id),
|
||||
['a', 'partial', 'off-globe', 'nan', 'unknown'],
|
||||
);
|
||||
placement.forget('a');
|
||||
assert.deepEqual(forgotten, ['a']);
|
||||
});
|
||||
|
||||
test('a provider that throws has no answer, and observe and forget are optional', () => {
|
||||
const placement = layerPlacement(
|
||||
{
|
||||
layer: () => ({
|
||||
place() {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
}),
|
||||
},
|
||||
'transit',
|
||||
);
|
||||
assert.equal(placement.place('x', 0), null);
|
||||
assert.doesNotThrow(() => placement.observe('x', { timeMs: 0 }));
|
||||
assert.doesNotThrow(() => placement.forget('x'));
|
||||
const failing = layerPlacement(
|
||||
{
|
||||
layer: () => ({
|
||||
place: () => null,
|
||||
observe() {
|
||||
throw new Error('full');
|
||||
},
|
||||
forget() {
|
||||
throw new Error('gone');
|
||||
},
|
||||
}),
|
||||
},
|
||||
'rail',
|
||||
);
|
||||
assert.doesNotThrow(() => failing.observe('x', { timeMs: 0 }));
|
||||
assert.doesNotThrow(() => failing.forget('x'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
||||
test('civilian and military trail heads use the lower-centre model anchor', async () => {
|
||||
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
||||
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
||||
)));
|
||||
@@ -300,7 +300,5 @@ test('civilian and military trail heads use the lower-centre model anchor and we
|
||||
(/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`,
|
||||
);
|
||||
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
||||
`${name} keeps diffuse texture contribution weak through code-side MIX`);
|
||||
}
|
||||
});
|
||||
+239
-16
@@ -29,12 +29,21 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
|
||||
* node:test. No Cesium, no Node built-ins.
|
||||
*/
|
||||
|
||||
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
|
||||
/**
|
||||
* Transit modes the layer colors: the GTFS basic route types, by name. A
|
||||
* vehicle's GTFS `route_type` settles its mode when its record carries one;
|
||||
* otherwise `routeMode` hints refine a feed's default.
|
||||
*/
|
||||
export const TRANSIT_MODES = Object.freeze([
|
||||
'bus',
|
||||
'trolleybus',
|
||||
'tram',
|
||||
'cable_tram',
|
||||
'subway',
|
||||
'monorail',
|
||||
'rail',
|
||||
'funicular',
|
||||
'aerial_lift',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
@@ -42,13 +51,81 @@ export const TRANSIT_MODES = Object.freeze([
|
||||
/** Per-mode icon used in labels and the selection card. */
|
||||
export const TRANSIT_MODE_ICON = Object.freeze({
|
||||
bus: '🚌',
|
||||
trolleybus: '🚎',
|
||||
tram: '🚊',
|
||||
cable_tram: '🚋',
|
||||
subway: '🚇',
|
||||
monorail: '🚝',
|
||||
rail: '🚆',
|
||||
funicular: '🚞',
|
||||
aerial_lift: '🚡',
|
||||
ferry: '⛴️',
|
||||
unknown: '🚏',
|
||||
});
|
||||
|
||||
/** The basic GTFS route types (0–7, 11, 12) by mode. */
|
||||
const BASIC_ROUTE_TYPE_MODES = Object.freeze({
|
||||
0: 'tram',
|
||||
1: 'subway',
|
||||
2: 'rail',
|
||||
3: 'bus',
|
||||
4: 'ferry',
|
||||
5: 'cable_tram',
|
||||
6: 'aerial_lift',
|
||||
7: 'funicular',
|
||||
11: 'trolleybus',
|
||||
12: 'monorail',
|
||||
});
|
||||
|
||||
/**
|
||||
* A GTFS `route_type` as an integer: a basic type (0–12) or an extended one
|
||||
* (100–1799), as a number or its decimal text. Null for anything else.
|
||||
* @param {unknown} value
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function transitRouteType(value) {
|
||||
const n =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^\d{1,4}$/.test(value.trim())
|
||||
? Number(value.trim())
|
||||
: NaN;
|
||||
return Number.isInteger(n) && n >= 0 && n < 1800 ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode of a GTFS `route_type`: the basic types, and the extended ones
|
||||
* mapped to the basic mode they are a kind of (a coach, 200, is a bus; a
|
||||
* high-speed train, 101, is rail; 405 is a monorail). Air, taxi and other
|
||||
* services, and a value GTFS does not define, are `unknown`.
|
||||
* @param {unknown} routeType
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeOfRouteType(routeType) {
|
||||
const n = transitRouteType(routeType);
|
||||
if (n === null) return 'unknown';
|
||||
if (n < 100) return BASIC_ROUTE_TYPE_MODES[n] ?? 'unknown';
|
||||
if (n === 405) return 'monorail';
|
||||
const family = Math.floor(n / 100);
|
||||
return (
|
||||
{
|
||||
1: 'rail', // railway
|
||||
2: 'bus', // coach
|
||||
3: 'rail', // suburban railway
|
||||
4: 'subway', // urban railway
|
||||
5: 'subway', // metro
|
||||
6: 'subway', // underground
|
||||
7: 'bus',
|
||||
8: 'trolleybus',
|
||||
9: 'tram',
|
||||
10: 'ferry', // water transport
|
||||
12: 'ferry', // ferry
|
||||
13: 'aerial_lift',
|
||||
14: 'funicular',
|
||||
}[family] ?? 'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
|
||||
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
|
||||
@@ -443,7 +520,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
|
||||
function liaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const line = liaRouteLabel(routeId);
|
||||
if (line === 'FUNI') return 'rail';
|
||||
if (line === 'FUNI') return 'funicular';
|
||||
return /^[AB]$/.test(line) ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
@@ -470,10 +547,14 @@ function astuceRouteLabel(routeId) {
|
||||
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
|
||||
}
|
||||
|
||||
/** PID trolleybus lines: route_type 11 in PID's routes.txt (checked 2026-11-14). */
|
||||
const PID_TROLLEYBUS_LINES = new Set([51, 52, 53, 58, 59, 232, 275]);
|
||||
|
||||
/**
|
||||
* Prague PID route ids are `L` plus a number whose range says what runs it,
|
||||
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
|
||||
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
|
||||
* trolleybus routes 232 and 275 among them, GTFS route type 11; the
|
||||
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
|
||||
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
|
||||
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
|
||||
@@ -487,7 +568,9 @@ function pidRoute(routeId) {
|
||||
if (!match) return { mode: 'unknown', label: routeId };
|
||||
const n = Number(match[1]);
|
||||
// i18n-ignore: line code, as signed
|
||||
if (n === 49) return { mode: 'rail', label: 'LD' };
|
||||
if (n === 49) return { mode: 'funicular', label: 'LD' };
|
||||
if (PID_TROLLEYBUS_LINES.has(n))
|
||||
return { mode: 'trolleybus', label: String(n) };
|
||||
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
|
||||
if (n < 100) return { mode: 'tram', label: String(n) };
|
||||
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
|
||||
@@ -512,6 +595,102 @@ function pidRouteLabel(routeId) {
|
||||
return pidRoute(routeId).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* A feed's route hint from its static GTFS routes.txt: the route ids of
|
||||
* every mode but `bus`, which is everything else.
|
||||
* @param {Record<string, string[]>} idsByMode
|
||||
* @returns {(routeId: string|null) => string}
|
||||
*/
|
||||
function routeModeTable(idsByMode) {
|
||||
const modes = new Map(
|
||||
Object.entries(idsByMode).flatMap(([mode, ids]) =>
|
||||
ids.map((id) => [id, mode]),
|
||||
),
|
||||
);
|
||||
return (routeId) => (routeId ? (modes.get(routeId) ?? 'bus') : 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucharest TPBI by route id, from its routes.txt (checked 2026-11-14): the
|
||||
* trams (route type 0), metro lines M1–M5 (1) and trolleybuses (11); every
|
||||
* other route is a bus. Realtime route ids are the static ones.
|
||||
*/
|
||||
const tpbiRouteMode = routeModeTable({
|
||||
tram: [
|
||||
'54',
|
||||
'55',
|
||||
'57',
|
||||
'61',
|
||||
'63',
|
||||
'65',
|
||||
'66',
|
||||
'67',
|
||||
'68',
|
||||
'69',
|
||||
'70',
|
||||
'814',
|
||||
'915',
|
||||
'1013',
|
||||
],
|
||||
subway: ['968', '969', '970', '971', '999'],
|
||||
trolleybus: [
|
||||
'72',
|
||||
'73',
|
||||
'74',
|
||||
'75',
|
||||
'76',
|
||||
'78',
|
||||
'81',
|
||||
'83',
|
||||
'84',
|
||||
'85',
|
||||
'86',
|
||||
'87',
|
||||
'247',
|
||||
'910',
|
||||
'916',
|
||||
'924',
|
||||
'925',
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Cluj-Napoca CTP by route id, from its routes.txt (checked 2026-11-14): the
|
||||
* trams 100–102L (route type 0) and the trolleybuses (11).
|
||||
*/
|
||||
const ctpClujRouteMode = routeModeTable({
|
||||
tram: ['2', '3', '4', '5'],
|
||||
trolleybus: [
|
||||
'1',
|
||||
'10',
|
||||
'14',
|
||||
'15',
|
||||
'22',
|
||||
'36',
|
||||
'49',
|
||||
'53',
|
||||
'54',
|
||||
'55',
|
||||
'117',
|
||||
'119',
|
||||
'120',
|
||||
'153',
|
||||
'166',
|
||||
'187',
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Turin GTT by route id, from its routes.txt (checked 2026-11-14): the trams
|
||||
* (route type 0), metro M1, and the Sassi–Superga rack tramway `79U`, which
|
||||
* the file types as a funicular (7).
|
||||
*/
|
||||
const gttRouteMode = routeModeTable({
|
||||
tram: ['3U', '4U', '7U', '9U', '10U', '13U', '15U', '16CDU', '16CSU'],
|
||||
subway: ['METROU'],
|
||||
funicular: ['79U'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
|
||||
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
|
||||
@@ -571,6 +750,7 @@ function transitFeed(fields) {
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* routeCorrections?: Readonly<Record<string, string>>,
|
||||
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
|
||||
* creditDated?: boolean,
|
||||
* }>>}
|
||||
@@ -579,6 +759,9 @@ function transitFeed(fields) {
|
||||
* stretches the proxy's cache for an upstream that rate-limits below one
|
||||
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
|
||||
* to the credit for licences that ask for them (`transitFeedCredit`).
|
||||
* `routeCorrections` names the routes whose `route_type` the operator's own
|
||||
* routes.txt gets wrong, `route_id` to the mode they really are; a correction
|
||||
* outranks the route type, which would otherwise outrank the route hint.
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
@@ -1264,6 +1447,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeMode: tpbiRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'roma-mobilita',
|
||||
@@ -1296,6 +1480,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeMode: gttRouteMode,
|
||||
routeLabel: gttRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
@@ -1401,6 +1586,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeMode: ctpClujRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ctp-iasi',
|
||||
@@ -1567,6 +1753,8 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
defaultMode: 'bus',
|
||||
routeLabel: liaRouteLabel,
|
||||
routeMode: liaRouteMode,
|
||||
// LiA's routes.txt types the funicular (`8-FUNI`) as a bus (3).
|
||||
routeCorrections: Object.freeze({ '8-FUNI': 'funicular' }),
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'filbleu-tours',
|
||||
@@ -1664,54 +1852,89 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed's own hint for a vehicle: its fleet number when the feed maps
|
||||
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
|
||||
* What settles a vehicle's mode, most specific first: the feed's fleet hint
|
||||
* (`vehicleMode`, which knows a bus standing in on a tram route), else the
|
||||
* feed's correction for a route its routes.txt mistypes (`routeCorrections`),
|
||||
* else the GTFS `route_type` of its route when the record carries one, else
|
||||
* the feed's route hint (`routeMode`). Null when none of them knows.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} vehicleId
|
||||
* @param {unknown} routeType GTFS `route_type`, when the record has one.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hintedMode(feed, routeId, vehicleId) {
|
||||
function hintedMode(feed, routeId, vehicleId, routeType) {
|
||||
const byVehicle =
|
||||
typeof feed?.vehicleMode === 'function'
|
||||
? feed.vehicleMode(vehicleId)
|
||||
: null;
|
||||
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
|
||||
const corrections = feed?.routeCorrections;
|
||||
if (routeId && corrections && Object.hasOwn(corrections, routeId))
|
||||
return corrections[routeId];
|
||||
const byType = transitModeOfRouteType(routeType);
|
||||
if (byType !== 'unknown') return byType;
|
||||
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
|
||||
* its default.
|
||||
* Mode for a vehicle: its fleet hint, route type or route hint when one
|
||||
* knows ({@link hintedMode}), else its feed's default.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||||
* @param {string|null} [vehicleId] GTFS-RT vehicle id.
|
||||
* @param {unknown} [routeType] GTFS `route_type` of the route, when known.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
export function transitModeFor(
|
||||
feed,
|
||||
routeId,
|
||||
vehicleId = null,
|
||||
routeType = null,
|
||||
) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the mode came from the route id rather than the feed's default. A
|
||||
* default is a guess about a fleet — TransLink's is "bus" while it publishes
|
||||
* rail in the same feed — so a consumer judging physical plausibility must
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* Whether the mode came from the vehicle, its route type or its route id
|
||||
* rather than the feed's default. A default is a guess about a fleet —
|
||||
* TransLink's is "bus" while it publishes rail in the same feed — so a
|
||||
* consumer judging physical plausibility must not hold a defaulted vehicle
|
||||
* to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} [vehicleId]
|
||||
* @param {unknown} [routeType]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
export function transitModeResolved(
|
||||
feed,
|
||||
routeId,
|
||||
vehicleId = null,
|
||||
routeType = null,
|
||||
) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route type a vehicle's model is chosen by: its record's, when that type
|
||||
* is a kind of the mode it was given. A fleet hint can overrule the route (a
|
||||
* bus on a tram route), and then the route's type does not describe it.
|
||||
* @param {string} mode The vehicle's mode ({@link transitModeFor}).
|
||||
* @param {unknown} routeType
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function transitModelRouteType(mode, routeType) {
|
||||
const n = transitRouteType(routeType);
|
||||
return n !== null && transitModeOfRouteType(n) === mode ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route as a rider would say it. Most feeds publish that already, so this
|
||||
* is identity for them; a feed that publishes internal keys supplies its own
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
haversineKm,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
TRANSIT_MODE_ICON,
|
||||
transitModeFor,
|
||||
transitModeOfRouteType,
|
||||
transitModeResolved,
|
||||
transitModelRouteType,
|
||||
transitRouteLabel,
|
||||
transitRouteType,
|
||||
} from './transitFeeds.js';
|
||||
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
|
||||
|
||||
@@ -729,8 +733,10 @@ test('Prague route-id ranges give metro, trains, ferries and trolleybuses their
|
||||
transitRouteLabel(feed, routeId),
|
||||
];
|
||||
assert.deepEqual(read('L22'), ['tram', '22']);
|
||||
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
|
||||
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
|
||||
assert.deepEqual(read('L58'), ['trolleybus', '58']);
|
||||
assert.deepEqual(read('L55'), ['bus', '55'], 'not a PID trolleybus line');
|
||||
assert.deepEqual(read('L275'), ['trolleybus', '275'], 'route type 11');
|
||||
assert.deepEqual(read('L49'), ['funicular', 'LD'], 'Petřín funicular');
|
||||
assert.deepEqual(read('L135'), ['bus', '135']);
|
||||
assert.deepEqual(read('L803'), ['bus', 'X3']);
|
||||
assert.deepEqual(read('L991'), ['subway', 'A']);
|
||||
@@ -767,7 +773,7 @@ test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () =>
|
||||
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
|
||||
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
|
||||
const lia = getTransitFeed('lia-le-havre');
|
||||
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
|
||||
assert.equal(transitModeFor(lia, '8-FUNI'), 'funicular');
|
||||
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
|
||||
assert.equal(transitRouteLabel(lia, '8-09'), '9');
|
||||
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
|
||||
@@ -778,3 +784,114 @@ test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
|
||||
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('a GTFS route type names its mode, basic and extended alike', () => {
|
||||
for (const [routeType, mode] of [
|
||||
[0, 'tram'],
|
||||
[1, 'subway'],
|
||||
[2, 'rail'],
|
||||
[3, 'bus'],
|
||||
[4, 'ferry'],
|
||||
[5, 'cable_tram'],
|
||||
[6, 'aerial_lift'],
|
||||
[7, 'funicular'],
|
||||
[11, 'trolleybus'],
|
||||
[12, 'monorail'],
|
||||
[101, 'rail'],
|
||||
[106, 'rail'],
|
||||
[200, 'bus'],
|
||||
[300, 'rail'],
|
||||
[401, 'subway'],
|
||||
[405, 'monorail'],
|
||||
[702, 'bus'],
|
||||
[800, 'trolleybus'],
|
||||
[900, 'tram'],
|
||||
[1000, 'ferry'],
|
||||
[1200, 'ferry'],
|
||||
[1300, 'aerial_lift'],
|
||||
[1400, 'funicular'],
|
||||
['11', 'trolleybus'],
|
||||
])
|
||||
assert.equal(transitModeOfRouteType(routeType), mode, String(routeType));
|
||||
// Air, taxi and other services, and values GTFS does not define.
|
||||
for (const other of [8, 1100, 1500, 1700, 1800, -1, 2.5, '', 'tram', null])
|
||||
assert.equal(transitModeOfRouteType(other), 'unknown', String(other));
|
||||
assert.equal(transitRouteType(' 200 '), 200);
|
||||
assert.equal(transitRouteType('2e2'), null);
|
||||
});
|
||||
|
||||
test('every mode has an icon, and every mode a route type names is a mode', () => {
|
||||
for (const mode of TRANSIT_MODES) assert.ok(TRANSIT_MODE_ICON[mode], mode);
|
||||
for (let routeType = 0; routeType < 1800; routeType++)
|
||||
assert.ok(TRANSIT_MODES.includes(transitModeOfRouteType(routeType)));
|
||||
});
|
||||
|
||||
test('a route type settles the mode before a route hint or the default', () => {
|
||||
const mbta = getTransitFeed('mbta');
|
||||
// The hint calls every numbered MBTA route a bus; a route type is finer.
|
||||
assert.equal(transitModeFor(mbta, '71'), 'bus');
|
||||
assert.equal(transitModeFor(mbta, '71', 'y1', 11), 'trolleybus');
|
||||
assert.equal(transitModeResolved(mbta, '71', 'y1', 11), true);
|
||||
// A default-only feed is resolved by a route type alone.
|
||||
const durham = getTransitFeed('durham-region');
|
||||
assert.equal(transitModeResolved(durham, '915', 'b1'), false);
|
||||
assert.equal(transitModeFor(durham, '915', 'b1', 200), 'bus');
|
||||
assert.equal(transitModeResolved(durham, '915', 'b1', 200), true);
|
||||
// A type that names no mode leaves the hints in charge.
|
||||
assert.equal(transitModeFor(mbta, 'Red', null, 1700), 'subway');
|
||||
assert.equal(transitModeFor(mbta, 'Red', null, 'x'), 'subway');
|
||||
});
|
||||
|
||||
test('a fleet hint outranks the route type, and then the type picks no model', () => {
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
// A bus standing in on the 501 streetcar route (route type 0).
|
||||
const mode = transitModeFor(ttc, '501', '8400', 0);
|
||||
assert.equal(mode, 'bus');
|
||||
assert.equal(transitModelRouteType(mode, 0), null);
|
||||
assert.equal(transitModelRouteType('tram', 0), 0);
|
||||
assert.equal(transitModelRouteType('bus', 200), 200, 'a coach is a bus');
|
||||
assert.equal(transitModelRouteType('bus', null), null);
|
||||
});
|
||||
|
||||
test('a route correction outranks a mistyped route type, and only for its route', () => {
|
||||
const lia = getTransitFeed('lia-le-havre');
|
||||
// LiA's routes.txt types the funicular as a bus (3).
|
||||
assert.equal(transitModeFor(lia, '8-FUNI', null, 3), 'funicular');
|
||||
assert.equal(transitModeResolved(lia, '8-FUNI', null, 3), true);
|
||||
// A correction is a mode, so the bus type picks no model for it.
|
||||
assert.equal(transitModelRouteType('funicular', 3), null);
|
||||
// Every other route still takes its route type.
|
||||
assert.equal(transitModeFor(lia, '8-A', null, 0), 'tram');
|
||||
assert.equal(transitModeFor(lia, '8-C3', null, 3), 'bus');
|
||||
assert.equal(transitModeFor(lia, null, null, 3), 'bus');
|
||||
// Corrections are narrow: a few named routes, each to a known mode.
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
if (!feed.routeCorrections) continue;
|
||||
assert.ok(Object.isFrozen(feed.routeCorrections), feed.id);
|
||||
for (const [route, mode] of Object.entries(feed.routeCorrections)) {
|
||||
assert.ok(TRANSIT_MODES.includes(mode), `${feed.id} ${route}`);
|
||||
assert.notEqual(mode, 'unknown', `${feed.id} ${route}`);
|
||||
}
|
||||
}
|
||||
// An inherited key is not a correction.
|
||||
assert.equal(transitModeFor(lia, 'toString', null, 3), 'bus');
|
||||
});
|
||||
|
||||
// Route ids as each realtime feed and its operator's routes.txt carried them
|
||||
// on 2026-11-14.
|
||||
test('Bucharest, Cluj and Turin route ids give trams, trolleybuses and a funicular', () => {
|
||||
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
|
||||
assert.equal(mode('tpbi-bucuresti', '85'), 'trolleybus', 'line 74');
|
||||
assert.equal(mode('tpbi-bucuresti', '1013'), 'tram', 'line 4');
|
||||
assert.equal(mode('tpbi-bucuresti', '968'), 'subway', 'M1');
|
||||
assert.equal(mode('tpbi-bucuresti', '828'), 'bus');
|
||||
assert.equal(mode('tpbi-bucuresti', 'PV1_407'), 'bus');
|
||||
assert.equal(mode('ctp-cluj', '1'), 'trolleybus');
|
||||
assert.equal(mode('ctp-cluj', '2'), 'tram', 'line 100');
|
||||
assert.equal(mode('ctp-cluj', '6'), 'bus', 'line 19');
|
||||
assert.equal(mode('gtt-torino', '79U'), 'funicular', 'Sassi–Superga');
|
||||
assert.equal(mode('gtt-torino', '16CDU'), 'tram');
|
||||
assert.equal(mode('gtt-torino', 'METROU'), 'subway');
|
||||
assert.equal(mode('gtt-torino', '6U'), 'bus');
|
||||
assert.equal(mode('gtt-torino', null), 'bus', 'no route: the default');
|
||||
});
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
|
||||
* short and blunt, a tram is long and slender with articulation joints, a metro
|
||||
* car is squared off, a mainline train has a tapered nose, and a ferry is a
|
||||
* hull with a bow.
|
||||
* hull with a bow. A trolleybus trails its two poles, a cable car is a short
|
||||
* single car, a monorail the slimmest bullet, a funicular hangs off its rope,
|
||||
* and a gondola is a cabin on its cable.
|
||||
*
|
||||
* Interior detail is drawn with the same dark hairline as the edge — windscreen
|
||||
* lines, articulation joints, a deck line — because a solid dark panel would
|
||||
@@ -103,6 +105,49 @@ const HEADING_BODIES = {
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
|
||||
|
||||
// ── Trolleybus: a bus a little narrower, trailing the two trolley poles
|
||||
// that reach back to its wires. Body and poles are one outline, so no
|
||||
// edge is drawn across the body.
|
||||
trolleybus: `
|
||||
<path d="M0,-41 L15,-26 L15,25 C15,28.5 13,30 9,30 L7.5,30 L7.5,42 L4,42
|
||||
L4,30 L-4,30 L-4,42 L-7.5,42 L-7.5,30 L-9,30 C-13,30 -15,28.5 -15,25
|
||||
L-15,-26 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-21 L10,-21" ${DETAIL}/>`,
|
||||
|
||||
// ── Cable car (street cable railway): a short single car, under half a
|
||||
// tram, with open platforms at both ends.
|
||||
cable_tram: `
|
||||
<path d="M0,-30 L11,-21 L11,26 C11,28 9,29 0,29 C-9,29 -11,28 -11,26
|
||||
L-11,-21 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-12 L11,-12" ${DETAIL}/>
|
||||
<path d="M-11,18 L11,18" ${DETAIL}/>`,
|
||||
|
||||
// ── Monorail: the slimmest train in the set, a rounded bullet nose over
|
||||
// its beam.
|
||||
monorail: `
|
||||
<path d="M0,-45 C3,-45 8.5,-36 8.5,-22 L8.5,42 L-8.5,42 L-8.5,-22
|
||||
C-8.5,-36 -3,-45 0,-45 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M0,-18 L0,38" ${DETAIL}/>`,
|
||||
|
||||
// ── Funicular: a squat car hauled by the rope at its upper end.
|
||||
funicular: `
|
||||
<path d="M-6,-30 L6,-30 L11,-24 L11,36 L-11,36 L-11,-24 Z
|
||||
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-8 L11,-8" ${DETAIL}/>
|
||||
<path d="M-11,14 L11,14" ${DETAIL}/>`,
|
||||
|
||||
// ── Gondola (aerial lift): a cabin hung on the cable that runs through
|
||||
// it; the cabin's nose is its front.
|
||||
aerial_lift: `
|
||||
<path d="M0,-18 L12,-7 L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14
|
||||
L-12,-7 Z
|
||||
M-2.5,-40 L2.5,-40 L2.5,46 L-2.5,46 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
|
||||
// ── Unknown: a plain marker with a nose and nothing else, so an
|
||||
// unclassified vehicle never pretends to be a mode it is not.
|
||||
unknown: `
|
||||
@@ -146,6 +191,40 @@ const NEUTRAL_BODIES = {
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-13 L7.5,-13 L7.5,13 L-7.5,13 Z" ${DETAIL}/>`,
|
||||
|
||||
trolleybus: `
|
||||
<path d="M-15,-26 C-15,-30 -13,-32 -9,-32 L-7.5,-32 L-7.5,-42 L-4,-42
|
||||
L-4,-32 L4,-32 L4,-42 L7.5,-42 L7.5,-32 L9,-32 C13,-32 15,-30 15,-26
|
||||
L15,26 C15,30 13,32 9,32 L7.5,32 L7.5,42 L4,42 L4,32 L-4,32 L-4,42
|
||||
L-7.5,42 L-7.5,32 L-9,32 C-13,32 -15,30 -15,26 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
|
||||
cable_tram: `
|
||||
<path d="M-11,-25 C-11,-27 -9,-28 0,-28 C9,-28 11,-27 11,-25
|
||||
L11,25 C11,27 9,28 0,28 C-9,28 -11,27 -11,25 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-17 L11,-17" ${DETAIL}/>
|
||||
<path d="M-11,17 L11,17" ${DETAIL}/>`,
|
||||
|
||||
monorail: `
|
||||
<path d="M0,-44 C5,-44 8.5,-38 8.5,-28 L8.5,28 C8.5,38 5,44 0,44
|
||||
C-5,44 -8.5,38 -8.5,28 L-8.5,-28 C-8.5,-38 -5,-44 0,-44 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M0,-24 L0,24" ${DETAIL}/>`,
|
||||
|
||||
funicular: `
|
||||
<path d="M-11,-30 L11,-30 L11,30 L-11,30 Z
|
||||
M-1.75,-46 L1.75,-46 L1.75,-30 L-1.75,-30 Z
|
||||
M-1.75,30 L1.75,30 L1.75,46 L-1.75,46 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-10 L11,-10" ${DETAIL}/>
|
||||
<path d="M-11,10 L11,10" ${DETAIL}/>`,
|
||||
|
||||
aerial_lift: `
|
||||
<path d="M-12,-14 C-12,-16 -10,-17 0,-17 C10,-17 12,-16 12,-14
|
||||
L12,14 C12,16 10,17 0,17 C-10,17 -12,16 -12,14 Z
|
||||
M-2.5,-46 L2.5,-46 L2.5,46 L-2.5,46 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
|
||||
unknown: `
|
||||
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
|
||||
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
|
||||
|
||||
@@ -62,7 +62,7 @@ test('the raster cache is bounded by kind, size and halo — never by call count
|
||||
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
|
||||
});
|
||||
|
||||
test('all 72 variants retain the specified final halo at both display sizes', async () => {
|
||||
test('every variant retains the specified final halo at both display sizes', async () => {
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
@@ -97,7 +97,8 @@ test('all 72 variants retain the specified final halo at both display sizes', as
|
||||
}
|
||||
for (let i = 0; i < 100; i++)
|
||||
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
|
||||
assert.equal(transitIconCacheSize(), 72);
|
||||
// Every kind at two sizes, three profiles, with and without a course.
|
||||
assert.equal(transitIconCacheSize(), TRANSIT_ICON_KINDS.length * 12);
|
||||
});
|
||||
|
||||
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
|
||||
@@ -197,6 +198,11 @@ test('sensor raster halo stays dark across its screen-space band on a white roof
|
||||
subway: 11.5,
|
||||
rail: 10.5,
|
||||
ferry: 12.5,
|
||||
trolleybus: 15,
|
||||
cable_tram: 11,
|
||||
monorail: 8.5,
|
||||
funicular: 11,
|
||||
aerial_lift: 12,
|
||||
unknown: 10,
|
||||
};
|
||||
for (const selected of [false, true]) {
|
||||
|
||||
@@ -60,9 +60,14 @@ const SPRITE_STYLE = Object.freeze({
|
||||
outlinePx: 1.25,
|
||||
rgbaByMode: Object.freeze({
|
||||
bus: Object.freeze([0, 255, 102, 1]),
|
||||
trolleybus: Object.freeze([170, 255, 0, 1]),
|
||||
tram: Object.freeze([255, 179, 0, 1]),
|
||||
cable_tram: Object.freeze([255, 120, 0, 1]),
|
||||
subway: Object.freeze([255, 59, 48, 1]),
|
||||
monorail: Object.freeze([255, 64, 170, 1]),
|
||||
rail: Object.freeze([215, 139, 255, 1]),
|
||||
funicular: Object.freeze([255, 214, 140, 1]),
|
||||
aerial_lift: Object.freeze([90, 140, 255, 1]),
|
||||
ferry: Object.freeze([58, 208, 255, 1]),
|
||||
unknown: Object.freeze([255, 255, 255, 1]),
|
||||
}),
|
||||
@@ -72,9 +77,14 @@ const SPRITE_STYLE = Object.freeze({
|
||||
/** Modes with a bracket tier of their own. */
|
||||
const TIER_MODES = Object.freeze([
|
||||
'bus',
|
||||
'trolleybus',
|
||||
'tram',
|
||||
'cable_tram',
|
||||
'subway',
|
||||
'monorail',
|
||||
'rail',
|
||||
'funicular',
|
||||
'aerial_lift',
|
||||
'ferry',
|
||||
'unknown',
|
||||
]);
|
||||
@@ -128,7 +138,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) {
|
||||
|
||||
/**
|
||||
* Detection-overlay tier key for a transit mode. Every theme's `tiers`
|
||||
* table carries these six, so the bracket colour is per mode in every
|
||||
* table carries one per mode, so the bracket colour is per mode in every
|
||||
* preset. Unlike traffic, transit sets its tier keyless as well: the feeds
|
||||
* are keyless, and there is no separate keyless experience to keep
|
||||
* byte-identical.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
decodeVehiclePositions,
|
||||
} from './gtfsRealtime.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
export { transitModeFor, transitRouteType } from './transitFeeds.js';
|
||||
|
||||
/** Fresh window: a snapshot younger than this is served without refetching. */
|
||||
export const TRANSIT_PROXY_TTL_MS = 15_000;
|
||||
|
||||
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
|
||||
];
|
||||
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
|
||||
// and tests match on them.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
|
||||
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,517 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { windProxy } from '../../server/providers/wind.js';
|
||||
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
|
||||
|
||||
// Imported per test so a missing worker decoder fails those tests, not the file.
|
||||
const createWindDecoder = async (options) =>
|
||||
(await import('../../server/providers/wind/decoder.js')).createWindDecoder(
|
||||
options,
|
||||
);
|
||||
const require = createRequire(import.meta.url);
|
||||
/** Whether this thread has loaded ecCodes (a CommonJS package). */
|
||||
const ecCodesLoadedHere = () =>
|
||||
Object.keys(require.cache).some((file) => file.includes('eccodes-wasm'));
|
||||
|
||||
const DECODE_MODULE = new URL(
|
||||
'../../server/providers/wind/decode.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/**
|
||||
* A worker that runs the provider's real serving loop over a stand-in for
|
||||
* ecCodes. The first byte selects the behaviour, the second a synchronous busy
|
||||
* time in tens of milliseconds, which is what a WASM decode looks like to the
|
||||
* thread that runs it.
|
||||
*/
|
||||
const FAKE_WORKER = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { serveWindDecodes } from ${JSON.stringify(DECODE_MODULE)};
|
||||
serveWindDecodes(parentPort, (bytes) => {
|
||||
if (bytes[0] === 1) throw new Error('bad message');
|
||||
if (bytes[0] === 2) {
|
||||
setTimeout(() => { throw new Error('crash'); });
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (bytes[0] === 3) process.exit(1);
|
||||
if (bytes[0] === 4) for (;;);
|
||||
const until = Date.now() + (bytes[1] || 0) * 10;
|
||||
while (Date.now() < until);
|
||||
return {
|
||||
ni: 4, nj: 3, lo1: 0, la1: 90, di: 90, dj: 90,
|
||||
values: Float64Array.from({ length: 12 }, (_, i) => bytes[0] + i / 8),
|
||||
units: 'm/s',
|
||||
};
|
||||
});
|
||||
`)}`,
|
||||
);
|
||||
|
||||
/** Run `work` while a 5 ms heartbeat records the longest main-thread stall. */
|
||||
async function longestStall(work) {
|
||||
let last = performance.now();
|
||||
let gap = 0;
|
||||
const beat = setInterval(() => {
|
||||
const t = performance.now();
|
||||
gap = Math.max(gap, t - last);
|
||||
last = t;
|
||||
}, 5);
|
||||
try {
|
||||
const started = performance.now();
|
||||
const value = await work();
|
||||
return { value, gap, elapsed: performance.now() - started };
|
||||
} finally {
|
||||
clearInterval(beat);
|
||||
}
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||
});
|
||||
return async (url = '/') => {
|
||||
const res = {
|
||||
writeHead(status) {
|
||||
this.statusCode = status;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url, method: 'GET' }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/** Manifest (minus fetchedAt) and grid bytes exactly as a client receives them. */
|
||||
async function served(request, url) {
|
||||
const { fetchedAt, ...manifest } = JSON.parse((await request(url)).body);
|
||||
const grid = manifest.gridUrl
|
||||
? (await request(manifest.gridUrl.replace('/api/wind', ''))).body
|
||||
: null;
|
||||
return { manifest, grid };
|
||||
}
|
||||
|
||||
/** One GRIB2 message on a regular lat/lon grid with 16-bit simple packing. */
|
||||
function syntheticGrib({ category, number, surface, level, values }) {
|
||||
const [ni, nj] = [8, 5];
|
||||
const u8 = (...bytes) => Buffer.from(bytes);
|
||||
const u16 = (value) => {
|
||||
const out = Buffer.alloc(2);
|
||||
out.writeUInt16BE(value);
|
||||
return out;
|
||||
};
|
||||
const u32 = (value) => {
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32BE(value >>> 0);
|
||||
return out;
|
||||
};
|
||||
const section = (id, ...parts) => {
|
||||
const body = Buffer.concat(parts);
|
||||
return Buffer.concat([u32(body.length + 5), u8(id), body]);
|
||||
};
|
||||
const reference = Buffer.alloc(4);
|
||||
reference.writeFloatBE(Math.min(...values) * 10);
|
||||
const packed = Buffer.alloc(values.length * 2);
|
||||
values.forEach((value, index) =>
|
||||
packed.writeUInt16BE(
|
||||
Math.round(value * 10 - reference.readFloatBE()),
|
||||
index * 2,
|
||||
),
|
||||
);
|
||||
const rest = Buffer.concat([
|
||||
section(1, u16(7), u16(0), u8(2, 1, 1), u16(2026), u8(9, 14, 6, 0, 0, 0, 1)),
|
||||
section(
|
||||
3,
|
||||
u8(0),
|
||||
u32(ni * nj),
|
||||
u8(0, 0),
|
||||
u16(0), // template 3.0, regular lat/lon
|
||||
u8(6, 0),
|
||||
u32(0),
|
||||
u8(0),
|
||||
u32(0),
|
||||
u8(0),
|
||||
u32(0),
|
||||
u32(ni),
|
||||
u32(nj),
|
||||
u32(0),
|
||||
u32(0xffffffff),
|
||||
u32(90e6), // first point 90N 0E, last point 90S 315E, 45 degree steps
|
||||
u32(0),
|
||||
u8(48),
|
||||
u32(90e6 | 0x80000000),
|
||||
u32(315e6),
|
||||
u32(45e6),
|
||||
u32(45e6),
|
||||
u8(0),
|
||||
),
|
||||
section(
|
||||
4,
|
||||
u16(0),
|
||||
u16(0), // template 4.0
|
||||
u8(category, number, 2, 0, 0),
|
||||
u16(0),
|
||||
u8(0, 1),
|
||||
u32(0),
|
||||
u8(surface, 0),
|
||||
u32(level),
|
||||
u8(255, 255),
|
||||
u32(0xffffffff),
|
||||
),
|
||||
// Template 5.0: value = (reference + packed) / 10.
|
||||
section(5, u32(ni * nj), u16(0), reference, u16(0), u16(1), u8(16, 0)),
|
||||
section(6, u8(255)),
|
||||
section(7, packed),
|
||||
Buffer.from('7777'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from('GRIB'),
|
||||
u8(0, 0, 0, 2),
|
||||
u32(0),
|
||||
u32(16 + rest.length),
|
||||
rest,
|
||||
]);
|
||||
}
|
||||
|
||||
const field = (seed, scale) =>
|
||||
Array.from({ length: 40 }, (_, i) => seed + Math.sin(i * 1.7) * scale);
|
||||
const MESSAGES = {
|
||||
u: syntheticGrib({
|
||||
category: 2,
|
||||
number: 2,
|
||||
surface: 103,
|
||||
level: 10,
|
||||
values: field(3, 14),
|
||||
}),
|
||||
v: syntheticGrib({
|
||||
category: 2,
|
||||
number: 3,
|
||||
surface: 103,
|
||||
level: 10,
|
||||
values: field(-2, 9),
|
||||
}),
|
||||
tmp: syntheticGrib({
|
||||
category: 0,
|
||||
number: 0,
|
||||
surface: 103,
|
||||
level: 2,
|
||||
values: field(281, 25),
|
||||
}),
|
||||
prmsl: syntheticGrib({
|
||||
category: 3,
|
||||
number: 1,
|
||||
surface: 101,
|
||||
level: 0,
|
||||
values: field(101300, 2400),
|
||||
}),
|
||||
};
|
||||
|
||||
/** A GFS object holding the four synthetic messages, plus its `.idx`. */
|
||||
function syntheticUpstream({ log = [], corrupt = () => false } = {}) {
|
||||
const order = ['u', 'v', 'tmp', 'prmsl'];
|
||||
const offsets = [0];
|
||||
for (const name of order) offsets.push(offsets.at(-1) + MESSAGES[name].length);
|
||||
const file = Buffer.concat(order.map((name) => MESSAGES[name]));
|
||||
const idx = [
|
||||
`1:${offsets[0]}:d=2026091406:UGRD:10 m above ground:6 hour fcst:`,
|
||||
`2:${offsets[1]}:d=2026091406:VGRD:10 m above ground:6 hour fcst:`,
|
||||
`3:${offsets[2]}:d=2026091406:TMP:2 m above ground:6 hour fcst:`,
|
||||
`4:${offsets[3]}:d=2026091406:PRMSL:mean sea level:6 hour fcst:`,
|
||||
`5:${offsets[4]}:d=2026091406:HGT:surface:6 hour fcst:`,
|
||||
].join('\n');
|
||||
const ranges = Object.fromEntries(
|
||||
order.map((name, i) => [name, `bytes=${offsets[i]}-${offsets[i + 1] - 1}`]),
|
||||
);
|
||||
const fetchImpl = async (url, options = {}) => {
|
||||
const range = options.headers?.Range;
|
||||
log.push(range ?? 'idx');
|
||||
if (!range) return new Response(idx, { status: 200 });
|
||||
const [, start, end] = /bytes=(\d+)-(\d+)/.exec(range);
|
||||
const name = order.find((key) => ranges[key] === range);
|
||||
const body = Buffer.from(file.subarray(Number(start), Number(end) + 1));
|
||||
if (corrupt(name)) body.fill(0);
|
||||
return new Response(body, { status: 206 });
|
||||
};
|
||||
return { fetchImpl, ranges, log };
|
||||
}
|
||||
|
||||
const NOON = Date.UTC(2026, 8, 14, 12);
|
||||
const KEYS = ['/', '/?overlay=temperature', '/?overlay=pressure'];
|
||||
|
||||
test('a blocking decode runs off the server thread', async (t) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => decoder.close());
|
||||
// Warm the worker so its start-up is not counted, then block it for 300 ms.
|
||||
await decoder.decode(Uint8Array.of(5, 0));
|
||||
const { value, gap, elapsed } = await longestStall(() =>
|
||||
decoder.decode(Uint8Array.of(7, 30), { targetDx: 90 }),
|
||||
);
|
||||
assert.ok(elapsed >= 280, `decode took ${elapsed} ms`);
|
||||
assert.ok(gap < 150, `server thread stalled for ${gap} ms`);
|
||||
// The worker validated and sampled the field; only the samples came back.
|
||||
assert.equal(value.values, undefined);
|
||||
assert.equal(value.dx, 90);
|
||||
assert.ok(value.samples instanceof Float64Array);
|
||||
assert.equal(value.samples.length, 4 * 3);
|
||||
assert.equal(value.samples[0], 7);
|
||||
});
|
||||
|
||||
test('the default wind provider decodes in the worker and matches the synchronous path byte for byte', async () => {
|
||||
const outputs = [];
|
||||
for (const options of [
|
||||
{}, // default: ecCodes in the worker
|
||||
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) }, // on this thread
|
||||
]) {
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream().fetchImpl,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
const out = [];
|
||||
for (const key of KEYS) out.push(await served(request, key));
|
||||
// The default path never brings ecCodes into this thread; the injected
|
||||
// synchronous decoder does, which also proves the probe can see it.
|
||||
assert.equal(ecCodesLoadedHere(), outputs.length === 1);
|
||||
outputs.push(out);
|
||||
}
|
||||
const [worker, sync] = outputs;
|
||||
for (let i = 0; i < KEYS.length; i += 1) {
|
||||
assert.equal(worker[i].manifest.unavailable, false, KEYS[i]);
|
||||
assert.deepEqual(worker[i].manifest, sync[i].manifest, KEYS[i]);
|
||||
assert.ok(worker[i].grid.equals(sync[i].grid), `${KEYS[i]} grid bytes`);
|
||||
}
|
||||
assert.equal(worker[0].grid.length, 12 * 7 * 4 * 2);
|
||||
assert.equal(worker[1].grid.length, 12 * 7 * 4 * 3);
|
||||
assert.equal(worker[1].manifest.scalar.kind, 'temperature');
|
||||
assert.equal(worker[2].manifest.scalar.kind, 'pressure');
|
||||
});
|
||||
|
||||
test('partial and failed refreshes match the synchronous path through the worker', async () => {
|
||||
for (const [bad, expected] of [
|
||||
['tmp', { scalarError: 'Temperature field unavailable' }],
|
||||
['u', { unavailable: true }],
|
||||
]) {
|
||||
const outputs = [];
|
||||
for (const options of [
|
||||
{},
|
||||
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) },
|
||||
]) {
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream({ corrupt: (name) => name === bad })
|
||||
.fetchImpl,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
outputs.push(await served(request, '/?overlay=temperature'));
|
||||
}
|
||||
const [worker, sync] = outputs;
|
||||
assert.deepEqual(worker.manifest, sync.manifest);
|
||||
for (const [name, value] of Object.entries(expected))
|
||||
assert.equal(worker.manifest[name], value);
|
||||
if (sync.grid) assert.ok(worker.grid.equals(sync.grid));
|
||||
else assert.equal(worker.grid, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an overlay change within the same forecast object fetches and decodes only the scalar', async () => {
|
||||
let clock = NOON;
|
||||
let decodes = 0;
|
||||
const upstream = syntheticUpstream();
|
||||
const decodeImpl = (bytes) => {
|
||||
decodes += 1;
|
||||
return decodeWindGribMessage(bytes);
|
||||
};
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => clock,
|
||||
targetDx: 30,
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
decodeImpl,
|
||||
}),
|
||||
);
|
||||
const none = await served(request, '/');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.u, upstream.ranges.v]);
|
||||
assert.equal(decodes, 2);
|
||||
|
||||
upstream.log.length = 0;
|
||||
const temperature = await served(request, '/?overlay=temperature');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.tmp]);
|
||||
assert.equal(decodes, 3);
|
||||
upstream.log.length = 0;
|
||||
const pressure = await served(request, '/?overlay=pressure');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.prmsl]);
|
||||
assert.equal(decodes, 4);
|
||||
|
||||
// Reused wind gives exactly what a server that fetched everything gives.
|
||||
const fresh = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream().fetchImpl,
|
||||
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
|
||||
}),
|
||||
);
|
||||
for (const [url, reused] of [
|
||||
['/?overlay=temperature', temperature],
|
||||
['/?overlay=pressure', pressure],
|
||||
]) {
|
||||
const full = await served(fresh, url);
|
||||
assert.deepEqual(reused.manifest, full.manifest);
|
||||
assert.ok(reused.grid.equals(full.grid));
|
||||
}
|
||||
assert.ok(
|
||||
temperature.grid.subarray(0, none.grid.length).equals(none.grid),
|
||||
'the overlay grid starts with the same U and V',
|
||||
);
|
||||
|
||||
// The next forecast step is a different object: wind is fetched again.
|
||||
clock += 3600_000;
|
||||
upstream.log.length = 0;
|
||||
await served(request, '/?overlay=temperature');
|
||||
assert.deepEqual(upstream.log, [
|
||||
'idx',
|
||||
upstream.ranges.u,
|
||||
upstream.ranges.v,
|
||||
upstream.ranges.tmp,
|
||||
]);
|
||||
});
|
||||
|
||||
test('wind that failed its checks is never retained for a later overlay', async () => {
|
||||
const upstream = syntheticUpstream({ corrupt: (name) => name === 'v' });
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
|
||||
}),
|
||||
);
|
||||
assert.equal((await served(request, '/')).manifest.unavailable, true);
|
||||
upstream.log.length = 0;
|
||||
const overlay = await served(request, '/?overlay=temperature');
|
||||
assert.equal(overlay.manifest.unavailable, true);
|
||||
assert.ok(upstream.log.includes(upstream.ranges.u), 'U is fetched again');
|
||||
});
|
||||
|
||||
test('a throwing, crashing, exiting or hung decode rejects its job and the next decode recovers', async (t) => {
|
||||
const decoder = await createWindDecoder({
|
||||
workerUrl: FAKE_WORKER,
|
||||
jobTimeoutMs: 400,
|
||||
});
|
||||
t.after(() => decoder.close());
|
||||
const ok = async () => {
|
||||
const value = await decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 });
|
||||
assert.equal(value.samples[0], 9);
|
||||
};
|
||||
await ok();
|
||||
// A decode error is reported for that job only; the worker stays up.
|
||||
const [thrown, sibling] = await Promise.allSettled([
|
||||
decoder.decode(Uint8Array.of(1, 0)),
|
||||
decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 }),
|
||||
]);
|
||||
assert.match(thrown.reason.message, /bad message/);
|
||||
assert.equal(sibling.value.samples[0], 9);
|
||||
// An uncaught error or an exit retires the worker; the next decode restarts it.
|
||||
for (const [mode, pattern] of [
|
||||
[2, /worker failed/],
|
||||
[3, /worker exited/],
|
||||
]) {
|
||||
const results = await Promise.allSettled([
|
||||
decoder.decode(Uint8Array.of(mode, 0)),
|
||||
decoder.decode(Uint8Array.of(9, 0)),
|
||||
]);
|
||||
assert.match(results[0].reason.message, pattern);
|
||||
// A job queued behind an exit dies with the worker; one queued behind an
|
||||
// asynchronous crash may still finish first. Either way it settles.
|
||||
if (mode === 3) assert.equal(results[1].status, 'rejected');
|
||||
await ok();
|
||||
}
|
||||
// A decode that never returns is cut off instead of hanging its request.
|
||||
await assert.rejects(decoder.decode(Uint8Array.of(4, 0)), /timed out/);
|
||||
await ok();
|
||||
// Cancelling stops the wait at once; the budget is checked before any copy.
|
||||
const controller = new AbortController();
|
||||
const slow = decoder.decode(Uint8Array.of(9, 20), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error('cancelled'));
|
||||
await assert.rejects(slow, /cancelled/);
|
||||
await assert.rejects(
|
||||
decoder.decode(new Uint8Array(8 * 1024 * 1024 + 1)),
|
||||
/byte budget/,
|
||||
);
|
||||
await ok();
|
||||
});
|
||||
|
||||
test('a worker crash during a refresh answers the request and the next refresh recovers', async (t) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => decoder.close());
|
||||
let clock = NOON;
|
||||
let crash = true;
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => clock,
|
||||
targetDx: 90,
|
||||
decodeImpl: decoder.decode,
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
if (!options.headers?.Range)
|
||||
return new Response(
|
||||
[
|
||||
'1:0:d=2026091406:UGRD:10 m above ground:anl:',
|
||||
'2:2:d=2026091406:VGRD:10 m above ground:anl:',
|
||||
'3:4:d=2026091406:HGT:surface:anl:',
|
||||
].join('\n'),
|
||||
);
|
||||
return new Response(Uint8Array.of(crash ? 3 : 6, 0), { status: 206 });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const failed = await served(request, '/');
|
||||
assert.equal(failed.manifest.unavailable, true);
|
||||
crash = false;
|
||||
clock += 60_000; // past the failure backoff
|
||||
const recovered = await served(request, '/');
|
||||
assert.equal(recovered.manifest.unavailable, false);
|
||||
assert.equal(recovered.grid.length, 4 * 3 * 4 * 2);
|
||||
});
|
||||
|
||||
test('closing one decoder rejects its jobs and leaves another working', async (t) => {
|
||||
const a = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
const b = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => b.close());
|
||||
const pending = a.decode(Uint8Array.of(9, 10));
|
||||
a.close();
|
||||
await assert.rejects(pending, /closed/);
|
||||
assert.equal((await b.decode(Uint8Array.of(9, 0))).samples[0], 9);
|
||||
});
|
||||
|
||||
test('an idle decode worker does not keep the process alive', () => {
|
||||
const decoderUrl = new URL(
|
||||
'../../server/providers/wind/decoder.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const script = `
|
||||
import(${JSON.stringify(decoderUrl)}).then(async ({ createWindDecoder }) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: new URL(${JSON.stringify(FAKE_WORKER.href)}) });
|
||||
const field = await decoder.decode(Uint8Array.of(9, 0));
|
||||
console.log('decoded', field.samples.length);
|
||||
});
|
||||
`;
|
||||
const child = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000,
|
||||
});
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stdout.trim(), 'decoded 65160');
|
||||
});
|
||||
@@ -776,6 +776,8 @@
|
||||
"TANKER": "ناقلة",
|
||||
"OTHER": "أخرى",
|
||||
"VESSEL": "سفينة",
|
||||
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||
"VIRTUAL": "افتراضي",
|
||||
"POS: LIVE": "الموضع: مباشر",
|
||||
"POS: {time}Z": "الموضع: {time}Z",
|
||||
"AIS: --": "AIS: --",
|
||||
@@ -3953,5 +3955,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
|
||||
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
|
||||
"Reload": "إعادة التحميل",
|
||||
"Retry": "إعادة المحاولة",
|
||||
"Reset view": "إعادة ضبط العرض",
|
||||
"Trolleybus": "ترولي باص",
|
||||
"Cable car": "ترام كابلي",
|
||||
"Monorail": "قطار أحادي السكة",
|
||||
"Funicular": "قطار جبلي مائل",
|
||||
"Gondola": "تلفريك",
|
||||
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
|
||||
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
|
||||
}
|
||||
@@ -416,6 +416,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "SCHIFF",
|
||||
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||
"VIRTUAL": "VIRTUELL",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||
@@ -3605,5 +3607,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
|
||||
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
|
||||
"Reload": "Neu laden",
|
||||
"Retry": "Erneut versuchen",
|
||||
"Reset view": "Ansicht zurücksetzen",
|
||||
"Trolleybus": "Oberleitungsbus",
|
||||
"Cable car": "Kabelstraßenbahn",
|
||||
"Monorail": "Einschienenbahn",
|
||||
"Funicular": "Standseilbahn",
|
||||
"Gondola": "Gondelbahn",
|
||||
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
|
||||
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
|
||||
}
|
||||
@@ -650,6 +650,8 @@
|
||||
"TANKER": "PETROLERO",
|
||||
"OTHER": "OTRO",
|
||||
"VESSEL": "BUQUE",
|
||||
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||
"VIRTUAL": "VIRTUAL",
|
||||
"POS: LIVE": "POS: EN VIVO",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"AIS: --": "AIS: --",
|
||||
@@ -3692,5 +3694,19 @@
|
||||
"500–1,000 m": "500–1000 m",
|
||||
"1,000–2,000 m": "1000–2000 m",
|
||||
"2,000–3,000 m": "2000–3000 m",
|
||||
"3,000 m and above": "3000 m o más"
|
||||
"3,000 m and above": "3000 m o más",
|
||||
"The globe stopped drawing": "El globo dejó de dibujarse",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
|
||||
"Reload": "Recargar",
|
||||
"Retry": "Reintentar",
|
||||
"Reset view": "Restablecer la vista",
|
||||
"Trolleybus": "Trolebús",
|
||||
"Cable car": "Tranvía de cable",
|
||||
"Monorail": "Monorraíl",
|
||||
"Funicular": "Funicular",
|
||||
"Gondola": "Góndola",
|
||||
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
|
||||
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
|
||||
}
|
||||
@@ -1291,6 +1291,8 @@
|
||||
"AIS: {name}": "AIS : {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||
"VESSEL": "NAVIRE",
|
||||
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||
"VIRTUAL": "VIRTUEL",
|
||||
"{speed}KT": "{speed} ND",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS : DIRECT",
|
||||
@@ -3692,5 +3694,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
|
||||
"The globe stopped drawing": "Le globe ne s’affiche plus",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
|
||||
"Reload": "Recharger",
|
||||
"Retry": "Réessayer",
|
||||
"Reset view": "Réinitialiser la vue",
|
||||
"Trolleybus": "Trolleybus",
|
||||
"Cable car": "Tramway à câble",
|
||||
"Monorail": "Monorail",
|
||||
"Funicular": "Funiculaire",
|
||||
"Gondola": "Télécabine",
|
||||
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
|
||||
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
|
||||
}
|
||||
@@ -1149,6 +1149,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "אונייה",
|
||||
"AID TO NAVIGATION": "עזר ניווט",
|
||||
"VIRTUAL": "וירטואלי",
|
||||
"{speed}KT": "{speed} קשר",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: חי",
|
||||
@@ -3692,5 +3694,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
|
||||
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
|
||||
"Reload": "טען מחדש",
|
||||
"Retry": "נסה שוב",
|
||||
"Reset view": "איפוס תצוגה",
|
||||
"Trolleybus": "טרוליבוס",
|
||||
"Cable car": "חשמלית כבלים",
|
||||
"Monorail": "מונורייל",
|
||||
"Funicular": "פוניקולר",
|
||||
"Gondola": "רכבל",
|
||||
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
|
||||
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
|
||||
}
|
||||
@@ -1291,6 +1291,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||
"VESSEL": "NAVE",
|
||||
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||
"VIRTUAL": "VIRTUALE",
|
||||
"{speed}KT": "{speed}KT",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
@@ -3692,5 +3694,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
|
||||
"The globe stopped drawing": "Il globo ha smesso di disegnare",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
|
||||
"Reload": "Ricarica",
|
||||
"Retry": "Riprova",
|
||||
"Reset view": "Ripristina vista",
|
||||
"Trolleybus": "Filobus",
|
||||
"Cable car": "Tram a fune",
|
||||
"Monorail": "Monorotaia",
|
||||
"Funicular": "Funicolare",
|
||||
"Gondola": "Cabinovia",
|
||||
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
|
||||
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
|
||||
}
|
||||
@@ -1253,6 +1253,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "航路標識",
|
||||
"VIRTUAL": "仮想",
|
||||
"{speed}KT": "{speed} kt",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: ライブ",
|
||||
@@ -3518,5 +3520,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る"
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る",
|
||||
"The globe stopped drawing": "地球儀の描画が止まりました",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
|
||||
"Reload": "再読み込み",
|
||||
"Retry": "再試行",
|
||||
"Reset view": "ビューをリセット",
|
||||
"Trolleybus": "トロリーバス",
|
||||
"Cable car": "ケーブルトラム",
|
||||
"Monorail": "モノレール",
|
||||
"Funicular": "ケーブルカー",
|
||||
"Gondola": "ゴンドラ",
|
||||
"Move panels back beside the rail": "パネルをレールの横に戻す",
|
||||
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
|
||||
}
|
||||
@@ -1289,6 +1289,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "EMBARCAÇÃO",
|
||||
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||
"VIRTUAL": "VIRTUAL",
|
||||
"{speed}KT": "{speed} KT",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: AO VIVO",
|
||||
@@ -3692,5 +3694,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
|
||||
"The globe stopped drawing": "O globo parou de desenhar",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
|
||||
"Reload": "Recarregar",
|
||||
"Retry": "Tentar novamente",
|
||||
"Reset view": "Redefinir vista",
|
||||
"Trolleybus": "Trólebus",
|
||||
"Cable car": "Bonde a cabo",
|
||||
"Monorail": "Monotrilho",
|
||||
"Funicular": "Funicular",
|
||||
"Gondola": "Teleférico",
|
||||
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
|
||||
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
|
||||
}
|
||||
@@ -394,6 +394,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||
"VESSEL": "СУДНО",
|
||||
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
|
||||
"AIS: --": "AIS: --",
|
||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||
@@ -3779,5 +3781,19 @@
|
||||
"500–1,000 m": "500–1000 м",
|
||||
"1,000–2,000 m": "1000–2000 м",
|
||||
"2,000–3,000 m": "2000–3000 м",
|
||||
"3,000 m and above": "3000 м и выше"
|
||||
"3,000 m and above": "3000 м и выше",
|
||||
"The globe stopped drawing": "Глобус перестал отрисовываться",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
|
||||
"Reload": "Перезагрузить",
|
||||
"Retry": "Повторить",
|
||||
"Reset view": "Сбросить вид",
|
||||
"Trolleybus": "Троллейбус",
|
||||
"Cable car": "Канатный трамвай",
|
||||
"Monorail": "Монорельс",
|
||||
"Funicular": "Фуникулёр",
|
||||
"Gondola": "Канатная дорога",
|
||||
"Move panels back beside the rail": "Вернуть панели к боковой панели",
|
||||
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
|
||||
}
|
||||
@@ -382,6 +382,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "FARTYG",
|
||||
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||
"VIRTUAL": "VIRTUELL",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||
@@ -3605,5 +3607,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
|
||||
"The globe stopped drawing": "Globen ritas inte längre",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
|
||||
"Reload": "Ladda om",
|
||||
"Retry": "Försök igen",
|
||||
"Reset view": "Återställ vy",
|
||||
"Trolleybus": "Trådbuss",
|
||||
"Cable car": "Kabelspårvagn",
|
||||
"Monorail": "Enskensbana",
|
||||
"Funicular": "Bergbana",
|
||||
"Gondola": "Kabinbana",
|
||||
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
|
||||
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
|
||||
}
|
||||
@@ -1309,6 +1309,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||
"VESSEL": "СУДНО",
|
||||
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||
"VIRTUAL": "ВІРТУАЛЬНИЙ",
|
||||
"{speed}KT": "{speed} ВУЗ",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||
@@ -3779,5 +3781,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
|
||||
"The globe stopped drawing": "Глобус перестав відмальовуватися",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
|
||||
"Reload": "Перезавантажити",
|
||||
"Retry": "Повторити",
|
||||
"Reset view": "Скинути вигляд",
|
||||
"Trolleybus": "Тролейбус",
|
||||
"Cable car": "Канатний трамвай",
|
||||
"Monorail": "Монорейка",
|
||||
"Funicular": "Фунікулер",
|
||||
"Gondola": "Канатна дорога",
|
||||
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
|
||||
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
|
||||
}
|
||||
@@ -1229,6 +1229,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "助航标志",
|
||||
"VIRTUAL": "虚拟",
|
||||
"{speed}KT": "{speed} kn",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS:实时",
|
||||
@@ -3518,5 +3520,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库"
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库",
|
||||
"The globe stopped drawing": "地球已停止绘制",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
|
||||
"Reload": "重新加载",
|
||||
"Retry": "重试",
|
||||
"Reset view": "重置视图",
|
||||
"Trolleybus": "无轨电车",
|
||||
"Cable car": "缆索电车",
|
||||
"Monorail": "单轨列车",
|
||||
"Funicular": "缆索铁路",
|
||||
"Gondola": "缆车",
|
||||
"Move panels back beside the rail": "将面板移回侧栏旁",
|
||||
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
|
||||
}
|
||||
@@ -1224,6 +1224,8 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "助航標誌",
|
||||
"VIRTUAL": "虛擬",
|
||||
"{speed}KT": "{speed} kn",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS:實時",
|
||||
@@ -3518,5 +3520,19 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫",
|
||||
"The globe stopped drawing": "地球已停止繪製",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
|
||||
"Reload": "重新加載",
|
||||
"Retry": "重試",
|
||||
"Reset view": "重置視圖",
|
||||
"Trolleybus": "無軌電車",
|
||||
"Cable car": "纜索電車",
|
||||
"Monorail": "單軌列車",
|
||||
"Funicular": "纜索鐵路",
|
||||
"Gondola": "纜車",
|
||||
"Move panels back beside the rail": "將面板移回側欄旁",
|
||||
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
|
||||
}
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user