Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 5dfa7ca32d docs(changelog): add the #1717 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:18:40 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 ef6c30d4c3 perf(wind): decode forecast GRIB in a worker and reuse the cycle's wind
Each /api/wind refresh decoded two or three 0.25 degree GRIB2 messages with
ecCodes (WASM), validated ~1.04 M doubles per message and resampled them, all
synchronously on the server thread. Measured with cached upstream bytes: 43
to 164 ms of event-loop block per warm refresh, 230 to 381 ms for the first
one in a process, 316 to 974 ms when all six keys refresh together.

Decode, validation and resampling now run on one lazily started worker
thread, which transfers back only the display-grid samples. The server keeps
the sampled U/V of the last forecast object per model (1.04 MB each), so an
overlay change, or a refresh within the same forecast step, fetches and
decodes only the scalar. Manifests and grid bodies are byte-identical to
before for the same upstream bytes.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:15:20 +01:00
247 changed files with 1469 additions and 13850 deletions

No files matched your search

+1 -1
View File
@@ -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,AidsToNavigationReport
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
# 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
+5 -2
View File
@@ -564,8 +564,11 @@ 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), resamples to a compact grid (1° by
default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached
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
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.
+1 -1
View File
@@ -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:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
@@ -1,6 +0,0 @@
- When the globe stops drawing after a render error or a lost WebGL context,
an alert dialog says so instead of leaving a frozen or black globe behind
working controls. Retry restarts drawing (Reload after a lost context), and
Reset view reopens the app without the view restored from the address bar.
Cesium's "Rendering has stopped" panel no longer appears in the app
(German, @gekh, #1579).
-12
View File
@@ -1,12 +0,0 @@
- Let an application add its own Cockpit subjects through
`services.cockpitSubjects`. A provider supplies the subject's position,
heading and HUD (in the page's language, via `getLocale` and
`onLocaleChange` from `./i18n`); the camera can follow it without the
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
banner, a veil (off under reduced motion) and a bottom progress line. The
Rail and Transit layers expose read and hold APIs so a selected train or
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
provider. GEV itself registers none, and entry to the built-in aircraft,
satellite and vessel views is unchanged; a provider's subject takes entry
when it was selected after them (Denis Groshev, @ZloyS, #1686).
@@ -1,3 +0,0 @@
- Draw a vessel whose feed sends no length at its ship type's typical size, so
a cruise ship or container ship no longer shows as its type's smallest model
(samehkhamis, #1715).
+4
View File
@@ -0,0 +1,4 @@
- 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).
@@ -1,5 +0,0 @@
- Collapse the CCTV panel when the layer is switched off. It stayed open with
an empty frame and a meta line that still described the last camera; the
meta line of a disabled layer now reads the catalog count. Contacts, Space
Missions and Clear selected data layers leave the panel as it is
(asturksever, #1723).
@@ -1,5 +0,0 @@
- Draw airport ground vehicles and ground stations as what they are instead of
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
radio mast, and one with no altitude stands on the ground instead of at the
10 km airborne default (samehkhamis, #1729).
@@ -1,4 +0,0 @@
- Draw airport ground vehicles as real shapes instead of stacked boxes: the
car is an operations SUV, the van a high-roof panel van, the fire truck an
airport crash tender with turrets and the pushback a low tug with a glazed
cab, all on the ground at their built size (samehkhamis, #1730).
@@ -1,3 +0,0 @@
- Draw aircraft models in their own mesh colours instead of a 94% flat
white (amber for military) over the model; the tracked and emergency
colours now tint the mesh without hiding its paint (samehkhamis, #1731).
@@ -1,4 +0,0 @@
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
or a local receiver) now faces its reported true heading instead of north,
so one taxiing south no longer looks like it is moving backwards
(samehkhamis, #1732).
-11
View File
@@ -1,11 +0,0 @@
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
hand, safe water, isolated danger or special mark), standing still with no
heading, and its icon is a disc rather than a chevron. Its card and readout
show its kind of mark with no speed or heading. AISStream now subscribes to
aid-to-navigation reports and names each by its kind of mark and its full
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
counts ships only. A virtual aid (a mark broadcast where nothing physical
stands) keeps its disc, is never drawn as a buoy, and its card says it is
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
@@ -1,10 +0,0 @@
- 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).
-5
View File
@@ -1,5 +0,0 @@
- 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).
@@ -1,9 +0,0 @@
- 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).
-7
View File
@@ -1,7 +0,0 @@
- 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).
-122
View File
@@ -230,125 +230,3 @@ 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 })`.
+9 -218
View File
@@ -365,25 +365,6 @@ 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
@@ -646,36 +627,6 @@ 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,
@@ -2257,83 +2208,6 @@ 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,
@@ -2355,17 +2229,6 @@ shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal
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
@@ -2580,53 +2443,6 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
detection is the backstop for when no probe context can be created. Applied
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
@@ -3474,8 +3290,7 @@ 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`, and card and detail ages count back from that time instead of
now. The dev proxy never sets `at`.
`window_end`. 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.
@@ -5918,26 +5733,7 @@ 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, 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.
identify buses, trams, subways, trains, ferries and unclassified vehicles.
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
@@ -6003,10 +5799,9 @@ 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. 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
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
`#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
@@ -7499,9 +7294,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, 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.
- 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.
- 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.
@@ -8431,11 +8226,7 @@ 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, 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
3 hours for hourly 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
View File
@@ -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,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" />
<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" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
-1
View File
@@ -298,7 +298,6 @@
},
"./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"
-6
View File
@@ -19,12 +19,6 @@ 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
+35 -54
View File
@@ -726,18 +726,18 @@
"file": "other/car.glb",
"min": [
-2.31,
-1.075,
0.018
-0.906,
0.019
],
"max": [
2.31,
1.075,
1.84
2.3,
0.906,
1.948
],
"length": 4.62,
"beam": 2.15,
"length": 4.61,
"beam": 1.812,
"clearance": 0.0,
"tris": 736,
"tris": 206,
"icon": "vehicle"
},
"car_carrier": {
@@ -1124,19 +1124,19 @@
"category": "other",
"file": "other/fire_truck.glb",
"min": [
-6.408,
-1.507,
0.029
-5.51,
-1.5,
0.027
],
"max": [
5.51,
1.507,
3.64
5.5,
1.5,
4.08
],
"length": 11.918,
"beam": 3.014,
"length": 11.01,
"beam": 3.0,
"clearance": 0.0,
"tris": 1016,
"tris": 314,
"icon": "vehicle"
},
"fishing_boat": {
@@ -1751,19 +1751,19 @@
"category": "other",
"file": "other/pushback.glb",
"min": [
-3.45,
-1.398,
0.025
-3.0,
-1.45,
0.024
],
"max": [
3.05,
1.398,
2.14
3.0,
1.45,
2.1
],
"length": 6.5,
"beam": 2.795,
"length": 6.0,
"beam": 2.9,
"clearance": 0.0,
"tris": 490,
"tris": 176,
"icon": "vehicle"
},
"r44": {
@@ -1785,25 +1785,6 @@
"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",
@@ -2303,18 +2284,18 @@
"file": "other/van.glb",
"min": [
-2.76,
-1.22,
0.018
-1.0,
0.019
],
"max": [
2.76,
1.22,
2.56
2.75,
1.0,
2.438
],
"length": 5.52,
"beam": 2.44,
"length": 5.51,
"beam": 2.0,
"clearance": 0.0,
"tris": 736,
"tris": 206,
"icon": "vehicle"
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-44
View File
@@ -983,14 +983,6 @@
"SHIP": [
"blimp",
75.0
],
"ZVEH": [
"car",
4.61
],
"GRND": [
"radio_mast",
1.9
]
},
"helicopters": {
@@ -1151,41 +1143,12 @@
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,
@@ -1225,7 +1188,6 @@
80,
89
],
"typical_len": 250,
"rules": [
{
"name_re": "\\bLNG\\b|\\bGAS\\b",
@@ -1252,7 +1214,6 @@
60,
69
],
"typical_len": 140,
"rules": [
{
"min_len": 70,
@@ -1282,7 +1243,6 @@
37,
37
],
"typical_len": 19,
"rules": [
{
"min_len": 35,
@@ -1313,7 +1273,6 @@
30,
30
],
"typical_len": 24,
"rules": [
{
"min_len": 15,
@@ -1384,7 +1343,6 @@
55,
55
],
"typical_len": 40,
"rules": [
{
"min_len": 25,
@@ -1400,7 +1358,6 @@
40,
49
],
"typical_len": 40,
"rules": [
{
"min_len": 20,
@@ -1416,7 +1373,6 @@
33,
33
],
"typical_len": 100,
"rules": [
{
"min_len": 50,
+1 -28
View File
@@ -557,13 +557,6 @@
"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",
@@ -1436,28 +1429,8 @@
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs",
"src/cockpitSubjectProviders.js",
"src/cockpitSubjectProviders.test.mjs",
"src/cockpitProgress.js",
"src/cockpitProgress.test.mjs",
"src/ui/cockpitSubjectView.test.mjs",
"src/ui/styles/cockpitSubject.css",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/cockpitSubject.test.mjs",
"src/layers/transit/cockpitSubject.js",
"src/data/fleetModels.test.mjs",
"src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs",
"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"
"scripts/measure-fleet-trail-anchors.mjs"
]
+5 -46
View File
@@ -22,9 +22,6 @@
"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",
@@ -154,8 +151,6 @@
"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",
@@ -168,7 +163,6 @@
"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",
@@ -253,7 +247,6 @@
"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",
@@ -1016,8 +1009,6 @@
"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",
@@ -1138,7 +1129,6 @@
"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",
@@ -1184,8 +1174,6 @@
"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",
@@ -1368,7 +1356,6 @@
"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",
@@ -1518,7 +1505,6 @@
"exports": ["./layers/flights"],
"modules": [
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
@@ -1588,8 +1574,7 @@
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/data/planespottersPhoto.js",
"src/layers/flights/placed.js"
"src/data/planespottersPhoto.js"
],
"external": ["cesium"]
},
@@ -1599,7 +1584,6 @@
"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",
@@ -1662,8 +1646,7 @@
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/layers/flights/placed.js"
"src/time/julian.js"
],
"external": ["cesium"]
},
@@ -3092,7 +3075,6 @@
"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",
@@ -3133,8 +3115,6 @@
"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",
@@ -3152,7 +3132,6 @@
"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",
@@ -3323,7 +3302,6 @@
"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",
@@ -3350,7 +3328,6 @@
"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",
@@ -3826,9 +3803,7 @@
"src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js",
"src/data/openVesselPhoto.js",
"src/layers/vessels/photos.js",
"src/layers/flights/placed.js",
"src/data/placementProvider.js"
"src/layers/vessels/photos.js"
],
"external": [
"hls.js",
@@ -4315,7 +4290,6 @@
"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",
@@ -4341,7 +4315,6 @@
"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",
@@ -4546,9 +4519,7 @@
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js",
"src/layers/flights/placed.js",
"src/data/placementProvider.js"
"src/data/openVesselPhoto.js"
],
"external": [
"hls.js",
@@ -4828,10 +4799,7 @@
"src/data/approxDistance.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js"
"src/data/aircraftEmergency.js"
],
"external": []
},
@@ -5221,7 +5189,6 @@
"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",
@@ -5259,7 +5226,6 @@
"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",
@@ -5314,7 +5280,6 @@
"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",
@@ -5331,7 +5296,6 @@
"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",
@@ -5954,10 +5918,5 @@
"src/layers/wildlifeSightings/performance.js"
],
"external": []
},
"rail-paths": {
"exports": ["./layers/rail/paths"],
"modules": ["src/data/railPaths.js"],
"external": []
}
}
-75
View File
@@ -339,45 +339,6 @@ 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(
() =>
@@ -390,42 +351,6 @@ 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' }),
);
-22
View File
@@ -202,25 +202,3 @@ 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');
});
-2
View File
@@ -27,8 +27,6 @@ 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
+6 -41
View File
@@ -1,8 +1,4 @@
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
@@ -116,21 +112,13 @@ 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:
(aidReport && aidNameFromAis(message)) ||
vesselNameFromAis(metadata, message, staticData) ||
`MMSI ${mmsi}`,
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
mmsi,
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
// 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),
type: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination),
length: staticData.length ?? null,
beam: staticData.beam ?? null,
@@ -143,9 +131,6 @@ export function ingestAisStreamEnvelope(envelope) {
// Use the AIS message's own report time, not server ingest wall-clock —
// 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(),
});
@@ -291,15 +276,6 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
);
}
/**
* An aid's whole name: an aid-to-navigation report carries the first 20
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
*/
function aidNameFromAis(message) {
const name = stringValue(message.Name);
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
}
function vesselTypeFromAis(message, staticData = {}) {
return stringValue(
message.Type ??
@@ -309,25 +285,14 @@ function vesselTypeFromAis(message, staticData = {}) {
);
}
/**
* The current rows, newest first, with every vessel ahead of every aid to
* navigation: a capped request never gives up a ship for a buoy.
*/
export function aisStreamRows(maxRows) {
const cutoff = Date.now() - AISSTREAM_STALE_MS;
const vessels = [];
const aids = [];
const rows = [];
for (const row of _aisStreamVessels.values()) {
if (row._updatedAt >= cutoff)
(isAidToNavigation(row) ? aids : vessels).push(row);
if (row._updatedAt >= cutoff) rows.push(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);
rows.sort((a, b) => b._updatedAt - a._updatedAt);
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
}
function pruneAisStreamCache() {
+5 -2
View File
@@ -1,6 +1,6 @@
import { fetchGfsWind } from './wind/gfs.js';
import { fetchIfsWind } from './wind/ifs.js';
import { decodeWindGribMessage } from './wind/decode.js';
import { decodeWindGribInWorker } from './wind/decoder.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 = decodeWindGribMessage,
decodeImpl = decodeWindGribInWorker,
targetDx = 1,
ttlMs = 3600_000,
timeoutMs = 40_000,
@@ -22,6 +22,8 @@ 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,
@@ -54,6 +56,7 @@ export function windProxy({
targetDx,
overlay,
signal: controller.signal,
windCache,
});
controller.signal.throwIfAborted();
const { grid, cycle } = value;
+6
View File
@@ -0,0 +1,6 @@
// 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);
+17
View File
@@ -1,3 +1,5 @@
import { sampleWindField } from './grid.js';
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
let eccodesPromise = null;
function loadEccodes() {
@@ -49,3 +51,18 @@ 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) });
}
});
}
+108
View File
@@ -0,0 +1,108 @@
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);
}
+86 -20
View File
@@ -125,6 +125,7 @@ export async function loadOptionalScalar({
fetchImpl,
decodeImpl,
signal,
targetDx = 1,
timeoutMs = 12_000,
}) {
if (!range) return undefined;
@@ -140,7 +141,13 @@ export async function loadOptionalScalar({
fetchImpl,
signal: controller.signal,
});
const value = await decodeImpl(bytes);
// 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,
);
controller.signal.throwIfAborted();
return value;
} catch {
@@ -158,17 +165,80 @@ import {
selectLatestGfsCycle,
nearestGfsStep,
} from './catalog.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.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;
}
/** 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 = decodeWindGribMessage,
decodeImpl = decodeWindGribInWorker,
signal,
overlay = 'none',
windCache,
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
@@ -194,22 +264,18 @@ export async function fetchGfsWind({
/* A missing optional field does not discard valid wind. */
}
}
// 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 fields = await loadWeatherSnapshot({
model: 'gfs',
url: base,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
});
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
+127 -35
View File
@@ -48,38 +48,133 @@ 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');
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 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;
};
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 y1 = Math.min(nj - 1, y0 + 1);
const fy = sy - y0;
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;
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),
};
}
/** 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 (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
if (!values.every(Number.isFinite))
throw new Error('Invalid resampled wind values');
@@ -130,20 +225,17 @@ 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');
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,
// 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,
...(metadata
? {
scalar: scalar.values,
scalar: sampledScalar,
scalarScale: overlay === 'pressure' ? 0.01 : 1,
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
}
+17 -22
View File
@@ -1,6 +1,6 @@
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
import { fetchText, loadWeatherSnapshot } from './gfs.js';
import { decodeWindGribInWorker } from './decoder.js';
import { weatherScalarMetadata } from './grid.js';
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
const IFS_BASE = 'https://data.ecmwf.int/forecasts';
@@ -110,9 +110,10 @@ export async function fetchIfsWind({
fetchImpl = fetch,
now = () => Date.now(),
targetDx = 1,
decodeImpl = decodeWindGribMessage,
decodeImpl = decodeWindGribInWorker,
signal,
overlay = 'none',
windCache,
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
@@ -137,24 +138,18 @@ export async function fetchIfsWind({
/* A missing optional field does not discard valid wind. */
}
}
// 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 fields = await loadWeatherSnapshot({
model: 'ifs',
url: urls.grib,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
});
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
@@ -400,27 +400,6 @@ test('Enter finishes from the canvas and the label field, never from a focused b
}
});
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
// draws again and the control that had focus is gone.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
} finally {
h.restore();
}
});
test('the live preview hangs at the height the click landed on', async () => {
const h = harness();
try {
-10
View File
@@ -6,7 +6,6 @@ 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';
@@ -64,9 +63,6 @@ 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,
@@ -78,7 +74,6 @@ export function createApplicationCatalog({
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
placement = null,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -102,20 +97,17 @@ 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 } : {}),
@@ -180,7 +172,6 @@ export function createApplicationCatalog({
createApplicationTransit({
surface,
source: sources.transit,
placement: layerPlacement(placement, 'transit'),
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
@@ -219,7 +210,6 @@ export function createApplicationCatalog({
createApplicationRail({
surface,
source: sources.rail,
placement: layerPlacement(placement, 'rail'),
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
-2
View File
@@ -11,8 +11,6 @@ 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 },
+28 -11
View File
@@ -94,6 +94,17 @@ 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
@@ -111,27 +122,33 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// 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).
// 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),
);
},
);
const timer = windowRef.setInterval(() => {
if (
viewer.useDefaultRenderLoop === false ||
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
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.
}
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
+27 -14
View File
@@ -457,7 +457,8 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
assert.equal(frame, null);
});
test('a panel stops drawing while its render loop is stopped', () => {
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
@@ -466,19 +467,31 @@ test('a panel stops drawing while its render loop is stopped', () => {
clearInterval() {},
};
const drawn = [];
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();
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;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
-3
View File
@@ -16,11 +16,9 @@ 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,
@@ -40,7 +38,6 @@ export function createApplicationVessels({
worldFocus,
render,
readouts,
placement,
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
+1 -4
View File
@@ -12,13 +12,11 @@ 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.
* `placement`: a host-supplied placement for its live aircraft, or null. */
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
militaryRegistry,
placement = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
@@ -43,7 +41,6 @@ export function createApplicationFlights({
render,
groundSnap,
recession,
placement,
},
});
}
+1 -4
View File
@@ -12,13 +12,11 @@ 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.
* `placement`: a host-supplied placement for its live aircraft, or null. */
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
militaryRegistry,
placement = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
@@ -43,7 +41,6 @@ export function createApplicationMilitary({
render,
groundSnap,
recession,
placement,
},
});
}
+1 -4
View File
@@ -16,15 +16,13 @@ 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.
* `placement` is a host-supplied placement for its live trains, or null.
* @param {{surface: object, source?: object, placement?: object|null,
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationRail({
surface,
source,
placement = null,
displayParams = null,
resolveAsset,
}) {
@@ -38,7 +36,6 @@ export function createApplicationRail({
sprites,
picking,
overlays,
placement,
credits: { registerDynamicCredit, railFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
+1 -4
View File
@@ -23,15 +23,13 @@ import {
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby vehicles are drawn as fleet models.
*
* `placement` is a host-supplied placement for its live vehicles, or null.
* @param {{surface: object, source?: object, placement?: object|null,
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationTransit({
surface,
source,
placement = null,
displayParams = null,
resolveAsset,
}) {
@@ -45,7 +43,6 @@ export function createApplicationTransit({
sprites,
picking,
overlays,
placement,
credits: { registerDynamicCredit, transitFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
-120
View File
@@ -1,120 +0,0 @@
// Retry in an inline panel whose host stops animation frames, so the panel
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { keepPanelRendering } from './embed.js';
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
function setup() {
let tick = null;
let clock = 0;
// The host reports the panel hidden: its own animation frames never arrive.
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const previousError = console.error;
console.error = () => {};
const state = { failure: null, drawn: 0 };
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {},
initializeFrame() {},
// Scene.render: a failing primitive update raises renderError
// (tryAndCatchError) and returns; a failing listener throws.
render() {
state.drawn += 1;
this.preUpdate.raiseEvent(this);
if (state.failure === 'caught')
this.renderError.raiseEvent(this, new Error('boom'));
if (state.failure === 'thrown') throw new Error('boom');
this.postRender.raiseEvent(this);
},
};
const { frames, widget, viewer, store, runFrames, restore } =
renderLoopFixture(scene, {
viewer: {
isDestroyed: () => false,
render: () => widget.render(),
resize: () => widget.resize(),
},
notice: { allowReload: false },
});
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
viewer.useDefaultRenderLoop = true;
const timerFrame = () => {
clock += 400;
tick();
};
return {
frames,
state,
store,
timerFrame,
animationFrames: runFrames,
restore: () => {
stop();
restore();
console.error = previousError;
},
};
}
for (const failure of ['caught', 'thrown']) {
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
// The host stops animation frames with the loop's next one queued.
assert.equal(frames.length, 1);
state.failure = failure;
timerFrame(); // an error thrown here does not reach the host page
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(typeof store.renderFailure.retry, 'function');
state.failure = null;
store.renderFailure.retry();
const before = state.drawn;
timerFrame();
assert.ok(state.drawn > before, 'the panel draws again after Retry');
assert.equal(
store.renderFailure,
null,
'a clean frame clears the notice',
);
assert.equal(frames.length, 1, 'Retry starts no second loop');
// The host resumes frames: the globe draws once a frame.
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
}
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
state.failure = 'caught';
animationFrames(1);
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(frames.length, 1);
state.failure = null;
store.renderFailure.retry();
timerFrame();
assert.equal(store.renderFailure, null);
assert.equal(frames.length, 1, 'Retry starts no second loop');
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
-8
View File
@@ -15,8 +15,6 @@ 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';
@@ -57,17 +55,11 @@ 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'), {
+4 -16
View File
@@ -375,15 +375,8 @@ export function installGlobeInViewRecovery(
};
}
/**
* 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 } = {},
) {
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
timeline: false,
animation: false,
@@ -403,21 +396,16 @@ export function applicationViewerOptions(
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
showRenderLoopErrors,
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({
container,
creditContainer,
showRenderLoopErrors,
}) {
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(
container,
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
applicationViewerOptions(creditContainer),
);
try {
viewer.targetFrameRate = 60;
+2 -68
View File
@@ -32,15 +32,8 @@ 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,
maxDtSec = 0.1,
) {
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
if (
!Number.isFinite(distanceM) ||
distanceM <= 0 ||
@@ -48,36 +41,13 @@ export function cockpitAnchorCorrectionStep(
dtSec <= 0
)
return 0;
const dt = Math.min(
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
dtSec,
);
const dt = Math.min(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)
@@ -98,42 +68,6 @@ 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.
-52
View File
@@ -8,11 +8,8 @@ import {
cockpitAnchorCorrectionStep,
cockpitAltitudeDisplayFt,
cockpitGroundSafeHeight,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
easeCockpitLift,
compassDivisions,
compassMarkerSlot,
scrollingCompassDivisions,
@@ -59,28 +56,6 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
});
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);
@@ -460,30 +435,3 @@ 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);
});
-182
View File
@@ -1,182 +0,0 @@
/**
* @module cockpitProgress
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
* at a width in pixels: where each tick, label and the marker go.
*
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
* with each other at their mean place. A merged tick takes the strongest tone
* of those it holds. Labels never overlap: where two would, the one of lower
* priority is left out (on a tie, the later one).
*
* Pure: no DOM.
*/
/** Bare ticks closer than this merge (px). */
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
/** Room kept between two labels (px). */
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
/** Tones from weakest to strongest, for a merged tick. */
const TONE_RANK = Object.freeze([
'muted',
'normal',
'live',
'estimate',
'warning',
'alert',
]);
const stronger = (a, b) =>
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
/** A label's width estimate (px): its longer line in small capitals. */
export function estimateProgressLabelPx(label) {
const longest = Math.max(
String(label?.text ?? '').length,
String(label?.detail ?? '').length,
);
return longest * 6.4 + 8;
}
/**
* The progress line at `widthPx`, or null when there is nothing to draw.
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
* @param {number} widthPx
* @param {{minTickPx?: number, gapPx?: number,
* measure?: (label: object) => number}} [options]
* @returns {{ticks: Array<{x: number, tone: string, count: number,
* labelled: boolean}>, labels: Array<{x: number, left: number,
* width: number, align: string, text: string, detail: string,
* tone: string}>, marker: {x: number, dwell: boolean}|null,
* travelledX: number, tag: string}|null}
*/
export function cockpitProgressLayout(
progress,
widthPx,
{
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
measure = estimateProgressLabelPx,
} = {},
) {
const ticks = progress?.ticks;
if (!Array.isArray(ticks) || ticks.length < 2) return null;
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
const placed = ticks.map((tick, order) => ({
x: tick.at * widthPx,
tone: tick.tone,
label: tick.label,
order,
}));
const labelled = placed.filter((tick) => tick.label);
// Bare ticks beside a labelled one go into it.
const kept = labelled.map((tick) => ({
x: tick.x,
tone: tick.tone,
count: 1,
labelled: true,
label: tick.label,
order: tick.order,
}));
const bare = [];
for (const tick of placed) {
if (tick.label) continue;
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
if (host) {
host.tone = stronger(host.tone, tick.tone);
host.count += 1;
} else bare.push(tick);
}
// Bare ticks in a chain closer than minTickPx merge at their mean place.
let cluster = null;
const flush = () => {
if (!cluster) return;
kept.push({
x: cluster.sum / cluster.count,
tone: cluster.tone,
count: cluster.count,
labelled: false,
label: null,
order: cluster.order,
});
cluster = null;
};
for (const tick of bare) {
if (cluster && tick.x - cluster.last < minTickPx) {
cluster.sum += tick.x;
cluster.count += 1;
cluster.last = tick.x;
cluster.tone = stronger(cluster.tone, tick.tone);
} else {
flush();
cluster = {
sum: tick.x,
count: 1,
last: tick.x,
tone: tick.tone,
order: tick.order,
};
}
}
flush();
kept.sort((a, b) => a.x - b.x || a.order - b.order);
// Labels by priority; one that would overlap a placed label is left out.
const boxes = [];
const candidates = kept
.filter((tick) => tick.labelled)
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
for (const tick of candidates) {
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
let left =
tick.label.align === 'start'
? tick.x
: tick.label.align === 'end'
? tick.x - width
: tick.x - width / 2;
left = Math.min(Math.max(0, left), widthPx - width);
const right = left + width;
if (
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
)
continue;
boxes.push({
x: tick.x,
left,
right,
width,
align: tick.label.align,
text: tick.label.text,
detail: tick.label.detail,
tone: tick.tone,
order: tick.order,
});
}
boxes.sort((a, b) => a.left - b.left);
const marker = progress.marker
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
: null;
return {
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
x,
tone,
count,
labelled: isLabelled,
})),
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
x,
left,
width,
align,
text,
detail,
tone,
})),
marker,
travelledX: marker ? marker.x : 0,
tag: progress.tag || '',
};
}
-144
View File
@@ -1,144 +0,0 @@
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
// width: close bare ticks merge, labelled ticks keep their place, labels never
// overlap (the lower priority gives way) and the part before the marker is
// the travelled part.
import test from 'node:test';
import assert from 'node:assert/strict';
import { cockpitProgressLayout } from './cockpitProgress.js';
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
const label = (text, priority, align = 'center', detail = '') => ({
text,
detail,
priority,
align,
});
/** Every label is 50 px wide. */
const measure = () => 50;
test('nothing is drawn without two ticks or a width', () => {
assert.equal(cockpitProgressLayout(null, 500), null);
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
assert.equal(
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
null,
);
});
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.1),
tick(0.101, null, 'warning'),
tick(0.102),
tick(0.5),
tick(1, label('Z', 1, 'end')),
],
marker: { at: 0.3, dwell: false },
tag: 'TIMETABLE',
},
1000,
{ measure },
);
assert.deepEqual(
layout.ticks.map(({ x, count, tone, labelled }) => [
Math.round(x),
count,
tone,
labelled,
]),
[
[0, 1, 'normal', true],
[101, 3, 'warning', false],
[500, 1, 'normal', false],
[1000, 1, 'normal', true],
],
);
assert.deepEqual(layout.marker, { x: 300, dwell: false });
assert.equal(layout.travelledX, 300);
assert.equal(layout.tag, 'TIMETABLE');
});
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.498),
tick(0.5, label('Next', 3), 'alert'),
tick(0.503, null, 'warning'),
tick(1, label('Z', 1, 'end')),
],
marker: null,
},
1000,
{ measure },
);
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
assert.equal(next.count, 3);
assert.equal(next.labelled, true);
assert.equal(next.tone, 'alert');
assert.equal(layout.ticks.length, 3);
assert.equal(layout.marker, null);
assert.equal(layout.travelledX, 0);
});
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('First', 1, 'start')),
tick(0.04, label('Previous', 2)),
tick(0.5, label('Next', 3)),
tick(0.52, label('Close', 0)),
tick(1, label('Last', 1, 'end')),
],
marker: { at: 0.2, dwell: true },
},
1000,
{ measure },
);
assert.deepEqual(
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
[
['Previous', 15],
['Next', 475],
['Last', 950],
],
);
// The first stop's label would cover the previous stop's: it gives way.
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
assert.deepEqual(layout.marker, { x: 200, dwell: true });
});
test('a centred label at an edge is clamped inside the line', () => {
const layout = cockpitProgressLayout(
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
300,
{ measure },
);
assert.equal(layout.labels[0].left, 0);
const right = cockpitProgressLayout(
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
300,
{ measure },
);
assert.equal(right.labels[0].left, 250);
});
test('a long run of calls still lays out, every one a tick or part of one', () => {
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
ticks[0].label = label('First', 1, 'start');
ticks[59].label = label('Last', 1, 'end');
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
const count = (layout) =>
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
assert.equal(count(narrow), 60);
assert.equal(count(wide), 60);
assert.ok(narrow.ticks.length < wide.ticks.length);
assert.equal(wide.ticks.length, 60);
});
-644
View File
@@ -1,644 +0,0 @@
/**
* @module cockpitSubjectProviders
* @description Subject providers: kinds of subject an application adds to the
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
* subject entry would take, holds it while the view rides it, hands the camera
* a frame each update and the HUD a view model, and releases it on exit. Core
* knows nothing else about it.
*
* Everything a provider returns is normalized here before the Cockpit uses
* it: a malformed provider is left out, a malformed frame ends the view, and a
* malformed HUD view is read field by field, never thrown on.
*
* Pure: no Cesium, no DOM.
*/
/** The kinds the Cockpit rides itself; no provider may take one. */
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
'aircraft',
'satellite',
'vessel',
]);
/** The tones a provider's HUD texts may take. */
export const COCKPIT_SUBJECT_TONES = Object.freeze([
'normal',
'muted',
'live',
'estimate',
'warning',
'alert',
]);
/**
* The heights a frame may give (m, ellipsoidal): below the deepest dry
* ground, above anything ridden near it; outside, the frame stands on the
* floor.
*/
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
/** The most readouts, sources and signals a HUD view shows. */
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
/** The most ticks a progress line takes. */
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
const REQUIRED_METHODS = Object.freeze([
'candidate',
'hold',
'read',
'hud',
'release',
]);
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
const TONES = new Set(COCKPIT_SUBJECT_TONES);
const isObject = (value) =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
/**
* `value` as display text: trimmed, at most `max` characters (the last one
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
* kept whole or left out); '' for anything that is not a string or a number.
*/
export function cockpitSubjectText(value, max) {
if (typeof value !== 'string' && !finite(value)) return '';
const text = String(value).replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
let end = max - 1;
const last = text.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
return `${text.slice(0, end)}…`;
}
const tone = (value) => (TONES.has(value) ? value : 'normal');
/** A Material Symbols entry face, or null. */
function faceOf(raw) {
if (!isObject(raw)) return null;
const label = cockpitSubjectText(raw.label, 16);
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
return null;
return Object.freeze({ icon: raw.icon, label });
}
/** The labels a provider gives, normalized, or null without the required. */
function labelsOf(raw) {
if (!isObject(raw)) return null;
const labels = {
view: cockpitSubjectText(raw.view, 48),
exit: cockpitSubjectText(raw.exit, 48),
exitText: cockpitSubjectText(raw.exitText, 24),
kicker: cockpitSubjectText(raw.kicker, 48),
};
return labels.view && labels.exit && labels.exitText
? Object.freeze(labels)
: null;
}
/** A provider's look, normalized, or null without an aheadLabel. */
function lookOf(raw) {
if (!isObject(raw)) return null;
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
if (!aheadLabel) return null;
return Object.freeze({
restPitchDeg: finite(raw.restPitchDeg)
? clamp(raw.restPitchDeg, -90, 90)
: 0,
aheadLabel,
});
}
/**
* A provider's worded member `key` as it reads now, normalized: the value,
* or what the function it is returns when called on `provider` (so it can
* follow the page's language); null when it is malformed or a read throws.
* Reading the member, and reading what it gives, runs provider code too (a
* getter), so both are guarded as the call is.
*/
function wordedNow(provider, key, normalize) {
try {
const value = provider[key];
return (
normalize(typeof value === 'function' ? value.call(provider) : value) ??
null
);
} catch {
return null;
}
}
/** A provider's name in a warning: its kind, cut, or its place in the list. */
function providerName(provider, order) {
try {
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
} catch {
// A kind getter that throws gives no name.
}
return `#${order}`;
}
/** Why `provider` cannot be kept, or null. */
function refusal(provider, kinds) {
if (!isObject(provider)) return 'not an object';
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
return 'malformed kind';
if (RESERVED.has(provider.kind)) return 'reserved kind';
if (kinds.has(provider.kind)) return 'repeated kind';
if (
typeof provider.layerId !== 'string' ||
!LAYER_ID_PATTERN.test(provider.layerId)
)
return 'malformed layer id';
for (const method of REQUIRED_METHODS)
if (typeof provider[method] !== 'function') return `no ${method}()`;
if (
provider.isEntryAllowed !== undefined &&
typeof provider.isEntryAllowed !== 'function'
)
return 'isEntryAllowed is not a function';
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
if (!wordedNow(provider, 'labels', labelsOf))
return 'labels need view, exit and exitText';
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
return 'look needs an aheadLabel';
return null;
}
/**
* The application's subject providers, checked once and frozen, in the order
* entry asks them: ascending `priority`, ties in list order. Each has
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
* - `priority`: a finite number (default 0);
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
* most 16 characters, in the page's language;
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
* button's accessible names, the exit button's text and the default kicker,
* in the page's language;
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
* ahead button named `aheadLabel`;
* `entryFace`, `labels` and `look` may each be a function returning the
* object: it is called on the provider again whenever they are shown and
* when the page's language changes, so its words can follow it
* (`./i18n`). A read that throws or is malformed keeps the last
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
* updates the camera every rendered frame;
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
* positive or Infinity, null for the Cockpit's default;
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
* synchronous (createCockpitSubjectCaller) and called on the provider, so
* a class instance's methods keep their `this`. `isEntryAllowed` allows
* entry only by returning `true`. `hold` returns `true` once it holds the
* subject, or `false` to refuse entry; any other answer refuses it too,
* with one warning. A refused hold, and one that throws or returns a
* promise, is followed by `release(id, {restore: false})`, and by another
* once that promise settles; `release` must therefore be harmless for a
* subject it does not hold or already gave back.
*
* A provider that is not so, or whose members throw when read (a getter), is
* left out with one warning naming it; the providers after it are kept.
* @param {unknown} list
* @param {{warn?: (message: string) => void}} [options]
* @returns {ReadonlyArray<object>}
*/
export function cockpitSubjectProviders(
list = [],
{ warn = (message) => console.warn(message) } = {},
) {
const kept = [];
const kinds = new Set();
(Array.isArray(list) ? list : []).forEach((provider, order) => {
let reason;
let copy = null;
try {
reason = refusal(provider, kinds);
if (!reason) copy = keptProvider(provider, order);
} catch {
reason = 'a member threw when read';
}
if (reason) {
warn(
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
);
return;
}
kinds.add(copy.kind);
kept.push(copy);
});
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
return Object.freeze(kept);
}
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
function keptProvider(provider, order) {
// Worded members are read again on each use (see above); the last good
// read stands in for one that is malformed or throws.
let face = wordedNow(provider, 'entryFace', faceOf);
let labels = wordedNow(provider, 'labels', labelsOf);
// On or off, and its rest pitch, as first read.
let look = wordedNow(provider, 'look', lookOf);
const restPitchDeg = look?.restPitchDeg ?? 0;
return Object.freeze({
kind: provider.kind,
layerId: provider.layerId,
priority: finite(provider.priority) ? provider.priority : 0,
order,
get entryFace() {
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
return face;
},
get labels() {
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
return labels;
},
get look() {
if (!look) return null;
const now = wordedNow(provider, 'look', lookOf);
if (now)
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
return look;
},
cameraIntervalMs:
finite(provider.cameraIntervalMs) &&
provider.cameraIntervalMs >= 0 &&
provider.cameraIntervalMs <= 1000
? provider.cameraIntervalMs
: null,
headingSlewDps:
provider.headingSlewDps === Infinity ||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
? provider.headingSlewDps
: null,
// Bound, so a method that uses `this` (a class instance's, say) still
// reads its own provider when called on this copy.
candidate: provider.candidate.bind(provider),
isEntryAllowed: provider.isEntryAllowed
? provider.isEntryAllowed.bind(provider)
: () => true,
hold: provider.hold.bind(provider),
read: provider.read.bind(provider),
hud: provider.hud.bind(provider),
release: provider.release.bind(provider),
});
}
/**
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
* usable id. `name` falls back to the id; `face` is null unless well formed;
* `selectedAt` (a number or a short string, else null) is a token the
* provider changes each time its subject is selected, even again: a
* subject selected after the built-in ones takes entry (entryCandidates).
* Null too when reading `raw` throws (a getter runs provider code).
* @param {unknown} raw What `candidate()` returned.
*/
export function cockpitSubjectCandidate(raw) {
try {
return candidateOf(raw);
} catch {
// A getter on what candidate() returned threw: no candidate.
return null;
}
}
function candidateOf(raw) {
if (!isObject(raw) || typeof raw.id !== 'string') return null;
const id = raw.id.trim();
if (!id || id.length > 128) return null;
const selectedAt =
finite(raw.selectedAt) ||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
? raw.selectedAt
: null;
return Object.freeze({
id,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
face: faceOf(raw.face),
selectedAt,
});
}
/**
* One camera frame from a provider, normalized, or null when it cannot place
* the camera (the view then ends): not an object, an id other than `heldId`,
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
* read of it that throws (a getter runs provider code).
* A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that
* is cold. The anchor is `'inertial'` only when asked for and the frame
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
* trimmed, as `cockpitSubjectCandidate` trims it.
* @param {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
* pitchDeg, minClearanceM}}`.
*/
export function cockpitSubjectFrame(raw, heldId) {
try {
return frameOf(raw, heldId);
} catch {
// A getter on what read() returned threw: as a malformed frame.
return null;
}
}
function frameOf(raw, heldId) {
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
return null;
const latitude = raw.latitude;
const longitude = raw.longitude;
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
return null;
const eye = isObject(raw.eye) ? raw.eye : null;
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
const heading = (value) =>
finite(value) ? ((value % 360) + 360) % 360 : null;
const headingDeg = heading(raw.headingDeg);
const speedMps =
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
return {
id: heldId,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
latitude,
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
heightM:
finite(raw.heightM) &&
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
? raw.heightM
: null,
headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps,
stale: raw.stale === true,
groundClamp: raw.groundClamp !== false,
// An inertial anchor runs on at the frame's speed; with none it would
// close on a moving subject at the correction's floor and fall behind.
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
eye: {
upM: eye.upM,
forwardM:
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
? eye.forwardM
: 0,
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
minClearanceM:
finite(eye.minClearanceM) && eye.minClearanceM >= 0
? eye.minClearanceM
: 0,
},
};
}
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
function rowsOf(raw, max, row) {
if (!Array.isArray(raw)) return [];
const rows = [];
const ids = new Set();
for (const entry of raw) {
if (rows.length >= max) break;
if (!isObject(entry)) continue;
const id = typeof entry.id === 'string' ? entry.id : '';
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
const read = row(entry);
if (!read) continue;
ids.add(id);
rows.push({ id, ...read });
}
return rows;
}
/** A progress line, or null without at least two ticks. */
function progressOf(raw) {
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
const ticks = [];
for (const tick of raw.ticks) {
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
continue;
let label = null;
if (isObject(tick.label)) {
const text = cockpitSubjectText(tick.label.text, 40);
if (text)
label = {
text,
detail: cockpitSubjectText(tick.label.detail, 64),
priority: finite(tick.label.priority)
? clamp(Math.round(tick.label.priority), 0, 9)
: 0,
align: ['start', 'center', 'end'].includes(tick.label.align)
? tick.label.align
: 'center',
};
}
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
}
if (ticks.length < 2) return null;
ticks.sort((a, b) => a.at - b.at);
const marker =
isObject(raw.marker) && finite(raw.marker.at)
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
: null;
return {
ticks,
marker,
tag: cockpitSubjectText(raw.tag, 16),
summary: cockpitSubjectText(raw.summary, 160),
};
}
/**
* A provider's HUD view model, normalized; never throws. Texts are coerced
* and cut to their caps, lists to their lengths, tones to
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
* Null when reading `raw` throws (a getter runs provider code), so the caller
* keeps the view it has, as when `hud()` itself throws.
* All text is shown as given: the provider words it in the page's language.
* @param {unknown} raw What `hud()` returned.
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
* sources: [{id, label, text, tone}],
* route: {title, from, to, status}|null,
* progress: {ticks: [{at, tone, label: {text, detail, priority,
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
* lookReadout, signals: [{id, tone, title, detail}]}`.
*/
export function cockpitSubjectHudView(raw) {
try {
return hudViewOf(raw);
} catch {
// A getter on what hud() returned threw: the caller keeps its last view.
return null;
}
}
function hudViewOf(raw) {
const view = isObject(raw) ? raw : {};
const meta = isObject(view.meta) ? view.meta : {};
const speed =
isObject(view.speed) &&
(view.speed.value === null ||
(finite(view.speed.value) && view.speed.value >= 0))
? {
value:
view.speed.value === null
? null
: Math.min(view.speed.value, 99_999),
unit: cockpitSubjectText(view.speed.unit, 12),
tag: cockpitSubjectText(view.speed.tag, 8),
tone: tone(view.speed.tone),
}
: null;
const route = isObject(view.route)
? {
title: cockpitSubjectText(view.route.title, 32),
from: cockpitSubjectText(view.route.from, 48),
to: cockpitSubjectText(view.route.to, 48),
status: cockpitSubjectText(view.route.status, 64),
}
: null;
const bannerTitle = isObject(view.banner)
? cockpitSubjectText(view.banner.title, 48)
: '';
const veil =
isObject(view.veil) && finite(view.veil.level)
? {
level: clamp(view.veil.level, 0, 1),
streaks: view.veil.streaks === true,
}
: null;
return {
kicker: cockpitSubjectText(view.kicker, 48),
title: cockpitSubjectText(view.title, 64),
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
speed,
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
const label = cockpitSubjectText(row.label, 24);
const value = cockpitSubjectText(row.value, 48);
return label || value
? {
label,
value,
detail: cockpitSubjectText(row.detail, 80),
tone: tone(row.tone),
}
: null;
}),
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
const text = cockpitSubjectText(row.text, 120);
return text
? {
label: cockpitSubjectText(row.label, 16),
text,
tone: tone(row.tone),
}
: null;
}),
route: route && (route.from || route.to) ? route : null,
progress: progressOf(view.progress),
banner: bannerTitle
? {
title: bannerTitle,
detail: cockpitSubjectText(view.banner.detail, 96),
tone: tone(view.banner.tone),
}
: null,
veil: veil && veil.level > 0 ? veil : null,
lookReadout: cockpitSubjectText(view.lookReadout, 48),
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
const title = cockpitSubjectText(row.title, 48);
return title
? {
title,
detail: cockpitSubjectText(row.detail, 96),
tone: tone(row.tone),
}
: null;
}),
};
}
/**
* A guarded caller for provider methods: `call(provider, method, args,
* fallback)` returns what the method returns, or `fallback` when it throws,
* warning once per provider and method.
*
* Provider methods are synchronous: the Cockpit calls them on its render
* cadence and uses the answer at once. One that returns a promise (an async
* function, say) breaks that contract: the promise is refused as a throw
* would be (its rejection, if any, is swallowed), with one warning, and
* `onSettled`, when given, runs once the promise has settled, so a caller
* can undo work that lands late.
*
* `call.warnOnce(provider, method, message, error)` warns on the same
* once-per-provider-and-method footing, for an answer the caller finds
* malformed.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
warn = (message, error) => console.warn(message, error),
} = {}) {
const warned = new Set();
const warnOnce = (provider, method, message, error) => {
const key = `${provider.kind}:${method}`;
if (warned.has(key)) return;
warned.add(key);
warn(
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
error,
);
};
function call(
provider,
method,
args = [],
fallback = null,
onSettled = null,
) {
let result;
let thenable;
try {
result = provider[method](...args);
// Reading `then` runs provider code too (a getter may throw), so it is
// guarded as the call is.
thenable =
result !== null &&
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function';
} catch (error) {
warnOnce(provider, method, 'failed', error);
return fallback;
}
if (thenable) {
// Its work may still land: `onSettled` runs once it has, either way.
const settled = () => {
if (typeof onSettled === 'function') onSettled();
};
Promise.resolve(result).then(settled, settled);
warnOnce(
provider,
method,
'returned a promise; provider methods are synchronous',
null,
);
return fallback;
}
return result;
}
call.warnOnce = warnOnce;
return call;
}
-698
View File
@@ -1,698 +0,0 @@
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
// are checked once and what they return is normalized before the Cockpit
// uses it: a malformed provider is left out, a malformed frame ends the view,
// and a malformed HUD view is read field by field and never thrown on.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
COCKPIT_BUILT_IN_KINDS,
COCKPIT_SUBJECT_MAX_READOUTS,
COCKPIT_SUBJECT_MAX_TICKS,
cockpitSubjectCandidate,
cockpitSubjectFrame,
cockpitSubjectHudView,
cockpitSubjectProviders,
cockpitSubjectText,
createCockpitSubjectCaller,
} from './cockpitSubjectProviders.js';
const noop = () => null;
/** A well-formed provider, with `overrides`. */
const provider = (overrides = {}) => ({
kind: 'example-kind',
layerId: 'example-layer',
entryFace: { icon: 'commute', label: 'RIDE' },
labels: {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
},
candidate: noop,
hold: () => true,
read: noop,
hud: noop,
release: noop,
...overrides,
});
const keep = (list) => {
const warnings = [];
const kept = cockpitSubjectProviders(list, {
warn: (message) => warnings.push(message),
});
return { kept, warnings };
};
test('well-formed providers are kept, frozen, in priority then list order', () => {
const { kept, warnings } = keep([
provider({ kind: 'late', priority: 5 }),
provider({ kind: 'first-of-two', priority: 1 }),
provider({ kind: 'second-of-two', priority: 1 }),
provider({ kind: 'default-priority' }),
]);
assert.deepEqual(warnings, []);
assert.deepEqual(
kept.map(({ kind }) => kind),
['default-priority', 'first-of-two', 'second-of-two', 'late'],
);
assert.ok(Object.isFrozen(kept));
assert.ok(Object.isFrozen(kept[0]));
const [first] = kept;
assert.equal(first.priority, 0);
assert.equal(first.look, null);
assert.equal(first.cameraIntervalMs, null);
assert.equal(first.headingSlewDps, null);
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
assert.deepEqual(first.labels, {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
kicker: '',
});
});
test('malformed, reserved and repeated providers are left out with one warning each', () => {
const cases = [
[null, /not an object/],
[provider({ kind: 'X' }), /malformed kind/],
[provider({ kind: 'a' }), /malformed kind/],
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
[provider({ hold: undefined }), /no hold\(\)/],
[provider({ read: 'read' }), /no read\(\)/],
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
];
for (const [entry, reason] of cases) {
const { kept, warnings } = keep([entry]);
assert.equal(kept.length, 0, String(reason));
assert.equal(warnings.length, 1, String(reason));
assert.match(warnings[0], reason);
}
for (const kind of COCKPIT_BUILT_IN_KINDS) {
const { kept, warnings } = keep([provider({ kind })]);
assert.equal(kept.length, 0, kind);
assert.match(warnings[0], /reserved kind/);
}
const { kept, warnings } = keep([provider(), provider()]);
assert.equal(kept.length, 1);
assert.match(warnings[0], /repeated kind/);
assert.deepEqual(keep('not a list').kept, []);
});
test('optional settings are kept when in range and defaulted otherwise', () => {
const [ranged] = keep([
provider({
priority: -2,
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
cameraIntervalMs: 0,
headingSlewDps: Infinity,
labels: {
view: 'v',
exit: 'e',
exitText: 'x',
kicker: 'K'.repeat(80),
},
}),
]).kept;
assert.equal(ranged.priority, -2);
assert.deepEqual(ranged.look, {
restPitchDeg: -90,
aheadLabel: 'Look ahead',
});
assert.equal(ranged.cameraIntervalMs, 0);
assert.equal(ranged.headingSlewDps, Infinity);
assert.equal(ranged.labels.kicker.length, 48);
assert.ok(ranged.labels.kicker.endsWith('…'));
const [defaulted] = keep([
provider({
priority: NaN,
cameraIntervalMs: 5000,
headingSlewDps: -1,
}),
]).kept;
assert.equal(defaulted.priority, 0);
assert.equal(defaulted.cameraIntervalMs, null);
assert.equal(defaulted.headingSlewDps, null);
});
test('a candidate needs an id; its name and face are read with care', () => {
assert.equal(cockpitSubjectCandidate(null), null);
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
id: 'a1',
name: 'a1',
face: null,
selectedAt: null,
});
// A selection token is a number or a short string.
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
5,
);
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
'click-2',
);
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
null,
);
const read = cockpitSubjectCandidate({
id: 'a1',
name: ' Example 12 ',
face: { icon: 'tram', label: 'RIDE' },
});
assert.equal(read.name, 'Example 12');
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
assert.equal(
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
null,
);
});
test('a frame is the held subject at a finite place with an eye height, else null', () => {
const frame = (overrides = {}) => ({
id: 'a1',
latitude: 47,
longitude: 8,
eye: { upM: 3.5 },
...overrides,
});
const read = cockpitSubjectFrame(frame(), 'a1');
assert.deepEqual(read, {
id: 'a1',
name: 'a1',
latitude: 47,
longitude: 8,
heightM: null,
headingDeg: null,
courseDeg: null,
speedMps: null,
stale: false,
groundClamp: true,
// No speed to run an inertial anchor on: it sits on the subject.
anchor: 'exact',
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
});
assert.equal(
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
'inertial',
'with a speed it runs on',
);
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
for (const bad of [
null,
frame({ id: 'other' }),
frame({ latitude: 91 }),
frame({ latitude: NaN }),
frame({ longitude: Infinity }),
frame({ eye: null }),
frame({ eye: { upM: -1 } }),
frame({ eye: { upM: 2000 } }),
frame({ eye: { upM: '3' } }),
])
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
const full = cockpitSubjectFrame(
frame({
name: 'Example',
longitude: 190,
heightM: 412.5,
headingDeg: -10,
courseDeg: 725,
speedMps: 22,
stale: true,
groundClamp: false,
anchor: 'exact',
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
}),
'a1',
);
assert.equal(full.longitude, -170);
assert.equal(full.heightM, 412.5);
// A height outside any plausible ground reads as none.
for (const heightM of [-6_400_000, 1e9, Infinity])
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
assert.equal(
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
-400,
);
assert.equal(full.headingDeg, 350);
assert.equal(full.courseDeg, 5);
assert.equal(full.speedMps, 22);
assert.equal(full.stale, true);
assert.equal(full.groundClamp, false);
assert.equal(full.anchor, 'exact');
assert.deepEqual(full.eye, {
upM: 2,
forwardM: 0,
pitchDeg: -90,
minClearanceM: 1.5,
});
// Only exactly false turns the ground clamp off; course falls back to heading.
const loose = cockpitSubjectFrame(
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
'a1',
);
assert.equal(loose.groundClamp, true);
assert.equal(
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
'inertial',
);
assert.equal(loose.courseDeg, 90);
assert.equal(loose.speedMps, null);
});
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
const empty = cockpitSubjectHudView(null);
assert.deepEqual(empty, {
kicker: '',
title: '',
meta: { text: '', tone: 'normal' },
speed: null,
readouts: [],
sources: [],
route: null,
progress: null,
banner: null,
veil: null,
lookReadout: '',
signals: [],
});
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
const view = cockpitSubjectHudView({
kicker: 'LINE · OPERATOR',
title: 'Name → Destination',
meta: { text: 'LIVE', tone: 'bogus' },
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
readouts: [
...Array.from({ length: 6 }, (_, i) => ({
id: `r${i}`,
label: 'NEXT',
value: `Stop ${i}`,
tone: 'warning',
})),
],
sources: [
{ id: 'track', label: 'TRACK', text: 'operator shape' },
{ id: 'track', label: 'TRACK', text: 'repeated id' },
{ id: 'Bad Id', text: 'refused' },
{ id: 'empty', label: 'X', text: '' },
],
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
veil: { level: 4, streaks: 1 },
lookReadout: 'LOOK RIGHT',
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
});
assert.equal(view.meta.tone, 'normal');
assert.deepEqual(view.speed, {
value: 84.4,
unit: 'km/h',
tag: 'LIVE',
tone: 'live',
});
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
assert.equal(view.readouts[0].tone, 'warning');
assert.deepEqual(
view.sources.map(({ id }) => id),
['track'],
);
assert.deepEqual(view.route, {
title: 'ROUTE',
from: 'A',
to: 'B',
status: '3 stops',
});
assert.equal(view.banner.tone, 'alert');
assert.deepEqual(view.veil, { level: 1, streaks: false });
assert.equal(view.lookReadout, 'LOOK RIGHT');
assert.equal(view.signals[0].tone, 'warning');
// A veil at 0, a speed below 0 and a route with no ends are absent.
const absent = cockpitSubjectHudView({
veil: { level: 0 },
speed: { value: -3 },
route: { from: '', to: '' },
});
assert.equal(absent.veil, null);
assert.equal(absent.speed, null);
assert.equal(absent.route, null);
// A speed of null is a speed not yet known.
assert.equal(
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
null,
);
});
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
const read = cockpitSubjectHudView({
progress: {
ticks: [
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
{ at: 0.5, tone: 'warning' },
{ at: 2 },
{ at: -0.1 },
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
],
marker: { at: 1.4, dwell: true },
tag: 'TIMETABLE',
summary: 'From Start to End',
},
}).progress;
assert.deepEqual(
read.ticks.map(({ at }) => at),
[0, 0.25, 0.5, 1],
);
assert.equal(read.ticks[0].label.priority, 9);
assert.equal(read.ticks[0].label.align, 'start');
assert.equal(read.ticks[1].label, null);
assert.equal(read.ticks[2].tone, 'warning');
assert.deepEqual(read.marker, { at: 1, dwell: true });
assert.equal(read.tag, 'TIMETABLE');
assert.equal(read.summary, 'From Start to End');
assert.equal(
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
null,
);
const many = cockpitSubjectHudView({
progress: {
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
})),
},
}).progress;
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
});
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
assert.equal(cockpitSubjectText({}, 10), '');
assert.equal(cockpitSubjectText(NaN, 10), '');
});
test('a cut never splits a surrogate pair', () => {
// The cut would fall between the emoji's halves: it is left out whole.
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
// One that fits before the cut is kept whole.
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
const lone =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
for (let max = 2; max <= 24; max += 1)
assert.equal(
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
false,
`cut at ${max}`,
);
});
test('a provider method that throws returns the fallback and warns once', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const failing = {
kind: 'example-kind',
read() {
throw new Error('boom');
},
hud: (value) => value * 2,
};
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'hud', [21]), 42);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind read\(\) failed/);
});
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const asynchronous = {
kind: 'example-kind',
hold: async () => true,
read: () => Promise.reject(new Error('late')),
};
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'read', [], null), null);
assert.equal(warnings.length, 2, 'once per method');
assert.match(warnings[0], /hold\(\) returned a promise/);
// The rejected promise is not left unhandled.
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('an answer whose `then` throws when read is handled as a throw', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const tricky = {
kind: 'example-kind',
hold: () => ({
get then() {
throw new Error('no then');
},
}),
};
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind hold\(\) failed/);
});
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const ride = {
kind: 'example-kind',
hold() {
throw new Error('boom');
},
};
call.warnOnce(ride, 'read', 'is odd');
call.warnOnce(ride, 'read', 'is odd again');
call(ride, 'hold', ['a1']);
call.warnOnce(ride, 'hold', 'is odd');
assert.deepEqual(warnings, [
'[Cockpit] subject provider example-kind read() is odd',
'[Cockpit] subject provider example-kind hold() failed',
]);
});
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
class Ride {
#word = 'RIDE';
#held = null;
kind = 'example-kind';
layerId = 'example-layer';
speak(word) {
this.#word = word;
}
entryFace() {
return { icon: 'commute', label: this.#word };
}
get labels() {
return {
view: `${this.#word} view`,
exit: 'Exit ride view',
exitText: 'EXIT RIDE',
};
}
look() {
return { aheadLabel: `Look ahead, ${this.#word}` };
}
candidate() {
return { id: 'a1', name: this.#word };
}
isEntryAllowed(candidate) {
return candidate.id === 'a1' && this.#held === null;
}
hold(id) {
this.#held = id;
return true;
}
read() {
return this.#held;
}
hud(frame) {
return { title: `${this.#word} ${frame}` };
}
release() {
this.#held = null;
return 'released';
}
}
const instance = new Ride();
const { kept, warnings } = keep([instance]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.labels.view, 'RIDE view');
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
instance.speak('FAHRT');
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
// Called on the kept copy, as the Cockpit calls them.
const failures = [];
const call = createCockpitSubjectCaller({
warn: (message) => failures.push(message),
});
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
assert.equal(call(ride, 'hold', ['a1']), true);
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
assert.equal(call(ride, 'read', [0, {}]), 'a1');
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
assert.equal(call(ride, 'read', [0, {}]), null);
assert.deepEqual(failures, []);
});
test('labels, entry face and look given as functions are read again on each use', () => {
let lang = 'en';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
};
const { kept, warnings } = keep([
provider({
labels: () => words[lang],
entryFace: () => ({
icon: 'commute',
label: { en: 'RIDE', de: 'FAHRT' }[lang],
}),
look: () => ({
restPitchDeg: lang === 'de' ? 40 : -3,
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
}),
}),
]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.labels.exitText, 'EXIT RIDE');
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.look.aheadLabel, 'Look ahead');
lang = 'de';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
assert.equal(ride.look.aheadLabel, 'Nach vorn');
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
// A read that fails or is malformed keeps the last good one.
lang = 'xx';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
// Functions that are malformed from the start leave the provider out.
const bad = keep([
provider({ kind: 'bad-labels', labels: () => null }),
provider({
kind: 'thrown-face',
entryFace: () => {
throw new Error('no face');
},
}),
]);
assert.deepEqual(bad.kept, []);
assert.equal(bad.warnings.length, 2);
});
/** `target` with a getter for `key` that throws (provider code runs on a read). */
const throwingGetter = (target, key) =>
Object.defineProperty(target, key, {
enumerable: true,
get() {
throw new Error(`${key} unavailable`);
},
});
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
const { kept, warnings } = keep([
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
throwingGetter(provider(), 'kind'),
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
// Read only once the provider is accepted, so its kind must stay free.
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
provider({ kind: 'example-kind' }),
]);
assert.deepEqual(
kept.map(({ kind }) => kind),
['example-kind'],
);
assert.deepEqual(warnings, [
'[Cockpit] subject provider thrown-face left out: malformed entry face',
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
'[Cockpit] subject provider thrown-look left out: a member threw when read',
'[Cockpit] subject provider #3 left out: a member threw when read',
'[Cockpit] subject provider thrown-method left out: a member threw when read',
'[Cockpit] subject provider example-kind left out: a member threw when read',
]);
});
test('a worded getter that starts throwing keeps the last good words', () => {
let failing = false;
const words = (value) => () => {
if (failing) throw new Error('translations unavailable');
return value;
};
const definition = provider();
for (const [key, value] of Object.entries({
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
entryFace: { icon: 'commute', label: 'RIDE' },
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
}))
Object.defineProperty(definition, key, { get: words(value) });
const { kept, warnings } = keep([definition]);
assert.deepEqual(warnings, []);
const [ride] = kept;
failing = true;
assert.equal(ride.labels.view, 'Ride view');
assert.equal(ride.entryFace.label, 'RIDE');
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
// So does a worded object whose own member throws when read.
failing = false;
Object.defineProperty(definition, 'labels', {
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
});
assert.equal(ride.labels.view, 'Ride view');
});
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
assert.equal(
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
null,
);
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
assert.equal(
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
null,
);
assert.equal(
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
null,
);
assert.equal(
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
null,
);
assert.equal(
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
null,
);
// Nothing to read is still the empty view, never null.
assert.equal(cockpitSubjectHudView(null).title, '');
});
-18
View File
@@ -481,24 +481,6 @@ export function orbitEntryAllowed({
);
}
/**
* Whether an application subject provider's candidate can be ridden. Like the
* bridge and orbit it needs only its own layer on, and waits out a Context
* change in flight; the provider then has its own say (`isEntryAllowed`).
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
* providerAllows: boolean}} state
* @returns {boolean}
*/
export function subjectEntryAllowed({
layerEnabled,
contextModeChanging,
providerAllows,
}) {
return (
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
);
}
/**
* Internal context-mode id → the word the voice tools accept for it.
*
+36 -127
View File
@@ -4,31 +4,18 @@ 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"/);
@@ -36,10 +23,7 @@ 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/);
@@ -50,22 +34,9 @@ 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', () => {
@@ -74,62 +45,40 @@ 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=/);
@@ -140,84 +89,44 @@ 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, edge switch 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 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-edge-btn',
'span.material-symbols-outlined',
'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-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"),
'',
);
});
+1 -5
View File
@@ -64,11 +64,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
// 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);
const track = finiteNumber(aircraft?.track);
return [
hex,
-12
View File
@@ -49,15 +49,3 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
assert.equal(normalized.states[0][7], null);
assert.equal(normalized.states[0][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');
});
+1 -2
View File
@@ -458,8 +458,7 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
// An ordinary model draws its mesh's own colours, whatever the billboard's.
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
assert.equal(rendering._modelColor('civil'), normal);
}
});
+5 -19
View File
@@ -1,5 +1,3 @@
import * as Cesium from 'cesium';
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
@@ -8,24 +6,12 @@ import * as Cesium from 'cesium';
* the model and reads the result back from the GPU (`readPixels`) to light it,
* 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. 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. 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.
*/
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 };
}
+1 -1
View File
@@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({
/**
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* changing the model's colour blend mode or amount.
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
-54
View File
@@ -1882,57 +1882,3 @@ 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',
);
});
-94
View File
@@ -316,97 +316,3 @@ 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);
});
+23 -157
View File
@@ -11,9 +11,6 @@
* 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.
@@ -21,7 +18,6 @@
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/';
@@ -89,62 +85,16 @@ const TEXT_SHIP_TYPES = [
];
/**
* 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.
* 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`.
* @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, callsign) {
export function resolveAircraft(types, type, category) {
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]) ||
@@ -152,51 +102,6 @@ export function resolveAircraft(types, type, category, callsign) {
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]
@@ -228,9 +133,8 @@ 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;
* when it is unknown (most live feeds send none), chosen and drawn at the
* range's `typical_len`, else as built.
* range that has one, else `default_vessel`. Drawn at the reported length, as
* built when it is unknown.
* @param {object} types Parsed `types.json`.
* @param {number} [shipType]
* @param {number} [length]
@@ -241,14 +145,13 @@ 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 && len < rule.min_len) continue;
if (rule.max_len && len > rule.max_len) continue;
if (rule.min_len && L < rule.min_len) continue;
if (rule.max_len && L > 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;
@@ -256,7 +159,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
}
}
}
return { model: types.default_vessel, length: L > 0 ? L : null };
return { model: types.default_vessel, length: drawn };
}
/**
@@ -401,32 +304,24 @@ function specOf(set, resolved, fallback, options) {
}
/**
* The model an aircraft is drawn with. The type's callsign rules (an airport
* vehicle by its callsign), then the type's entry; then, for a
* The model an aircraft is drawn with. The type's entry first; 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, callsign?: string|null}} aircraft
* category?: string|number|null}} aircraft
* @returns {object} {@link fleetModelSpec}
*/
export function aircraftModelSpec(
set,
{ typeCode, type, category, callsign } = {},
) {
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
const { types, catalog } = set;
const code = String(typeCode ?? type ?? '')
.trim()
.toUpperCase();
const emitter = emitterCategory(category);
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;
let resolved = resolveAircraft(types, code, emitter);
const typed = code && (types.aircraft[code] || types.helicopters[code]);
if (!ruled && !typed && code) {
if (!typed && code) {
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
if (model && catalog[model])
resolved = { model, length: catalog[model].length };
@@ -439,49 +334,25 @@ export function aircraftModelSpec(
/**
* The model a vessel is drawn with, from its AIS ship type (a code or a text
* 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.
* family), reported length and beam (metres) and name.
* @param {{catalog: object, types: object}} set
* @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
* @param {{type?: string|number|null, length?: number|null,
* beam?: number|null, name?: string|null}} vessel
* @returns {object} {@link fleetModelSpec}
*/
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);
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
const resolved = resolveVessel(
set.types,
aisShipTypeCode(type),
reported,
hullWidth,
length ?? undefined,
beam ?? undefined,
name ?? undefined,
);
return specOf(
set,
resolved,
(r) => ({ model: set.types.default_vessel, length: r.length }),
{ beam: hullWidth },
{ beam: beam ?? undefined },
);
}
@@ -510,9 +381,6 @@ export function namedFleetModels(types) {
...pairs(types.aircraft),
...pairs(types.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,
@@ -522,8 +390,6 @@ export function namedFleetModels(types) {
transit.default[0],
...Object.keys(transit.elevation),
...Object.values(CLASS_MODELS),
...AID_MODELS.map(([, model]) => model),
DEFAULT_AID_MODEL,
]);
}
-202
View File
@@ -4,9 +4,7 @@ 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,
@@ -25,7 +23,6 @@ import {
vesselModelSpec,
} from './fleetModels.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { TRANSIT_MODES } from './transitFeeds.js';
import {
centrelineSegments,
distanceToSurface,
@@ -54,22 +51,12 @@ 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' },
{
@@ -124,33 +111,6 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
});
});
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
const zveh = (callsign) =>
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
model: 'fire_truck',
length: 11.01,
});
assert.equal(zveh('PUSH 14').model, 'pushback');
assert.equal(zveh('TUG3').model, 'pushback');
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
assert.equal(zveh('SQUID20').model, 'van');
assert.equal(zveh('SQB9').model, 'van');
assert.equal(zveh('S Q B 9').model, 'van');
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
assert.equal(
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
'a320',
);
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
model: 'radio_mast',
length: 1.9,
});
});
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
assert.equal(
@@ -173,29 +133,6 @@ test('vessels: the first matching rule of the first type range with one, else th
});
});
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
assert.deepEqual(resolveVessel(sliceTypes, 70), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
model: 'general_cargo',
length: 90,
});
assert.deepEqual(resolveVessel(sliceTypes, 31), {
model: 'tug',
length: null,
});
// Every passenger, cargo and tanker range of the set has one.
for (const code of [60, 70, 80]) {
assert.ok(resolveVessel(types, code).length > 100, String(code));
}
});
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
model: 'tug',
@@ -264,46 +201,6 @@ test('transit bodies mark the share of the route colour they take; other models
}
});
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
const zveh = (callsign) =>
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
.model;
assert.equal(zveh('FIRE 2'), 'fire_truck');
assert.equal(zveh('PUSHBACK1'), 'pushback');
assert.equal(zveh('TUG 4'), 'pushback');
assert.equal(zveh('DEICER 4'), 'van');
assert.equal(zveh('SQUID20'), 'van');
assert.equal(zveh('SQB9'), 'van');
assert.equal(zveh('LEADER 7'), 'car');
assert.equal(zveh('AOPS'), 'car');
assert.equal(zveh(null), 'car');
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
assert.equal(mast.model, 'radio_mast');
assert.equal(mast.scale, 1);
});
test('a callsign rule outranks the class model of a type the map lacks', () => {
const ruled = {
...set,
types: {
...set.types,
aircraft_callsign: {
...set.types.aircraft_callsign,
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
},
},
};
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
'fire_truck',
);
// No rule matched: the class model, as before.
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
'generic_heli',
);
});
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
@@ -370,12 +267,6 @@ 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.
@@ -384,99 +275,6 @@ 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',
+4 -5
View File
@@ -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.1851, 0.301, 0]),
car: Object.freeze([2.3, 0.418, 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.39, 0.4227, 0]),
fire_truck: Object.freeze([5.5, 0.6051, 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,9 +53,8 @@ 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([2.9536, 0.388, 0]),
pushback: Object.freeze([3, 0.2499, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]),
radio_mast: Object.freeze([1.2, 0, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]),
@@ -63,5 +62,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.695, 0.2812, 0]),
van: Object.freeze([2.75, 0.418, 0]),
});
+1 -46
View File
@@ -57,14 +57,7 @@ test('every typed aircraft renders at its published length, a real-world aircraf
);
// A grounded aircraft stands on its wheels: its origin the scaled
// clearance above the ground, never below it.
// 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 > 0, `${type}: clearance ${spec.clearanceM}`);
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
}
}
@@ -303,41 +296,3 @@ test('a layer asking every tick gets one cached spec per type and category', asy
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
assert.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,
);
});
-131
View File
@@ -1,131 +0,0 @@
/**
* 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.
}
},
});
}
-108
View File
@@ -1,108 +0,0 @@
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'));
});
+3 -1
View File
@@ -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', async () => {
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
)));
@@ -300,5 +300,7 @@ test('civilian and military trail heads use the lower-centre model anchor', asyn
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
`${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`);
}
});
+16 -239
View File
@@ -29,21 +29,12 @@ import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
* node:test. No Cesium, no Node built-ins.
*/
/**
* 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.
*/
/** Transit modes the layer colors. `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',
]);
@@ -51,81 +42,13 @@ 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
@@ -520,7 +443,7 @@ const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
function liaRouteMode(routeId) {
if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'funicular';
if (line === 'FUNI') return 'rail';
return /^[AB]$/.test(line) ? 'tram' : 'bus';
}
@@ -547,14 +470,10 @@ 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
@@ -568,9 +487,7 @@ 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: 'funicular', label: 'LD' };
if (PID_TROLLEYBUS_LINES.has(n))
return { mode: 'trolleybus', label: String(n) };
if (n === 49) return { mode: 'rail', label: 'LD' };
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}` };
@@ -595,102 +512,6 @@ 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.
@@ -750,7 +571,6 @@ 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,
* }>>}
@@ -759,9 +579,6 @@ 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({
@@ -1447,7 +1264,6 @@ 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',
@@ -1480,7 +1296,6 @@ 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({
@@ -1586,7 +1401,6 @@ 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',
@@ -1753,8 +1567,6 @@ 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',
@@ -1852,89 +1664,54 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
}
/**
* 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.
* 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.
* @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, routeType) {
function hintedMode(feed, routeId, vehicleId) {
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: its fleet hint, route type or route hint when one
* knows ({@link hintedMode}), else its feed's default.
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
* its 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,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
export function transitModeFor(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
const mode =
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
}
/**
* 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.
* 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.
* @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,
routeType = null,
) {
const hinted = hintedMode(feed, routeId, vehicleId, routeType);
export function transitModeResolved(feed, routeId, vehicleId = null) {
const hinted = hintedMode(feed, routeId, vehicleId);
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
+3 -120
View File
@@ -11,13 +11,9 @@ import {
haversineKm,
publicTransitCatalog,
transitFeedsInRange,
TRANSIT_MODE_ICON,
transitModeFor,
transitModeOfRouteType,
transitModeResolved,
transitModelRouteType,
transitRouteLabel,
transitRouteType,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
@@ -733,10 +729,8 @@ 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'), ['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('L58'), ['bus', '58'], 'trolleybus');
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']);
@@ -773,7 +767,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'), 'funicular');
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
@@ -784,114 +778,3 @@ 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');
});
+1 -80
View File
@@ -21,9 +21,7 @@ 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. 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.
* hull with a bow.
*
* 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
@@ -105,49 +103,6 @@ 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: `
@@ -191,40 +146,6 @@ 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"
+2 -8
View File
@@ -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('every variant retains the specified final halo at both display sizes', async () => {
test('all 72 variants retain the specified final halo at both display sizes', async () => {
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
for (const kind of TRANSIT_ICON_KINDS) {
@@ -97,8 +97,7 @@ test('every variant retains the specified final halo at both display sizes', asy
}
for (let i = 0; i < 100; i++)
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
// Every kind at two sizes, three profiles, with and without a course.
assert.equal(transitIconCacheSize(), TRANSIT_ICON_KINDS.length * 12);
assert.equal(transitIconCacheSize(), 72);
});
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
@@ -198,11 +197,6 @@ 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]) {
+1 -11
View File
@@ -60,14 +60,9 @@ 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]),
}),
@@ -77,14 +72,9 @@ 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',
]);
@@ -138,7 +128,7 @@ export function presetSpriteOutlinePx(styleName, selected = false) {
/**
* Detection-overlay tier key for a transit mode. Every theme's `tiers`
* table carries one per mode, so the bracket colour is per mode in every
* table carries these six, 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.
-1
View File
@@ -19,7 +19,6 @@ 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;
-9
View File
@@ -46,15 +46,6 @@ 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({
-107
View File
@@ -45,110 +45,3 @@ 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));
}
-61
View File
@@ -1,61 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
test('an aid-to-navigation type code names its kind of aid', () => {
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
assert.equal(aidToNavigationTypeName(2), 'RACON');
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
// Code 0, a code past 31 and no code name no kind.
for (const code of [0, 32, -1, '', null, undefined, 'x'])
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
});
test('aids to navigation are known by their type, else their MMSI', () => {
for (const type of [
'Beacon, Special Mark',
'Cardinal Mark N',
'Port Hand Mark',
'Starboard Hand Mark',
'Safe Water',
'Isolated danger',
'Preferred Channel Port hand',
'RACON',
'Light Vessel / LANBY / Rigs',
'Reference point',
'Navigation aid',
'AtoN',
'Lighthouse',
'Buoy',
// Every kind an aid report names is an aid, whatever its MMSI.
'Light, with sectors',
'Leading light rear',
'Fixed structure off shore',
'Emergency wreck marking buoy',
])
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
// A virtual aid is an aid whatever its type or MMSI.
assert.equal(
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
true,
);
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001' },
{ type: 'Buoy-Laying Vessel', mmsi: '' },
{ type: 'Buoy Layer', mmsi: '' },
{ type: 'Lighter', mmsi: '' },
{ type: 'Flight deck', mmsi: '' },
{ type: 70, mmsi: 366999002 },
{ type: '70', mmsi: '366999002' },
{ type: '0', mmsi: '' },
{ type: 'Cargo', mmsi: '99' },
{ type: 'Passenger', mmsi: '9936720011' },
{},
])
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
});
+517
View File
@@ -0,0 +1,517 @@
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');
});
+1 -17
View File
@@ -776,8 +776,6 @@
"TANKER": "ناقلة",
"OTHER": "أخرى",
"VESSEL": "سفينة",
"AID TO NAVIGATION": "مساعدة ملاحية",
"VIRTUAL": "افتراضي",
"POS: LIVE": "الموضع: مباشر",
"POS: {time}Z": "الموضع: {time}Z",
"AIS: --": "AIS: --",
@@ -3955,19 +3953,5 @@
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Showing {shown} of {total}": "عرض {shown} من {total}",
"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": "نقل اللوحات فوق الشريط إلى الحافة"
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
}
+1 -17
View File
@@ -416,8 +416,6 @@
"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…",
@@ -3607,19 +3605,5 @@
"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",
"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"
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
}
+1 -17
View File
@@ -650,8 +650,6 @@
"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: --",
@@ -3694,19 +3692,5 @@
"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",
"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"
"3,000 m and above": "3000 m o más"
}
+1 -17
View File
@@ -1291,8 +1291,6 @@
"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",
@@ -3694,19 +3692,5 @@
"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",
"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"
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
}
+1 -17
View File
@@ -1149,8 +1149,6 @@
"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: חי",
@@ -3694,19 +3692,5 @@
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
"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": "הזזת הלוחות מעל הסרגל, אל הקצה"
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
}
+1 -17
View File
@@ -1291,8 +1291,6 @@
"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",
@@ -3694,19 +3692,5 @@
"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",
"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"
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
}
+1 -17
View File
@@ -1253,8 +1253,6 @@
"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: ライブ",
@@ -3520,19 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
"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": "パネルをレールの上、画面端へ移動"
"BROWSE DATA LIBRARY": "データライブラリを見る"
}
+1 -17
View File
@@ -1289,8 +1289,6 @@
"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",
@@ -3694,19 +3692,5 @@
"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",
"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"
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
}
+1 -17
View File
@@ -394,8 +394,6 @@
"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…",
@@ -3781,19 +3779,5 @@
"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 м и выше",
"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": "Переместить панели поверх боковой панели к краю"
"3,000 m and above": "3000 м и выше"
}
+1 -17
View File
@@ -382,8 +382,6 @@
"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 …",
@@ -3607,19 +3605,5 @@
"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",
"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"
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
}
+1 -17
View File
@@ -1309,8 +1309,6 @@
"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": "ПОЗ.: ОНЛАЙН",
@@ -3781,19 +3779,5 @@
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
"Showing {shown} of {total}": "Показано {shown} з {total}",
"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": "Перемістити панелі поверх бічної смуги до краю"
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
}
+1 -17
View File
@@ -1229,8 +1229,6 @@
"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:实时",
@@ -3520,19 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
"Showing {shown} of {total}": "显示 {shown} / {total}",
"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": "将面板移到侧栏上方,贴靠边缘"
"BROWSE DATA LIBRARY": "浏览数据资源库"
}
+1 -17
View File
@@ -1224,8 +1224,6 @@
"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:實時",
@@ -3520,19 +3518,5 @@
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
"Showing {shown} of {total}": "顯示 {shown} / {total}",
"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": "將面板移到側欄上方,貼齊邊緣"
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
}
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more