Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini Feizabadi 7ebe98ba84 Merge remote-tracking branch 'origin/main' into mazeyar/shared-terrain-surface
# Conflicts:
#	src/app/constructCatalog.js
2026-11-14 21:03:10 +01:00
Sameh Khamis 250f0e20c3 Merge pull request #1733 from bilawalsidhu/fleet/aid-to-navigation-buoys
Draw aids to navigation as buoys
2026-11-14 11:27:46 -08:00
Sameh Khamis 52507a0755 Draw no buoy for a virtual aid to navigation
A virtual aid (AIS message 21's VirtualAtoN flag) is a mark broadcast
where nothing physical stands, so drawing it as a buoy showed a mark
that is not there. The vessel source record now carries `virtual`
(false unless a source says true; any source may set it), AISStream
sets it from VirtualAtoN, and the vessel layer keeps such an aid's
disc: vesselModelSpec returns null for it and the layer gathers no
model item for it. Its card, selected card and readout add VIRTUAL
after its kind.
2026-11-14 11:07:13 -08:00
Sameh Khamis 9ae121c7c0 Number the buoy models changelog fragment for #1733 2026-11-14 11:02:14 -08:00
Sameh Khamis 6c7fe48f40 Show an aid to navigation's kind, not a speed or heading
A buoy's card, selected card and HUD readout showed "0.0KT · 135°" from
whatever its source reported. A fixed mark has neither, so they now show
its kind alone, whole ("CARDINAL MARK N", which the 14-character type cut
read as "CARDINAL MARK "), in the bone white of its disc.

isAidToNavigation now knows every kind an aid report names ("Light, with
sectors", "Fixed structure off shore" had no aid word), leaves out a
"Buoy Layer" as it does a tender, and matches each type text once: the
vessel layer asks per vessel on its rotation pass, which now skips the
aid discs it never turns.

Aid rows no longer cost ships their place: aisStreamRows lists every
vessel ahead of every aid, so the layer's 12,000-row request never drops
a ship for a buoy, and vessels_in_area (and the brief built on it)
counts ships only. An aid's name runs on into its NameExtension.
2026-11-14 10:02:59 -08:00
Sameh Khamis 279aedb2f4 Draw aids to navigation as buoys
A buoy, beacon, RACON, light or IALA mark in the vessel feed was drawn
as a ship. A vessel record whose type names one, or whose MMSI is an
aid-to-navigation station's (99MIDxxxx), is now isAidToNavigation and
vesselModelSpec draws it as the fleet set's buoy of its mark (cardinal
N/E/S/W, port or starboard hand, safe water, isolated danger, special
mark; a red can when the type names no mark), at its built size and
flagged fixed so its model does not turn to a heading. Its icon is a
disc instead of a chevron.

AISStream now subscribes to AidsToNavigationReport by default, and the
server names each report's aid type code (0 to 31) as its kind instead
of reading it as a ship type.
2026-11-14 09:54:01 -08:00
Sameh Khamis f24abfc88f Merge pull request #1732 from bilawalsidhu/fix/adsblol-true-heading
fix(flights): an aircraft with no ground track faces its true heading, not north
2026-11-14 09:10:58 -08:00
Sameh Khamis 58776a6976 Merge branch 'main' into fix/adsblol-true-heading 2026-11-14 09:00:30 -08:00
Sameh Khamis 391e33b0d5 Merge pull request #1731 from bilawalsidhu/fleet/aircraft-mesh-colours
Draw aircraft models in their own mesh colours
2026-11-14 08:54:44 -08:00
Sameh Khamis deb42b38f7 Add the changelog fragment 2026-11-14 08:49:48 -08:00
Sameh Khamis fd06272e37 fix(flights): an aircraft with no ground track faces its true heading, not north
readsb rows (adsb.lol and a local dump1090 receiver) often send no track for
an aircraft on the ground, only its true heading. The track then read as
missing and the aircraft was drawn facing north, so one taxiing south looked
like it was moving backwards. Each normalizer now falls back to true_heading,
never the magnetic heading.
2026-11-14 08:49:29 -08:00
Sameh Khamis a3611caddb Fix the Local ADS-B model test name 2026-11-14 08:25:05 -08:00
Sameh Khamis 4d81d3be3a Describe aircraft models in their mesh colours in the docs 2026-11-14 08:24:51 -08:00
Sameh Khamis c2c196ab1e Add the changelog fragment 2026-11-14 08:06:59 -08:00
Sameh Khamis 8cb096fc21 Draw aircraft models in their own mesh colours
Every aircraft model (Flights, Military Flights, Local ADS-B) mixed 94% of a
flat class colour over its mesh, so airliners drew as white silhouettes and
military ones as amber. Models now use HIGHLIGHT: white leaves the mesh's own
colours as built, and the tracked and emergency colours tint the mesh without
hiding its paint. The IR boost still draws them flat white.
2026-11-14 08:06:51 -08:00
Sameh Khamis 3934be9ff9 Merge pull request #1730 from bilawalsidhu/fleet/airport-vehicle-models
Rebuild the airport vehicle models with real shapes
2026-11-14 07:45:39 -08:00
Sameh Khamis c73c5c4eb6 Add the changelog fragment 2026-11-13 23:43:17 -08:00
Sameh Khamis 491c50ed18 Rebuild the airport vehicle models with real shapes
The car, van, fire truck and pushback tug were stacked boxes. The car is now an
operations SUV (rounded body, glass house with a sloped windscreen, roof light
bar, grille and lamps), the van a high-roof panel van, the fire truck an airport
crash tender with roof and bumper turrets on three axles, and the pushback a low
chamfered tug with a glazed cab. All stand on the ground at their built size.
Their trail anchors are measured again.
2026-11-13 23:43:09 -08:00
Sameh Khamis c5cf579174 Merge pull request #1729 from bilawalsidhu/fleet/ground-vehicles
Draw airport ground vehicles by callsign and ground stations as a radio mast
2026-11-13 21:38:58 -08:00
Sameh Khamis 332551ec84 Add the changelog fragment 2026-11-13 21:20:48 -08:00
Sameh Khamis 6e655b801d feat(fleet): draw airport ground vehicles by callsign and ground stations as a radio mast
Feeds carry many airport ground vehicles (type ZVEH) and ground stations
(GRND) that were drawn as the generic plane, some at the 10 km airborne
default.

- types.json gains aircraft_callsign rules: a ZVEH callsign starting FIRE is
  a fire truck, PUSH/TUG a pushback tug, DEICER/SQUID/SQB a van, else a car.
  GRND draws a new 11.8 m radio mast (other/radio_mast.glb).
- resolveAircraft and aircraftModelSpec take the callsign; a matched rule
  names the model outright, ahead of the type's class model.
- The layer spec cache keys such types by the rule matched, so it holds at
  most one entry per rule.
- These models (catalog category `other`) draw at their built size, as such
  models already do, and a
  contact with no altitude whose model is one now stands on the ground; the
  feed's own type is kept for such contacts, as no airframe lookup knows it.
- Local ADS-B passes the callsign too.
2026-11-13 21:20:16 -08:00
Sameh Khamis 083da40d88 Merge pull request #1686 from ZloyS/denis/cockpit-subject-providers
feat(cockpit): application cockpit subjects
2026-11-13 19:06:18 -08:00
Sameh Khamis 16d163a886 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 18:58:00 -08:00
Sameh Khamis c653b3878d Merge pull request #1723 from bilawalsidhu/fix/cctv-panel-collapse-on-disable
Collapse the CCTV panel when the layer is switched off
2026-11-13 18:57:54 -08:00
Sameh Khamis 53cbd55181 Merge branch 'main' into fix/cctv-panel-collapse-on-disable 2026-11-13 18:47:22 -08:00
Sameh Khamis a91a89ec1c Merge pull request #1579 from bilawalsidhu/german/map-render-error-state
Show a recoverable notice when the globe stops drawing
2026-11-13 18:47:16 -08:00
Sameh Khamis c43bf103d6 Merge branch 'main' into german/map-render-error-state 2026-11-13 18:37:03 -08:00
Sameh Khamis 6c55daf9ac fix(transit): a held vehicle's snapshot height is its drawn marker's while the frame pass places it
Near the ground the marker follows the terrain sampled between two reports,
which a straight blend of the reports' floors misses over uneven ground.
2026-11-13 18:36:42 -08:00
Sameh Khamis 9b46692c64 Suspend only the layers Context isolation is switching off
The session snapshot recorded a user's switch only after the CCTV panel had
already rendered the layer off, so a first switch-off by the user inside
Contacts, or after a partly failed restore, still kept the panel open.
Context controls now hold the layers _clearLayersOutsideContextMode is
switching off while its transitions run; suspendsLayer reads that set, and
the snapshot is no longer consulted.
2026-11-13 18:32:57 -08:00
Sameh Khamis 4bb7b411e9 fix(cockpit): a provider's subject rides where it is drawn, and the documented provider rides a moving train
- A frame with no speedMps gets an exact anchor: an inertial one ran on at
  zero speed and closed at 0.75 m/s, so the camera fell kilometres behind a
  moving train. The docs example now passes speedMps and anchor: 'exact',
  a name for its HUD, and isEntryAllowed for a train bound to its line.
- Transit's snapshot height is the floor where playback draws the vehicle,
  not the newest report's floor uphill, and null while that floor is cold,
  so a view waits for the surface instead of standing on the ellipsoid.
- Rail's direction follows playback once it has placed the train: a report
  that runs back (a reversal, or GPS jitter) no longer turns the view
  while the drawn train still runs forward.
- A frame's id is trimmed as the candidate's is.
- A double-click on the selected vessel no longer enters an application's
  subject that took entry after it.
- The docs import from gods-eye-view/..., the package's name.
2026-11-13 18:23:50 -08:00
Sameh Khamis 9d01f8df77 Show no notice for a lost context on a page that must not reload, and log each render error once
An inline panel that lost its WebGL context showed the panel runtime's own
notice and, over it, an alert dialog with no buttons that it could not act
on. With allowReload false a lost context now clears the notice and shows
none, as the docs already said. keepPanelRendering no longer logs render
errors itself: the render failure notice logs each one. Name the Cesium
private fields finishRaise and reportThrown rely on, and the version they
were checked against.
2026-11-13 18:16:07 -08:00
Sameh Khamis f0ee1b95ca Collapse the CCTV panel when the user switches CCTV off inside Contacts
Turning a layer on does not leave Contacts, so a user could turn CCTV back
on inside the mode and off again; the layer was still in the session
snapshot and the panel stayed open on an empty frame. The snapshot now
records every layer the user switched during the session, and
suspendsLayer is true only for a layer isolation switched off that the
user has not touched since.
2026-11-13 18:12:44 -08:00
Said Turksever f29b0b2639 Keep the CCTV panel through Context isolation, and collapse through the panel chrome
Contacts and Space Missions switch CCTV off to isolate themselves and restore
it on the way out: the panel and its share preference now stay as they are for
that round trip, as they do for Clear selected data layers. The controls no
longer read the collapsed class before asking: the panel chrome tells a panel
the accordion folded from one whose saved state is closed.

The camera controls gate asserts the collapse, reopens CCTV from the right
rail before switching it back on, and runs a Contacts entry and exit with the
panel open.
2026-11-13 17:46:43 +00:00
Said Turksever 7f18943ec4 docs: CCTV panel collapse on switch-off, and changelog fragment 2026-11-13 17:27:22 +00:00
Said Turksever 0325805eb0 Collapse the CCTV panel when the layer is switched off
The panel opened itself when a camera became active, but nothing closed it
when the layer went off: it stayed expanded, empty, and its meta line still
described the last camera. The collapse runs only on the on-to-off change, so a
panel opened by hand while the layer is off stays open, and a context-mode
layer clear keeps the panel as Radio does.
2026-11-13 17:26:34 +00:00
German Khokhlov 73bd952736 Share one render loop fixture across the render failure tests
Four test files built the same widget on CesiumWidget.prototype, viewer
proxy, store and notice; renderLoopFixture.mjs now does it once. The notice
test drives Cesium.Event instead of a hand-made double. frameEvents() drops
tileset.tileLoad, which Cesium raises inside its own error handling, and the
notice no longer guards a viewer without a canvas or requestRender.
2026-11-13 20:16:08 +04:00
Sameh Khamis 7df581765d Merge pull request #1715 from bilawalsidhu/sameh/vessel-typical-length
Draw a vessel of unknown length at its ship type's typical size
2026-11-13 07:52:52 -08:00
Denis Groshev 974023510f fix(rail, transit): a train or vehicle a first-person view holds draws no 3D model either, since the view's eye is inside it; let go, its model draws again
The fleet models (#1714) draw nearby trains and vehicles under the 3D toggle. A
held one now leaves out its model as it does its icon, as the Vessels layer does
for its bridge subject: its model item is hidden, and handing the visual back
to its icon keeps the icon hidden while held. Tests drive each layer with the
real fleet-model layer and a stubbed glTF loader; both fail without the fix.
2026-11-13 16:43:50 +01:00
Sameh Khamis 92d34bc4cc Stretch a vessel to its beam only beside a reported length
A vessel drawn at its type's typical length keeps the model's proportions:
a reported beam alone would squeeze a 300 m container ship to that width.
2026-11-13 07:42:27 -08:00
Denis Groshev 546ac22708 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 16:38:13 +01:00
Sameh Khamis 432a29ba59 Add the changelog fragment 2026-11-13 07:34:50 -08:00
Sameh Khamis a9d390c165 Draw a vessel of unknown length at its ship type's typical size
Most live vessel feeds send no length, and the resolver then fell through
every length rule to the range's last model drawn as built, so a cruise
ship or a container ship showed as the smallest model of its range.
types.json gives each passenger, cargo, tanker, fishing and service range
a typical_len (cargo 300 m, tanker 250 m, passenger 140 m, fishing 40 m,
...); a vessel with no usable length is chosen and drawn at it.
2026-11-13 07:34:41 -08:00
German Khokhlov df41c0affb Shorten the render failure notice's comments and docs
Comments keep only the why: the loop-running flag, the mid-raise events and
the one-time canvas focus. The CURRENT-STATE section lists behaviour and
pitfalls instead of retelling the code, and no longer says every tileset
event is raised outside Scene's error handling.
2026-11-13 19:19:15 +04:00
German Khokhlov d42b6fe764 Merge branch 'main' into german/map-render-error-state 2026-11-13 18:47:36 +04:00
Denis Groshev fbc1a9a0ec fix(cockpit): a provider member that throws when read (a getter) leaves that provider out with one warning and keeps the rest; a worded member that starts throwing keeps its last good words; a candidate, frame or HUD view whose getter throws reads as null, so the view ends or keeps its last HUD 2026-11-13 09:22:55 +01:00
Denis Groshev 22f43aab01 refactor(cockpit): a provider words its text with getLocale and onLocaleChange from the ./i18n entry; the ./i18n/locale export, which gave only those two, is removed 2026-11-13 07:39:27 +01:00
Denis Groshev a7d722a1f4 Merge branch 'main' into denis/cockpit-subject-providers 2026-11-13 07:26:22 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d4f86748ea Merge the Transit provider fix and current main; fix the Bike Share test harness
The provider/globe identity check from #1688 moves into the shared terrain
reader, so every layer gets it; Transit shows the reader the flat provider
with observe(), since it answers a flat globe itself.

The Bike Share height test kept one array entry per event-loop turn and
spread it into Math.max, which overflowed the stack on slow CI runners. It
now keeps a running maximum.

Both found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:27:30 +01:00
Mazeyar Moeini Feizabadi c29ee98e23 Revert "style(transit): format the terrain provider test"
This reverts commit 5110f4cce3.
2026-11-13 03:26:17 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 5110f4cce3 style(transit): format the terrain provider test
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:25:07 +01:00
Mazeyar Moeini Feizabadi 8792e4701e Merge remote-tracking branch 'origin/main' into mazeyar/transit-terrain-height-cache 2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 87674240bd fix(transit): start terrain heights afresh when the provider or globe changes
A height read after the camera and tiles settled was kept until either
changed, but swapping the terrain provider or the globe with a still camera
changed neither, so the previous terrain's elevation could stand
indefinitely. The cache is now cleared when either identity changes, checked
before the flat-globe answer so terrain -> flat -> terrain is covered too.

Found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 888662749a docs(changelog): name the shared terrain fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:39 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0f4dda5cc6 perf(terrain): one shared, cached and budgeted terrain height reader for Transit, Rail, Bike Share and local data
globe.getHeight is a ray cast against the drawn tile's mesh and
scene.sampleHeight a GPU depth render; several layers asked for them
uncached and unbudgeted. The cache written for Transit moves to
src/services/terrainSurface.js and is handed to every layer through the
ground service, so layers share one cache and one read budget.

- Rail reads each train on a ~4 m grid instead of every frame.
- Local data layers read terrain floors through it and stop depth samples
  at 8 ms a frame.
- Bike Share draws stations at once and finds their heights afterwards in
  4 ms steps.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:24 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d5f7926c7e docs(changelog): name the transit height fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:50:22 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1c49a89faa fix(transit): keep terrain heights per point so rereads stop freezing the page
On a terrain globe every floor reread asked globe.getHeight, a ray cast against
the drawn tile's mesh, for every trail fix of every vehicle in view, twice per
pass and seven passes per poll. Over Boston's 650 vehicles that was about
150,000 reads per 10 s for about 8,000 distinct points, and blocked the page
for up to 19 s at a time while zooming.

Heights are now kept per point (1e-5 degrees), read again only while the camera
moves or tiles load, and read within a time budget: vehicle positions first,
the path a marker is drawn along next, undrawn trail history last.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:10:49 +01:00
Denis Groshev 0584292405 fix(cockpit): a train handed to another feed's copy keeps its selection time, so it never takes entry back on its own; snapshot stops are copied again when a provider updates its list in place; setCockpitSubject's return is documented as it is 2026-11-12 17:22:55 +01:00
Denis Groshev 09f3380203 docs(cockpit): a held train stays the copy drawn, the read APIs and their exports are experimental, and leaving a view does not track an earlier aircraft again 2026-11-12 17:02:37 +01:00
Denis Groshev c24352ff5e fix(cockpit): an application subject keeps its rates on a slow update interval, its ground lift eases out, and its HUD shows a speed and a progress name only when given
An application subject's camera step is now capped at its own update
interval plus 0.1 s rather than at 0.1 s, so at 500 ms its heading slew,
ground lift and anchor correction run at their configured rates; a long
stall still moves it no further than one late update. The built-in
views keep their 0.1 s cap.

When groundClamp turns false the lift the floor gave goes at the rate
it came, not at once. A HUD view with no speed has no speed readout,
and a progress line with no summary is hidden from assistive
technology instead of being an unnamed image.
2026-11-12 17:00:12 +01:00
Denis Groshev feaf59776c fix(rail): focusTrain refuses a train the layer does not draw
A ride let go with restore focuses its train again. When the train's
category was turned off during the ride, or for a copy another feed draws,
focusTrain selected and followed a train with no marker. It now returns
false for a train in a category turned off or hidden as another feed's
copy, and leaves the selection and the camera as they are.
2026-11-12 17:00:12 +01:00
Denis Groshev 4ae67fcb35 fix(rail): a held train stays the copy drawn of its train and keeps moving, hidden, when its category is turned off
The duplicate filter hid a held copy when another feed's copy outranked it
and moved the selection away, so the held train stopped playing and the
other copy's marker and card came back over the view. A copy a first-person
view holds is now drawn whatever the scores until the view lets go; the
better copy takes over at the next poll after the release. Holding a copy
that another feed drew draws it at once.

A category toggle no longer deselects a held train, and a held train keeps
playing, its marker hidden, while its category is off. Released, it is
deselected and stops as the toggle leaves any other train.
2026-11-12 17:00:12 +01:00
Denis Groshev fe7590d66a docs(application): a provider's subject takes entry when selected after every built-in subject, whose fixed order is unchanged 2026-11-12 16:59:18 +01:00
Denis Groshev 557fe7169a docs(application): the locale and rail path exports and the Rail and Transit read APIs are experimental
Says so where the cockpit subject section names them, together with
path.line, and that a snapshot read again while nothing changed is the one
read before, so a provider may read one on every update.
2026-11-12 16:58:39 +01:00
Denis Groshev a17346be6f fix(rail): a train snapshot's path.line is a frozen copy of its line, not the layer's own path
The copy keeps the path's fields with its number arrays as frozen arrays,
so pointAtS and projectOnPath read it as they read the path and nothing
written to it reaches the layer. It is built once per line and shared by
every snapshot on that line, so reading it on every frame costs nothing.
2026-11-12 16:58:39 +01:00
Denis Groshev 294ccc0ee8 perf(cockpit): a Rail or Transit snapshot read again while nothing in it changed is the one read before
getSelectedTrain, getSelectedVehicle and getCockpitSubject fill drafts and
freeze a new object only for a part whose values changed, so a view that
reads them on every frame builds nothing while the train or vehicle stands
still, and a moving one keeps its feed, stop and provenance objects. A
train's stops are copied once per list the stops provider returns, each
stop a frozen copy, instead of on every call.
2026-11-12 16:58:38 +01:00
Denis Groshev a4f321a9ef fix(cockpit): entry to the built-in views is as before: aircraft, satellite and vessel keep their fixed order whenever each was selected, and an application's subject takes entry only when it was selected after every built-in one present, so without providers nothing changes 2026-11-12 16:58:38 +01:00
Denis Groshev 6fbc98952f fix(cockpit): a provider's methods keep their this, a hold answers true or false, and a cut text never splits a surrogate pair
Provider methods and worded members are called on the provider itself,
so a class-based provider works. A hold that answers false refuses entry
and is released; any answer but true or false refuses it too, released,
with one warning. An answer whose `then` getter throws is handled as a
throw, and a text cut at its cap never leaves half an emoji.
2026-11-12 16:58:38 +01:00
Denis Groshev 4c33552105 fix(cockpit): an application subject's wait for the floor is bounded, a late asynchronous hold is released, and a hold never counts as the user's selection
- A subject with no height at all waits at most
  COCKPIT_GROUND_WAIT_TIMEOUT_MS for the floor, its HUD updating and
  reading ACQUIRING SURFACE; then the view ends and the provider gets its
  subject back (there is no height of its own to fall back to).
- A hold that returns a promise is refused as before and released again
  once it settles, unless that subject is being ridden by then; release
  must tolerate a second call.
- Rail and Transit stamp selectedAtMs only for a user's selection (a click,
  focusTrain or focusVehicle); a deselection clears it, and a train or
  vehicle selected only by a hold reads null.
2026-11-12 12:31:04 +01:00
Denis Groshev 1e4060f6ba docs(changelog): add the #1686 fragment 2026-11-12 11:36:31 +01:00
Denis Groshev 57d87d2bbf fix(cockpit): application subjects follow a change of language, never stand on the ellipsoid, and are guarded on every call; a re-selected subject takes entry again
- Labels, entry face and look may be functions, read again on every use
  and on a locale change; a failing read keeps the last good value.
- A frame with no height keeps the eye's last height, or waits
  (surfaceAcquiring) until the floor is known; heights outside
  -1,000..50,000 m read as null.
- Provider methods are synchronous: a returned promise is refused like a
  throw, and a hold that fails is released.
- A candidate may carry selectedAt; Rail and Transit stamp selectedAtMs
  on every selection, so re-selecting moves a subject to the front.
- A released Transit vehicle leaves the visible set at once.
- docs/CURRENT-STATE.md states the new entry order; docs/APPLICATION.md
  documents cockpit subjects, ./i18n/locale and ./layers/rail/paths.
2026-11-12 11:13:58 +01:00
Denis Groshev 01a74fceec fix(cockpit): an application subject's eye eases off the floor instead of jumping: its anchor is no longer clamped to the floor, and the eye's clearance above it changes at most 1.5 m/s (easeCockpitLift) 2026-11-12 10:50:27 +01:00
Denis Groshev f75179eea0 feat(cockpit): an application can add its own cockpit subjects; the Rail and Transit layers expose the read and hold APIs such a subject needs
GEV itself registers no new subject: with no provider, the Cockpit offers
aircraft, satellites and vessels as before, except that the most recently
selected of them now wins entry.

- services.cockpitSubjects: providers ({kind, layerId, entryFace, priority,
  isEntryAllowed, read, hold, release, hud}) are checked once by the
  coordinator; frames and HUD views on every update; a throwing provider
  ends its view or keeps the last HUD.
- Entry: the most recently selected subject wins, across all kinds.
- Camera: a frame may turn the ground clamp off, anchor exactly on the
  subject and update every frame.
- A generic HUD: kicker, title, cards, always-visible source rows, banner,
  a veil with light streaks (off under reduced motion) and a bottom
  progress line whose close ticks merge.
- Text comes from the provider, already in the page's language
  (./i18n/locale: getLocale, onLocaleChange).
- Rail and Transit read and hold APIs (getSelectedTrain/Vehicle,
  getCockpitSubject, setCockpitSubject, focusTrain/Vehicle); a held
  train's marker, card and route line are hidden, and its feed keeps
  polling while held.
- ./layers/rail/paths is exported.
2026-11-12 10:50:26 +01:00
German Khokhlov 29cf9ff8f6 Add the render failure notice in Swedish 2026-11-11 19:37:06 +04:00
German Khokhlov 897a74ea27 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/he.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-11 19:33:49 +04:00
German Khokhlov bd1da596b8 Keep the draw tool test file in its own formatting 2026-11-11 19:25:02 +04:00
German Khokhlov 6976cbc941 Hand focus back to the globe canvas after a render failure
When the control that had focus is gone or cannot take it, focus went to
the #cesiumContainer div, where Enter no longer finished a drawing. It now
goes to the globe's canvas, which the map's keys read as the map; the canvas
is focusable only until focus moves on or the globe is pressed. A control
disabled while the notice was up no longer leaves focus on the page, and
neither focus call scrolls.
2026-11-11 18:03:30 +04:00
German Khokhlov 771ed98e3b Add the render failure notice in Hebrew 2026-11-11 12:48:44 +04:00
German Khokhlov 282ad6c5f6 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-11 12:45:05 +04:00
German Khokhlov d1f4d1acdc Say the map is not updated in the Arabic render failure notice 2026-11-11 12:44:08 +04:00
German Khokhlov ebaa439739 Tighten the render failure notice's error handling and focus
- An error a frame of the inline panel's timer throws no longer reaches the
  host page; it has been raised as a render error by then.
- An error Scene raised and threw on (rethrowRenderErrors) is raised once.
- After a lost context, later render errors are only logged, so the notice
  keeps saying why.
- A render error without a value shows no detail.
- The globe's tile load progress and the scene's tileset load events are
  finished too when a listener's error cuts them short.
- Focus goes back where it was when the globe draws again, or to the map.
- The appearance counter and the guard for a missing viewer are gone.

Tests cover every finished event, a thrown timer frame, a single report under
rethrowRenderErrors and the focus return; the inline tests share one setup.
2026-11-11 12:44:08 +04:00
German Khokhlov 265074aa54 Raise the render failure notice above floating panels
The notice sat at z-index 1001, under the Québec camera viewer (2000), which
covered its text and, with video playing, its buttons on a phone. It now sits
at 2001. On touch screens its buttons are 44 px tall.
2026-11-11 12:44:08 +04:00
German Khokhlov c763c38193 Keep the render loop marked running after a frame Retry could race
An error Scene catches itself marks Cesium's render loop stopped while the
loop's next animation frame is still queued. A Retry, or a tab shown again,
before that frame ran started a second loop, and the globe drew every frame
twice. The render and resize wrapper now keeps the loop marked running after
a call made while it ran; the queued frame stops it if nothing restarted it.
This also covers the inline panel's timer frames, so its own guard goes.
2026-11-10 16:40:22 +04:00
German Khokhlov 2448662574 Merge remote-tracking branch 'origin/main' into german/map-render-error-state
# Conflicts:
#	src/app/scene.js
#	src/app/viewer.js
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
#	src/ui/views/SceneChromeView.vue
2026-11-10 14:29:34 +04:00
German Khokhlov d9e34f10c6 Finish an interrupted frame event as soon as its error surfaces
Retry used to apply the queued listener changes of a Cesium event a
throwing listener left mid-raise, adds first and removes second. A layer
turned off and on while the notice was up landed in both queues and was
removed. The render and resize wrapper now finishes those events before
it raises the render error, so later adds and removes apply at once and
in order.
2026-11-10 14:28:01 +04:00
German Khokhlov 02a8442e6e Let Retry finish a frame event a throwing listener cut short
Cesium's Event.raiseEvent leaves an event mid-raise when a listener throws, so listeners added or removed afterwards stay queued. Retry now applies that queue for the frame and clock events: the notice's frame watchers see the next frame, and a listener removed since, such as a layer turned off, is not called again.
2026-11-06 14:45:42 +04:00
German Khokhlov 628de8386d Report thrown render loop errors and keep one loop after an inline Retry
An error thrown outside Scene's own error handling (a postRender listener,
the clock's onTick, a resize) stopped Cesium's loop without a renderError,
and with Cesium's panel turned off the globe froze silently. The notice now
raises such an error as a render error.

In an inline panel whose host stopped animation frames, Retry left the
panel's timer latched off, and once frames resumed a second render loop
drew every frame twice. The timer now follows Cesium's loop, and keeps the
loop marked running when its next frame is still queued.
2026-11-05 21:21:03 +04:00
German Khokhlov abfc85e03d Add the changelog entry for #1579 2026-11-05 13:03:42 +04:00
German Khokhlov fbec1488ac Show a recoverable notice when the globe stops drawing
Cesium stops its render loop after a render error and offers only a
panel that dismisses itself, and a lost WebGL context never draws again,
so the globe froze or went black while the controls kept responding.

The scene chrome now shows an alert dialog with the error. Retry restarts
the render loop and keeps the notice until a frame renders without an
error; after a lost context it reloads the page, whose address keeps the
view. Reset view opens the app root without the restored hash or share
path. An inline conversation panel never reloads its host page. The
application viewer turns off Cesium's own panel; other callers keep it.
2026-11-05 13:03:14 +04:00
180 changed files with 11528 additions and 1291 deletions

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
AISSTREAM_API_KEY= AISSTREAM_API_KEY=
# Optional AISStream subscription filters. # Optional AISStream subscription filters.
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]] # AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport # AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket # Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the # is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
# default worldwide subscription — a narrow filter above can be legitimately # default worldwide subscription — a narrow filter above can be legitimately
+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. - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
@@ -0,0 +1,6 @@
- When the globe stops drawing after a render error or a lost WebGL context,
an alert dialog says so instead of leaving a frozen or black globe behind
working controls. Retry restarts drawing (Reload after a lost context), and
Reset view reopens the app without the view restored from the address bar.
Cesium's "Rendering has stopped" panel no longer appears in the app
(German, @gekh, #1579).
+12
View File
@@ -0,0 +1,12 @@
- Let an application add its own Cockpit subjects through
`services.cockpitSubjects`. A provider supplies the subject's position,
heading and HUD (in the page's language, via `getLocale` and
`onLocaleChange` from `./i18n`); the camera can follow it without the
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
banner, a veil (off under reduced motion) and a bottom progress line. The
Rail and Transit layers expose read and hold APIs so a selected train or
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
provider. GEV itself registers none, and entry to the built-in aircraft,
satellite and vessel views is unchanged; a provider's subject takes entry
when it was selected after them (Denis Groshev, @ZloyS, #1686).
@@ -0,0 +1,7 @@
- Fix the page freezing for seconds at a time with Transit on over a terrain
globe, most visibly while zooming over a large fleet. Every reread asked the
terrain for the height of every trail fix of every vehicle in view; heights
are now kept per point, read again only while the camera moves or tiles
load, and read within a time budget that puts vehicle positions first.
Switching the terrain provider or globe starts the kept heights afresh
(mazy1998, review by kvnloo, #1688).
@@ -0,0 +1,5 @@
- Read terrain heights through one shared, cached and budgeted reader
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
data layers. Opening Data Centers no longer blocks the page for over two
seconds, opening Bike Share for most of a second, and Rail asks the terrain
about a twentieth as often (mazy1998, #1689).
@@ -0,0 +1,3 @@
- Draw a vessel whose feed sends no length at its ship type's typical size, so
a cruise ship or container ship no longer shows as its type's smallest model
(samehkhamis, #1715).
@@ -1,4 +0,0 @@
- The detection overlay no longer rebuilds every bracket on a paint where the
view and every contact are unchanged, which cuts frame work by a fifth to a
third at a whole-globe view of Live Flights with a still camera (mazy1998,
#1719).
@@ -0,0 +1,5 @@
- Collapse the CCTV panel when the layer is switched off. It stayed open with
an empty frame and a meta line that still described the last camera; the
meta line of a disabled layer now reads the catalog count. Contacts, Space
Missions and Clear selected data layers leave the panel as it is
(asturksever, #1723).
@@ -0,0 +1,5 @@
- Draw airport ground vehicles and ground stations as what they are instead of
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
radio mast, and one with no altitude stands on the ground instead of at the
10 km airborne default (samehkhamis, #1729).
@@ -0,0 +1,4 @@
- Draw airport ground vehicles as real shapes instead of stacked boxes: the
car is an operations SUV, the van a high-roof panel van, the fire truck an
airport crash tender with turrets and the pushback a low tug with a glazed
cab, all on the ground at their built size (samehkhamis, #1730).
@@ -0,0 +1,3 @@
- Draw aircraft models in their own mesh colours instead of a 94% flat
white (amber for military) over the model; the tracked and emergency
colours now tint the mesh without hiding its paint (samehkhamis, #1731).
@@ -0,0 +1,4 @@
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
or a local receiver) now faces its reported true heading instead of north,
so one taxiing south no longer looks like it is moving backwards
(samehkhamis, #1732).
+11
View File
@@ -0,0 +1,11 @@
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
hand, safe water, isolated danger or special mark), standing still with no
heading, and its icon is a disc rather than a chevron. Its card and readout
show its kind of mark with no speed or heading. AISStream now subscribes to
aid-to-navigation reports and names each by its kind of mark and its full
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
counts ships only. A virtual aid (a mark broadcast where nothing physical
stands) keeps its disc, is never drawn as a buoy, and its card says it is
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
+122
View File
@@ -230,3 +230,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
`localePicker: false` to `createApplicationControls` instead of editing the `localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`. loader with `node --import=<core package>/build/register-vue`.
### Cockpit subjects
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
selected AIS vessel. An application adds kinds of its own with
`services.cockpitSubjects` on `createApplicationControls`: a list of
providers, checked once when the controls are built
(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
that throws when read (a getter), is left out with a warning and the rest are
kept. A provider's subject takes entry when it was selected after every
built-in subject present (a tracked aircraft or satellite, a selected vessel),
whose own fixed order is unchanged, and needs the provider's `layerId` on; the
provider then holds its subject, hands the camera a frame and the generic HUD
a view model on every update, and releases it on exit. Provider methods are
synchronous and are called on the provider, so a class instance's methods keep
their `this`; one that throws or returns a promise is treated as having
failed. What a method returns may carry getters: one that throws when read
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
the last; a worded member (labels, entry face, look) that throws keeps its
last good words.
```js
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: {
view: 'Fahrtansicht',
exit: 'Fahrtansicht verlassen',
exitText: 'VERLASSEN',
},
};
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
const ride = {
kind: 'example-ride',
layerId: 'rail',
entryFace: { icon: 'train', label: 'RIDE' },
// A function is read again when the page's language changes.
labels: () => words[getLocale()] ?? words.en,
candidate() {
const train = rail.getSelectedTrain();
return (
train && {
id: train.key,
name: train.label,
selectedAt: train.selectedAtMs,
}
);
},
// Only a train bound to its line can be ridden: read() needs its path.
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
hold: (id) => rail.setCockpitSubject(id),
read(nowMs, ctx) {
const train = rail.getCockpitSubject();
if (!train?.path) return null; // ends the view
const point = pointAtS(train.path.line, train.path.s);
return {
id: train.key,
latitude: point.lat,
longitude: point.lon,
heightM: train.position.railHeightM, // null stands it on the shared floor
name: train.label,
headingDeg:
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
// The layer already moves the train smoothly: sit on it.
anchor: 'exact',
eye: { upM: 3.5 },
};
},
hud: (frame) => ({ title: frame.name }),
release(id, { restore }) {
rail.setCockpitSubject(null);
if (restore) rail.focusTrain(id);
},
};
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
```
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
Core translates only its own strings, so a provider words its labels, entry
face and HUD for the page's language (labels may be functions, as above; the
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in
it changed is the one read before, so `read` may take one on every update; a
train's `path.line` is a frozen copy of its line, for `pointAtS` and
`projectOnPath`. The `layers/rail/paths` export and the
Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
`getCockpitSubject` and the snapshots they return, `path.line` among them)
are experimental and may change without notice.
A candidate's `selectedAt` is a token that changes each time the user selects
the subject, even the same one again, so entry counts it as selected then; a
new `id` under the same token is the same selection (a train's selection
moving to another feed's copy of it). The Rail and Transit snapshots give one,
`selectedAtMs`: when the user last selected the train or vehicle by a click or
`focusTrain`/`focusVehicle`. It is null after a deselection and for one
selected only by a hold, since a hold is not the user's selection.
A frame with `heightM: null` stands on the shared floor, or where it last
stood while that floor is cold. If no height has been known since entry, the
view waits, showing ACQUIRING SURFACE, for at most five seconds
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject.
`hold(id)` returns `true` once it holds the subject, or `false` to refuse
entry; any other answer, `undefined` included, refuses entry too, with one
warning. A refused hold, and one that throws or returns a promise, is followed
by `release(id, { restore: false })`, again once a returned promise settles,
so `release` must be harmless for a subject it does not hold and when called
more than once.
Leaving the view does not track again an aircraft that was tracked before
entry, as with the bridge and orbital views: when the user leaves by the exit
control, the map goes back to the ridden subject through
`release(id, { restore: true })`.
+171 -25
View File
@@ -2208,6 +2208,83 @@ the subject (tracking stopped, Satellites off) or tracking another target
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
covers entry, camera, zoom, look, HUD and exit. covers entry, camera, zoom, look, HUD and exit.
### Application subjects
An application can add its own first-person subjects beside the aircraft,
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
the coordinator; a malformed one, or one with a member that throws when read,
is left out with a warning and the rest are kept). A provider names
its layer, an entry face and labels in the page's language, and supplies four
operations: `candidate()` (what entry would ride now), `hold(id)`,
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
view model), with `release(id, {restore})` on exit. Entry needs the provider's
layer on, no Context change in flight and the provider's own
`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
the reason, while a provider's view is in use.
The built-in kinds keep their fixed entry order (a tracked aircraft, then the
tracked satellite, then the selected vessel), so without providers entry is
unchanged. A provider's candidate takes entry only when it was selected after
every built-in subject present, by when the Cockpit first saw it or saw it
selected again (its `selectedAt` changing): a train picked after an aircraft
was tracked takes entry, and an aircraft tracked after it takes it back.
Providers' candidates are ordered most recently selected first, then by
priority; one first seen together with a built-in subject comes after it. The
first in that order decides alone: when its gate is shut there is no entry
rather than the next subject's.
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
frame gives none, or where it last stood while that floor is cold; with
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
turn off the floor clamps (`groundClamp: false`, for a subject inside a
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
layer that already moves it smoothly; a frame with no `speedMps` is always
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
camera every rendered frame. A frame that is null, malformed or for another
subject ends the view (`restoreTracking: false`), as an aircraft tracked
meanwhile and the provider's layer turning off do; the exit control asks the
provider to restore its subject. The generic HUD shows the provider's kicker,
title, meta line, speed with its unit and tag, readout cards, always-visible
source rows, banner, route card and Live Signals rows, a veil that darkens the
picture under the HUD (with passing light streaks, which stop under reduced
motion), arrow-key look-around when the provider asks for it, and a progress
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
labels never overlap). Core translates none of the provider's text: a host
words it for the page's language (`gods-eye-view/i18n`:
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
functions, read again when they are shown and on a change of language, as the
HUD view is.
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
which way it runs (`pathBinding.js` learns it), its rail height, speed as
reported or derived, position source, feed credit and stops, and
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
even again; null after a deselection and for a train only a hold selected),
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
feed held, clicks and Escape left to the view; the held copy stays the one
drawn of its train while held, another feed's copy hidden, and it keeps
moving, still selected, when its category is turned off) and
`focusTrain(key)` (false for a train the layer does not draw); its pure path
module is exported as `gods-eye-view/layers/rail/paths`. That export
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
the one read before, and its `path.line` is a frozen copy. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
out of the camera's view, and its sprite, 3D model, card, trail and detection
box are hidden; released, it is out of play until a visibility sweep finds it in
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
it (the drawn marker's height while it is placed, else its segment's two
reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the
way the drawn train runs: once playback has placed it, a report that runs back
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
cover them.
## Context coordination ## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions, Context controls own Contacts/Space Missions state, entry and exit transactions,
@@ -2229,6 +2306,17 @@ shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal settles), so a new camera's preview is blank only while its frame loads. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
The panel opens when a camera becomes active and collapses when the layer is
switched off, from its own switch or anywhere else. The panel chrome applies
the collapse, so a panel the desktop accordion only folded has its saved and
shared state closed too. Clear selected data layers and Context isolation
(Contacts, Space Missions) switch the layer off without the user asking: the
panel and its share preference stay as they are, and the layer comes back to an
open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
while `_clearLayersOutsideContextMode` switches the layer off): the user
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
meta line reads the catalog count, not the last active camera.
## UI disposal ## UI disposal
UI disposal releases the CCTV subscription, command-dock observer, legacy drag UI disposal releases the CCTV subscription, command-dock observer, legacy drag
@@ -2443,6 +2531,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
detection is the backstop for when no probe context can be created. Applied detection is the backstop for when no probe context can be created. Applied
during viewer construction, before any tile builds a draw command. during viewer construction, before any tile builds a draw command.
## Render failure notice
When the globe stops drawing, the scene chrome says so instead of leaving a
black globe behind working controls. `installRenderFailureNotice()`
(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
`webglcontextlost`, logs the error, and publishes a notice that
`RenderFailure.vue` shows as an alert dialog with focus on its first action.
The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
"Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
keeps it by default for other callers.
- **Retry** restarts the render loop. The notice stays until a frame starts
and ends without an error: Cesium raises `postRender` after a failed frame
too.
- **Reload**, after a lost context, reloads the page; the address keeps the
view.
- **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
path; the query, such as `?lang=`, is kept.
Pitfalls:
- An error thrown outside Scene's error handling (a frame listener, `onTick`,
a resize) stops the loop without a `renderError`. The notice wraps the
widget's `render()` and `resize()` and raises it as one, once even with
`rethrowRenderErrors`.
- A throwing listener leaves its Cesium event mid-raise, with listener changes
only queued. The wrapper finishes those raises first (`finishRaise()` over
`frameEvents()`), so a layer turned off or on meanwhile behaves.
- A render error marks the loop stopped while its next frame is still queued.
The wrapper keeps the loop marked running, so a Retry or a shown tab
(`src/app/tools.js`) before that frame starts no second loop.
- After a lost context, later render errors are only logged, so the notice
keeps saying why. The listener neither prevents nor restores the loss.
- When the globe draws again, focus goes back where it was, or to the globe's
canvas, which the map's keys read as the map; the canvas is focusable only
until focus leaves or the globe is pressed. Focus the user moved stays put.
- The notice sits above floating panels such as the Québec camera viewer, and
its buttons are 44 px tall on touch screens.
An inline conversation panel never reloads or navigates its host's page: it
offers Retry after a render error only, and no notice after a lost context,
where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`).
Its timer (`keepPanelRendering()`) draws only while the render loop is on and
logs nothing itself, so each render error is logged once, by the notice. An
error a timer frame throws is caught there: it has already been raised as a
render error.
## Map Source control ownership ## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection Map Source controls own chip listeners, source-state subscriptions and selection
@@ -5825,6 +5960,39 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
settings and screen position rather than observed brightness. Selected-trail settings and screen position rather than observed brightness. Selected-trail
diagnostics report every condition even when selection fails. diagnostics report every condition even when selection fails.
Heights of the terrain globe's loaded surface come from one shared reader,
`src/services/terrainSurface.js`, which the ground service hands to every layer
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
drawn tile's mesh, so the reader keeps each answer per point, about a metre
apart. While the camera moves or tiles load a kept height is read again after
2 s; a height read once both have been still for 5 s is kept until either
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
of it for re-reads, longest-kept first. A request ranks as a marker (always
read), drawn geometry, or background (a quarter of each budget). Past the
budget a kept point keeps its last height and a new point takes the last
height read in its ~111 m floor cell or waits.
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
the path a marker is drawn along next, the undrawn trail history of
unselected vehicles as background, and its reread cycle continues until no
point waits. Before this, every reread cast a ray for every trail fix of
every vehicle in view, which blocked the page for up to 19 s at a time over
a 650-vehicle fleet while zooming.
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
so a moving train reads a few times a second and a standing one not at all.
- Local data layers (Data Centers, Dams) read terrain floors through it, and
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
frame per layer and continue on the next frames.
- Bike Share draws a city's stations at once at the fallback height and finds
their ground heights afterwards in 4 ms steps, from the terrain reader on a
terrain globe and from depth samples where the globe is hidden.
Verified in the browser on a keyless terrain globe only: Boston for Transit,
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
and any city steeper than Boston. Street Traffic keeps its own height cache
and ALPR cameras their own sample budget.
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
stroke over 3.25 px dark backing. Other contact themes retain their existing stroke over 3.25 px dark backing. Other contact themes retain their existing
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
@@ -7294,9 +7462,9 @@ pins and share links already supply complete boxes.
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
30-second backoff ahead of the current clock are ignored. A smaller rollback 30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds. can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. 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. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. - Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
- **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. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
@@ -7459,28 +7627,6 @@ apply to `public/` assets or built-preview serving.
holds the way it stripped detection's would take bracket promptness with it, silently. holds the way it stripped detection's would take bracket promptness with it, silently.
- Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not - Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not
re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work). re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work).
- **A detection paint reuses the sweep before it when nothing it reads has changed
(`src/data/detectionPaintCache.js`).** The sweep in `_drawOverlay` (project every contact, fade
it, append its bracket, build callout candidates) depends only on the view and on the contacts.
Each paint still asks every layer for its contacts, then compares them with what the last sweep
was built from: the same objects in the same order, the same position coordinates, and the same
value in every field the sweep reads (`PAINTED_OBJECT_FIELDS`), under the same view (profile,
style, canvas size, keyhole, camera position, view-projection matrix, UI rectangles; see
`_paintViewOf`). On a match the kept bracket paths, counts and callout candidates are used again
and only the strokes and callouts are repainted. No layer has to announce a change, so none can
leave a stale picture behind. A sweep that READS a new object field or a new view value must add
it to those two lists; `detectionHost.test.mjs` compares 41 frames against a full sweep, canvas
call for canvas call.
- Never reused or kept: a solve paint (the sweep gathers the cohort), any paint while the Cyber
sonar sweep or a Global Context echo is live (brackets change with the clock), and any sweep
that reached a depth-tested contact (its answer comes from the scene).
- Where it pays: a held camera with contacts that move in steps. Live Flights re-positions the
fleet every 80 ms, so at 60 fps about seven paints in ten reuse a sweep. Once a frame takes
longer than 80 ms every paint sees moved aircraft and none is reused; after eight such sweeps
the contacts are only recorded on every eighth, so the comparison costs nothing there.
Satellites move on every frame and are never reused.
- `getDetectionDiagnostics().paintReuseCount` (and `data-paint-reuse-count`) counts reused
paints since `initDetection`; `placementBuildCount` is 0 on a reused paint.
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is - The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
developer telemetry and is HIDDEN by default. It paints only under developer telemetry and is HIDDEN by default. It paints only under
`?detectDebug=1` (the `trafficDebug` convention), resolved once per `?detectDebug=1` (the `trafficDebug` convention), resolved once per
+1
View File
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | | `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | | `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and `update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
+1
View File
@@ -298,6 +298,7 @@
}, },
"./sources/rail-service": "./src/sources/railService.js", "./sources/rail-service": "./src/sources/railService.js",
"./layers/rail/source": "./src/layers/rail/source.js", "./layers/rail/source": "./src/layers/rail/source.js",
"./layers/rail/paths": "./src/data/railPaths.js",
"./sources/map-features": "./src/sources/overpassFeatures.js", "./sources/map-features": "./src/sources/overpassFeatures.js",
"./server/providers/mapillary": { "./server/providers/mapillary": {
"node": "./server/providers/mapillary.js" "node": "./server/providers/mapillary.js"
+6
View File
@@ -19,6 +19,12 @@ How the layers use them:
type's published length (a vessel to its reported AIS length and beam), so type's published length (a vessel to its reported AIS length and beam), so
every model is drawn life size. Ground objects, balloons and obstacles in every model is drawn life size. Ground objects, balloons and obstacles in
the `other` category are drawn at the size they are built. the `other` category are drawn at the size they are built.
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
port or starboard hand, safe water, isolated danger, special mark), a red
can when its type names no mark, at its built size and with no heading
(`vesselModelSpec`, `aidToNavigationModel`).
- An aircraft model's origin is its axis; it stands that model's scaled - An aircraft model's origin is its axis; it stands that model's scaled
clearance above the ground. A ship's origin is its waterline, and a transit clearance above the ground. A ship's origin is its waterline, and a transit
or rail vehicle's the road or rail top (a monorail or gondola is raised by or rail vehicle's the road or rail top (a monorail or gondola is raised by
+53 -34
View File
@@ -726,18 +726,18 @@
"file": "other/car.glb", "file": "other/car.glb",
"min": [ "min": [
-2.31, -2.31,
-0.906, -1.075,
0.019 0.018
], ],
"max": [ "max": [
2.3, 2.31,
0.906, 1.075,
1.948 1.84
], ],
"length": 4.61, "length": 4.62,
"beam": 1.812, "beam": 2.15,
"clearance": 0.0, "clearance": 0.0,
"tris": 206, "tris": 736,
"icon": "vehicle" "icon": "vehicle"
}, },
"car_carrier": { "car_carrier": {
@@ -1124,19 +1124,19 @@
"category": "other", "category": "other",
"file": "other/fire_truck.glb", "file": "other/fire_truck.glb",
"min": [ "min": [
-5.51, -6.408,
-1.5, -1.507,
0.027 0.029
], ],
"max": [ "max": [
5.5, 5.51,
1.5, 1.507,
4.08 3.64
], ],
"length": 11.01, "length": 11.918,
"beam": 3.0, "beam": 3.014,
"clearance": 0.0, "clearance": 0.0,
"tris": 314, "tris": 1016,
"icon": "vehicle" "icon": "vehicle"
}, },
"fishing_boat": { "fishing_boat": {
@@ -1751,19 +1751,19 @@
"category": "other", "category": "other",
"file": "other/pushback.glb", "file": "other/pushback.glb",
"min": [ "min": [
-3.0, -3.45,
-1.45, -1.398,
0.024 0.025
], ],
"max": [ "max": [
3.0, 3.05,
1.45, 1.398,
2.1 2.14
], ],
"length": 6.0, "length": 6.5,
"beam": 2.9, "beam": 2.795,
"clearance": 0.0, "clearance": 0.0,
"tris": 176, "tris": 490,
"icon": "vehicle" "icon": "vehicle"
}, },
"r44": { "r44": {
@@ -1785,6 +1785,25 @@
"tris": 1172, "tris": 1172,
"icon": "heli" "icon": "heli"
}, },
"radio_mast": {
"category": "other",
"file": "other/radio_mast.glb",
"min": [
-0.7,
-0.815,
0.0
],
"max": [
1.2,
0.815,
11.8
],
"length": 1.9,
"beam": 1.63,
"clearance": 0.0,
"tris": 940,
"icon": "vehicle"
},
"regional_dmu": { "regional_dmu": {
"category": "transit", "category": "transit",
"file": "transit/regional_dmu.glb", "file": "transit/regional_dmu.glb",
@@ -2284,18 +2303,18 @@
"file": "other/van.glb", "file": "other/van.glb",
"min": [ "min": [
-2.76, -2.76,
-1.0, -1.22,
0.019 0.018
], ],
"max": [ "max": [
2.75, 2.76,
1.0, 1.22,
2.438 2.56
], ],
"length": 5.51, "length": 5.52,
"beam": 2.0, "beam": 2.44,
"clearance": 0.0, "clearance": 0.0,
"tris": 206, "tris": 736,
"icon": "vehicle" "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,6 +983,14 @@
"SHIP": [ "SHIP": [
"blimp", "blimp",
75.0 75.0
],
"ZVEH": [
"car",
4.61
],
"GRND": [
"radio_mast",
1.9
] ]
}, },
"helicopters": { "helicopters": {
@@ -1143,12 +1151,41 @@
5.0 5.0
] ]
}, },
"aircraft_callsign": {
"ZVEH": [
{
"prefixes": [
"FIRE"
],
"model": "fire_truck",
"length": 11.01
},
{
"prefixes": [
"PUSH",
"TUG"
],
"model": "pushback",
"length": 6.0
},
{
"prefixes": [
"DEICER",
"SQUID",
"SQB"
],
"model": "van",
"length": 5.51
}
]
},
"vessels": [ "vessels": [
{ {
"types": [ "types": [
70, 70,
79 79
], ],
"typical_len": 300,
"rules": [ "rules": [
{ {
"min_len": 70, "min_len": 70,
@@ -1188,6 +1225,7 @@
80, 80,
89 89
], ],
"typical_len": 250,
"rules": [ "rules": [
{ {
"name_re": "\\bLNG\\b|\\bGAS\\b", "name_re": "\\bLNG\\b|\\bGAS\\b",
@@ -1214,6 +1252,7 @@
60, 60,
69 69
], ],
"typical_len": 140,
"rules": [ "rules": [
{ {
"min_len": 70, "min_len": 70,
@@ -1243,6 +1282,7 @@
37, 37,
37 37
], ],
"typical_len": 19,
"rules": [ "rules": [
{ {
"min_len": 35, "min_len": 35,
@@ -1273,6 +1313,7 @@
30, 30,
30 30
], ],
"typical_len": 24,
"rules": [ "rules": [
{ {
"min_len": 15, "min_len": 15,
@@ -1343,6 +1384,7 @@
55, 55,
55 55
], ],
"typical_len": 40,
"rules": [ "rules": [
{ {
"min_len": 25, "min_len": 25,
@@ -1358,6 +1400,7 @@
40, 40,
49 49
], ],
"typical_len": 40,
"rules": [ "rules": [
{ {
"min_len": 20, "min_len": 20,
@@ -1373,6 +1416,7 @@
33, 33,
33 33
], ],
"typical_len": 100,
"rules": [ "rules": [
{ {
"min_len": 50, "min_len": 50,
+17
View File
@@ -557,6 +557,13 @@
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs", "src/ui/frameRateView.test.mjs",
"src/ui/renderFailure.test.mjs",
"src/ui/renderFailureView.test.mjs",
"src/ui/renderFailureLoopErrors.test.mjs",
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/ui/renderLoopFixture.mjs",
"src/layers/cctv/lifecycle.js", "src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js", "src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js", "src/layers/cctv/geometry.js",
@@ -1429,8 +1436,18 @@
"src/data/placePacksLoading.test.mjs", "src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js", "src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs", "src/ui/placePacksLoading.test.mjs",
"src/cockpitSubjectProviders.js",
"src/cockpitSubjectProviders.test.mjs",
"src/cockpitProgress.js",
"src/cockpitProgress.test.mjs",
"src/ui/cockpitSubjectView.test.mjs",
"src/ui/styles/cockpitSubject.css",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/cockpitSubject.test.mjs",
"src/layers/transit/cockpitSubject.js",
"src/data/fleetModels.test.mjs", "src/data/fleetModels.test.mjs",
"src/data/fleetModelInstances.test.mjs", "src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs", "src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs" "scripts/measure-fleet-trail-anchors.mjs"
] ]
+40 -4
View File
@@ -11,7 +11,8 @@
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js" "src/sources/infrastructureData.js",
"src/services/terrainSurface.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -22,6 +23,9 @@
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/ui/views/index.js", "src/ui/views/index.js",
"src/ui/views/CockpitView.vue", "src/ui/views/CockpitView.vue",
"src/ui/views/panels/CockpitProgress.vue",
"src/cockpitProgress.js",
"src/cockpitSubjectProviders.js",
"src/ui/views/CommandDockView.vue", "src/ui/views/CommandDockView.vue",
"src/ui/views/ContextView.vue", "src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
@@ -163,6 +167,7 @@
"src/ui/kit/UiBadge.vue", "src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue", "src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/RenderFailure.vue",
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
@@ -247,6 +252,7 @@
"src/data/adsbLolFallback.js", "src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js", "src/data/aisWatchdog.js",
"src/data/vesselTypes.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"server/providers/acartia.js", "server/providers/acartia.js",
@@ -1009,6 +1015,8 @@
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -1174,6 +1182,8 @@
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -1356,6 +1366,7 @@
"src/ui/styles/status.css", "src/ui/styles/status.css",
"src/ui/styles/cockpit.css", "src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css", "src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitSubject.css",
"src/ui/styles/cockpitRadar.css", "src/ui/styles/cockpitRadar.css",
"src/ui/styles/overlays.css", "src/ui/styles/overlays.css",
"src/ui/styles/layers.css", "src/ui/styles/layers.css",
@@ -1505,6 +1516,7 @@
"exports": ["./layers/flights"], "exports": ["./layers/flights"],
"modules": [ "modules": [
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js", "src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
@@ -1584,6 +1596,7 @@
"src/layers/flights/modelSpec.js", "src/layers/flights/modelSpec.js",
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js", "src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
@@ -2101,6 +2114,7 @@
"src/layers/bikeshare/state.js", "src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js", "src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js", "src/layers/bikeshare/viewport.js",
"src/services/terrainSurface.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/i18n/index.js", "src/i18n/index.js",
@@ -2690,7 +2704,6 @@
"src/data/detectionDraw.js", "src/data/detectionDraw.js",
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/data/detectionOcclusion.js", "src/data/detectionOcclusion.js",
"src/data/detectionPaintCache.js",
"src/data/detectionPresentation.js", "src/data/detectionPresentation.js",
"src/data/detectionRenderDemand.js", "src/data/detectionRenderDemand.js",
"src/data/sceneOcclusionProbe.js", "src/data/sceneOcclusionProbe.js",
@@ -3053,6 +3066,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -3116,6 +3130,8 @@
"src/ui/displayPanelScroll.js", "src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/cockpitSubjectProviders.js",
"src/cockpitProgress.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js", "src/ui/cockpitSignals.js",
@@ -3133,6 +3149,7 @@
"src/ui/displayControls.js", "src/ui/displayControls.js",
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/renderFailure.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
"src/ui/weatherPanel.js", "src/ui/weatherPanel.js",
"src/ui/railCards.js", "src/ui/railCards.js",
@@ -3303,6 +3320,7 @@
"src/app/layers/rail.js", "src/app/layers/rail.js",
"src/data/railFeeds.js", "src/data/railFeeds.js",
"src/layers/rail/index.js", "src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js", "src/layers/rail/source.js",
"src/layers/rail/policy.js", "src/layers/rail/policy.js",
"src/layers/rail/ingestion.js", "src/layers/rail/ingestion.js",
@@ -3329,6 +3347,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -4228,6 +4247,7 @@
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/scopeMask.js", "src/scopeMask.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/data/pointerOwnerNames.js", "src/data/pointerOwnerNames.js",
@@ -4291,6 +4311,7 @@
"src/app/layers/rail.js", "src/app/layers/rail.js",
"src/data/railFeeds.js", "src/data/railFeeds.js",
"src/layers/rail/index.js", "src/layers/rail/index.js",
"src/layers/rail/cockpitSubject.js",
"src/layers/rail/source.js", "src/layers/rail/source.js",
"src/layers/rail/policy.js", "src/layers/rail/policy.js",
"src/layers/rail/ingestion.js", "src/layers/rail/ingestion.js",
@@ -4316,6 +4337,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -4373,7 +4395,6 @@
"src/data/detectionRenderDemand.js", "src/data/detectionRenderDemand.js",
"src/data/detectionPresentation.js", "src/data/detectionPresentation.js",
"src/data/detectionOcclusion.js", "src/data/detectionOcclusion.js",
"src/data/detectionPaintCache.js",
"src/data/sceneOcclusionProbe.js", "src/data/sceneOcclusionProbe.js",
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/app/layers/recentImagery.js", "src/app/layers/recentImagery.js",
@@ -4575,6 +4596,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -4801,7 +4823,10 @@
"src/data/approxDistance.js", "src/data/approxDistance.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftEmergency.js" "src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js"
], ],
"external": [] "external": []
}, },
@@ -5191,6 +5216,7 @@
"exports": ["./layers/transit/standalone"], "exports": ["./layers/transit/standalone"],
"modules": [ "modules": [
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
@@ -5228,6 +5254,7 @@
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/movement.js", "src/layers/transit/movement.js",
@@ -5246,6 +5273,7 @@
"src/renderGovernor.js", "src/renderGovernor.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
"src/services/terrainHeights.js", "src/services/terrainHeights.js",
@@ -5282,6 +5310,7 @@
"modules": [ "modules": [
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/fleetModels.js", "src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetModelInstances.js", "src/data/fleetModelInstances.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftModelLighting.js", "src/data/aircraftModelLighting.js",
@@ -5297,7 +5326,9 @@
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
@@ -5920,5 +5951,10 @@
"src/layers/wildlifeSightings/performance.js" "src/layers/wildlifeSightings/performance.js"
], ],
"external": [] "external": []
},
"rail-paths": {
"exports": ["./layers/rail/paths"],
"modules": ["src/data/railPaths.js"],
"external": []
} }
} }
+75
View File
@@ -339,6 +339,45 @@ try {
'#cctv-frame', '#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '', (image) => !image.getAttribute('src') && image.dataset.cameraId === '',
); );
// The address bar follows the share state after a short delay: wait for the
// CCTV panel token (1 collapsed, 0 open) the step expects.
const panelState = async (expectedToken) => {
const settled = await page
.waitForFunction(
(token) =>
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
),
{ timeout: 5000 },
expectedToken,
)
.then(
() => true,
() => false,
);
return page.evaluate(
(shared) => ({
collapsed: document
.getElementById('cctv-panel')
.classList.contains('collapsed'),
shared,
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
}),
settled ? expectedToken : null,
);
};
const closedOnDisable = await panelState('1');
check(
'disable collapses the panel and its share preference',
closedOnDisable.collapsed && closedOnDisable.shared === '1',
JSON.stringify(closedOnDisable),
);
// The switch sits inside the collapsed panel: reopen it from the right rail.
await page.click('[data-rail-item="cctv-panel"]');
await page.waitForFunction(
() =>
!document.getElementById('cctv-panel').classList.contains('collapsed'),
);
await page.click('#cctv-enable-btn'); await page.click('#cctv-enable-btn');
await page.waitForFunction( await page.waitForFunction(
() => () =>
@@ -351,6 +390,42 @@ try {
'disable clears the preview and re-enable reacquires the camera frame', 'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly, disabledCleanly,
); );
// Contacts switches CCTV off to isolate itself and restores it on the way
// out: the open panel and its share preference survive the round trip.
const cameraBeforeContacts = await page.evaluate(
() =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId,
);
const entered = await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode('flights'),
);
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const duringContacts = await panelState('0');
await page.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode(null),
);
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const afterContacts = await panelState('0');
check(
'Contacts isolation keeps the open CCTV panel and restores its camera',
entered?.ok === true &&
!duringContacts.collapsed &&
duringContacts.shared === '0' &&
!afterContacts.collapsed &&
afterContacts.shared === '0' &&
(await page.evaluate(
(cameraId) =>
window.__godsEyeView.styleManager._cctvControls.getState()
?.activeCameraId === cameraId,
cameraBeforeContacts,
)),
JSON.stringify({ entered, duringContacts, afterContacts }),
);
await page.$eval('#cctv-frame', (image) => await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }), image.scrollIntoView({ block: 'nearest' }),
); );
+2
View File
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
'ExtendedClassBPositionReport', 'ExtendedClassBPositionReport',
'ShipStaticData', 'ShipStaticData',
'StaticDataReport', 'StaticDataReport',
// Buoys, beacons and other aids to navigation (AIS message 21).
'AidsToNavigationReport',
]; ];
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is // Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within // REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
+41 -6
View File
@@ -1,4 +1,8 @@
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
import {
aidToNavigationTypeName,
isAidToNavigation,
} from '../../../src/data/vesselTypes.js';
export const AISSTREAM_CACHE_MAX = 50000; export const AISSTREAM_CACHE_MAX = 50000;
export const AISSTREAM_STALE_MS = 30 * 60 * 1000; export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m // Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
const staticData = _aisStreamStatic.get(mmsi) || {}; const staticData = _aisStreamStatic.get(mmsi) || {};
const aidReport = messageType === 'AidsToNavigationReport';
_aisStreamVessels.set(mmsi, { _aisStreamVessels.set(mmsi, {
lat, lat,
lon, lon,
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, name:
(aidReport && aidNameFromAis(message)) ||
vesselNameFromAis(metadata, message, staticData) ||
`MMSI ${mmsi}`,
mmsi, mmsi,
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
type: vesselTypeFromAis(message, staticData), // An aid-to-navigation report's type is the kind of aid (0 to 31), not
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
type: aidReport
? aidToNavigationTypeName(message.Type)
: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination), destination: stringValue(message.Destination ?? staticData.destination),
length: staticData.length ?? null, length: staticData.length ?? null,
beam: staticData.beam ?? null, beam: staticData.beam ?? null,
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
// Use the AIS message's own report time, not server ingest wall-clock — // Use the AIS message's own report time, not server ingest wall-clock —
// trail spacing and dead reckoning depend on true fix epochs. // trail spacing and dead reckoning depend on true fix epochs.
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
// A virtual aid is broadcast where nothing physical stands. Only such a
// row carries the field, so ship rows stay their size.
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
_updatedAt: Date.now(), _updatedAt: Date.now(),
}); });
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
); );
} }
/**
* An aid's whole name: an aid-to-navigation report carries the first 20
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
*/
function aidNameFromAis(message) {
const name = stringValue(message.Name);
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
}
function vesselTypeFromAis(message, staticData = {}) { function vesselTypeFromAis(message, staticData = {}) {
return stringValue( return stringValue(
message.Type ?? message.Type ??
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
); );
} }
/**
* The current rows, newest first, with every vessel ahead of every aid to
* navigation: a capped request never gives up a ship for a buoy.
*/
export function aisStreamRows(maxRows) { export function aisStreamRows(maxRows) {
const cutoff = Date.now() - AISSTREAM_STALE_MS; const cutoff = Date.now() - AISSTREAM_STALE_MS;
const rows = []; const vessels = [];
const aids = [];
for (const row of _aisStreamVessels.values()) { for (const row of _aisStreamVessels.values()) {
if (row._updatedAt >= cutoff) rows.push(row); if (row._updatedAt >= cutoff)
(isAidToNavigation(row) ? aids : vessels).push(row);
} }
rows.sort((a, b) => b._updatedAt - a._updatedAt); const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row); vessels.sort(newestFirst);
if (vessels.length < maxRows) aids.sort(newestFirst);
return vessels
.concat(aids)
.slice(0, maxRows)
.map(({ _updatedAt, ...row }) => row);
} }
function pruneAisStreamCache() { function pruneAisStreamCache() {
@@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b
} }
}); });
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.tool.setShape('area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
// draws again and the control that had focus is gone.
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
} finally {
h.restore();
}
});
test('the live preview hangs at the height the click landed on', async () => { test('the live preview hangs at the height the click landed on', async () => {
const h = harness(); const h = harness();
try { try {
+5 -2
View File
@@ -175,7 +175,7 @@ export function createApplicationCatalog({
displayParams: () => flights.getParams(), displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
}), }),
createApplicationBikeshare({ source: sources.bikeshare }), createApplicationBikeshare({ surface, source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }), createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }), createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(), createApplicationOsmPlaces(),
@@ -205,7 +205,10 @@ export function createApplicationCatalog({
}), }),
createApplicationCyclones({ feed: sources.cyclones }), createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers({
...localGeoJsonServices,
terrainSurface: surface.groundFloor.terrainSurface,
}),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
createApplicationRail({ createApplicationRail({
surface, surface,
+2
View File
@@ -11,6 +11,8 @@ import { t } from '../i18n/index.js';
* whose address does not carry one. `localePicker: false` removes the language * whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that offers its own language choice (`switchLocale` from * picker, for a host that offers its own language choice (`switchLocale` from
* the `./i18n` entry). * the `./i18n` entry).
* `services.cockpitSubjects` adds the application's own first-person subjects
* to the Cockpit (src/cockpitSubjectProviders.js).
*/ */
export function createApplicationControls({ export function createApplicationControls({
scene: { viewer, mapStackController, operations }, scene: { viewer, mapStackController, operations },
+11 -28
View File
@@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) {
const PANEL_FRAME_MS = 33; const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250; const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/** /**
* Keep the globe drawing in an inline panel. Some hosts report a panel on * Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them * screen as hidden, which stops the browser's animation frames and with them
@@ -122,33 +111,27 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
}; };
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer. // Cesium stops its render loop after a render error, and the timer stops
// Report the error in full: workers report plain objects, which Cesium // with it; Retry restarts both. The render failure notice logs the error
// prints as [object Object]. // (src/ui/renderFailure.js).
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => { const timer = windowRef.setInterval(() => {
if ( if (
failed || viewer.useDefaultRenderLoop === false ||
now() - lastFrame < MISSED_FRAMES_MS || now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.() viewer.isDestroyed?.()
) )
return; return;
viewer.resize(); try {
viewer.render(); viewer.resize();
viewer.render();
} catch {
// Raised as a render error by now (src/ui/renderFailure.js), which
// stops this timer; thrown on, it would only reach the host page.
}
}, PANEL_FRAME_MS); }, PANEL_FRAME_MS);
return () => { return () => {
windowRef.clearInterval(timer); windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest); windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
}; };
} }
+14 -27
View File
@@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
assert.equal(frame, null); assert.equal(frame, null);
}); });
test('a panel reports a render error in full and stops drawing', () => { test('a panel stops drawing while its render loop is stopped', () => {
let listener = null;
let tick = null; let tick = null;
const windowRef = { const windowRef = {
requestAnimationFrame: () => 1, requestAnimationFrame: () => 1,
@@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => {
clearInterval() {}, clearInterval() {},
}; };
const drawn = []; const drawn = [];
const viewer = { const viewer = { resize() {}, render: () => drawn.push('render') };
resize() {}, let clock = 0;
render: () => drawn.push('render'), const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
scene: { // Cesium stops its render loop after a render error.
renderError: { viewer.useDefaultRenderLoop = false;
addEventListener: (fn) => ((listener = fn), () => (listener = null)), clock = 1000;
}, tick();
}, assert.deepEqual(drawn, []);
}; // Retry restarts it, and the timer draws again.
const logged = []; viewer.useDefaultRenderLoop = true;
const original = console.error; tick();
console.error = (...args) => logged.push(args.join(' ')); assert.deepEqual(drawn, ['render']);
try { stop();
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
}); });
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
+9 -2
View File
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */ /** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) { export function createApplicationBikeshare({ source, surface }) {
return createBikeshareLayer({ return createBikeshareLayer({
source, source,
services: { render, sprites, picking, overlays }, services: {
render,
sprites,
picking,
overlays,
// Station heights come from the terrain surface every layer shares.
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
},
}); });
} }
+120
View File
@@ -0,0 +1,120 @@
// Retry in an inline panel whose host stops animation frames, so the panel
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { keepPanelRendering } from './embed.js';
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
function setup() {
let tick = null;
let clock = 0;
// The host reports the panel hidden: its own animation frames never arrive.
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const previousError = console.error;
console.error = () => {};
const state = { failure: null, drawn: 0 };
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {},
initializeFrame() {},
// Scene.render: a failing primitive update raises renderError
// (tryAndCatchError) and returns; a failing listener throws.
render() {
state.drawn += 1;
this.preUpdate.raiseEvent(this);
if (state.failure === 'caught')
this.renderError.raiseEvent(this, new Error('boom'));
if (state.failure === 'thrown') throw new Error('boom');
this.postRender.raiseEvent(this);
},
};
const { frames, widget, viewer, store, runFrames, restore } =
renderLoopFixture(scene, {
viewer: {
isDestroyed: () => false,
render: () => widget.render(),
resize: () => widget.resize(),
},
notice: { allowReload: false },
});
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
viewer.useDefaultRenderLoop = true;
const timerFrame = () => {
clock += 400;
tick();
};
return {
frames,
state,
store,
timerFrame,
animationFrames: runFrames,
restore: () => {
stop();
restore();
console.error = previousError;
},
};
}
for (const failure of ['caught', 'thrown']) {
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
// The host stops animation frames with the loop's next one queued.
assert.equal(frames.length, 1);
state.failure = failure;
timerFrame(); // an error thrown here does not reach the host page
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(typeof store.renderFailure.retry, 'function');
state.failure = null;
store.renderFailure.retry();
const before = state.drawn;
timerFrame();
assert.ok(state.drawn > before, 'the panel draws again after Retry');
assert.equal(
store.renderFailure,
null,
'a clean frame clears the notice',
);
assert.equal(frames.length, 1, 'Retry starts no second loop');
// The host resumes frames: the globe draws once a frame.
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
}
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
const { frames, state, store, timerFrame, animationFrames, restore } =
setup();
try {
state.failure = 'caught';
animationFrames(1);
assert.equal(store.renderFailure?.reason, 'render-error');
assert.equal(frames.length, 1);
state.failure = null;
store.renderFailure.retry();
timerFrame();
assert.equal(store.renderFailure, null);
assert.equal(frames.length, 1, 'Retry starts no second loop');
state.drawn = 0;
animationFrames(3);
assert.equal(state.drawn, 3);
} finally {
restore();
}
});
+8
View File
@@ -15,6 +15,8 @@ import {
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.js'; import { describeError } from './errors.js';
import { isEmbeddedInline } from './embed.js';
import { installRenderFailureNotice } from '../ui/renderFailure.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js'; import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js'; import { onLocaleChange, t } from '../i18n/index.js';
@@ -55,11 +57,17 @@ export async function createApplicationScene({
const viewer = createApplicationViewer({ const viewer = createApplicationViewer({
container: 'cesiumContainer', container: 'cesiumContainer',
creditContainer, creditContainer,
// The render failure notice below replaces Cesium's dead-end panel.
showRenderLoopErrors: false,
}); });
defer(() => { defer(() => {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
// An inline panel must not reload or navigate its host's page.
defer(
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
);
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer( defer(
followWorldTime(viewer, worldTimeFor('sky'), { followWorldTime(viewer, worldTimeFor('sky'), {
+16 -4
View File
@@ -375,8 +375,15 @@ export function installGlobeInViewRecovery(
}; };
} }
/** Cesium.Viewer options of the standard globe viewer. */ /**
export function applicationViewerOptions(creditContainer) { * Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
* false leaves render errors to the caller's own notice instead of Cesium's
* panel.
*/
export function applicationViewerOptions(
creditContainer,
{ showRenderLoopErrors = true } = {},
) {
return { return {
timeline: false, timeline: false,
animation: false, animation: false,
@@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) {
creditContainer, creditContainer,
msaaSamples: 4, msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } }, contextOptions: { webgl: { preserveDrawingBuffer: true } },
showRenderLoopErrors,
}; };
} }
/** Create the standard globe viewer in caller-owned, visible containers. */ /** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) { export function createApplicationViewer({
container,
creditContainer,
showRenderLoopErrors,
}) {
if (!container || !creditContainer) if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required'); throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer( const viewer = new Cesium.Viewer(
container, container,
applicationViewerOptions(creditContainer), applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
); );
try { try {
viewer.targetFrameRate = 60; viewer.targetFrameRate = 60;
+68 -2
View File
@@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) {
* then uses this amount to converge on the layer's delayed display position. * then uses this amount to converge on the layer's delayed display position.
* Capping correction below forward speed prevents a late feed fix from * Capping correction below forward speed prevents a late feed fix from
* becoming a first-person surge or reversal while still removing drift. * becoming a first-person surge or reversal while still removing drift.
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
* application subject's own (cockpitSubjectStepSec).
*/ */
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { export function cockpitAnchorCorrectionStep(
distanceM,
speedMps,
dtSec,
maxDtSec = 0.1,
) {
if ( if (
!Number.isFinite(distanceM) || !Number.isFinite(distanceM) ||
distanceM <= 0 || distanceM <= 0 ||
@@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
dtSec <= 0 dtSec <= 0
) )
return 0; return 0;
const dt = Math.min(0.1, dtSec); const dt = Math.min(
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
dtSec,
);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt)); const eased = distanceM * (1 - Math.exp(-1.25 * dt));
const correctionRateMps = Math.max(0.75, speed * 0.22); const correctionRateMps = Math.max(0.75, speed * 0.22);
return Math.min(distanceM, eased, correctionRateMps * dt); return Math.min(distanceM, eased, correctionRateMps * dt);
} }
/**
* The seconds an application subject's camera update advances its heading
* slew, ground lift and anchor by: the time since the last update, at most
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
* and eases at its configured rates, while a long stall (a hidden tab) still
* moves it no further than one late update would.
* @param {number} elapsedMs Time since the last camera update.
* @param {number} intervalMs The subject's update interval (0: every frame).
* @returns {number}
*/
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
const elapsedSec = Number.isFinite(elapsedMs)
? Math.max(0, elapsedMs) / 1000
: 0;
const intervalSec = Number.isFinite(intervalMs)
? Math.max(0, intervalMs) / 1000
: 0;
return Math.min(intervalSec + 0.1, elapsedSec);
}
/** Return whether a throttled cockpit presentation update is due. */ /** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
@@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
return nowMs - startedMs >= timeoutMs; return nowMs - startedMs >= timeoutMs;
} }
/**
* How fast the clearance a ground clamp adds under an application subject's
* eye comes and goes (m/s).
*/
export const COCKPIT_SUBJECT_LIFT_MPS = 1.5;
/**
* The lift the ground clamp gives an application subject's eye this update.
* The floor is read from coarse cells that refine as tiles load, so the lift
* it asks for jumps; a ridden eye takes the clearance part of it at
* `rateMps`, while what it needs to stay out of the floor itself (`hardM`)
* applies at once.
* @param {number|null} previousM The lift given last update; null on entry.
* @param {number} targetM The lift the floor and clearance ask for (m).
* @param {number} hardM The lift that keeps the eye out of the floor (m).
* @param {number} dtSec Seconds since the last update.
* @param {number} [rateMps]
* @returns {number} At least 0, at least `hardM`.
*/
export function easeCockpitLift(
previousM,
targetM,
hardM,
dtSec,
rateMps = COCKPIT_SUBJECT_LIFT_MPS,
) {
const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0;
const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0;
if (!Number.isFinite(previousM)) return Math.max(target, hard);
const step =
Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) *
Math.max(0, Number.isFinite(dtSec) ? dtSec : 0);
const eased = previousM + Math.max(-step, Math.min(step, target - previousM));
return Math.max(0, hard, eased);
}
/** /**
* Keep the cockpit camera above the shared rendered-surface floor. * Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height. * Unknown floors preserve the proposed camera height.
+52
View File
@@ -8,8 +8,11 @@ import {
cockpitAnchorCorrectionStep, cockpitAnchorCorrectionStep,
cockpitAltitudeDisplayFt, cockpitAltitudeDisplayFt,
cockpitGroundSafeHeight, cockpitGroundSafeHeight,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired, cockpitSurfaceWaitExpired,
cockpitUiUpdateDue, cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
easeCockpitLift,
compassDivisions, compassDivisions,
compassMarkerSlot, compassMarkerSlot,
scrollingCompassDivisions, scrollingCompassDivisions,
@@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0); assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
}); });
test('an application subject on a slow cadence is corrected for its whole step', () => {
// The built-in views' step stays capped at 0.1 s...
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
// ...while a subject updated every 500 ms is corrected for all of it.
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
});
test('an application subject steps by its interval, never by a long stall', () => {
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
// A hidden tab's minutes count as one late update.
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
});
test('cockpit camera clearance never falls below the rendered ground floor', () => { test('cockpit camera clearance never falls below the rendered ground floor', () => {
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69); assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200); assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
@@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null); assert.equal(formatCockpitEmergency({ label: '' }), null);
}); });
test('an application subject’s ground lift eases its clearance, never the floor', () => {
// On entry the lift asked for applies at once.
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
// A floor cell that rises by a metre is met at the lift rate...
let lift = 0;
const frames = [];
for (let i = 0; i < 60; i += 1) {
lift = easeCockpitLift(lift, 1, 0, 0.016);
frames.push(lift);
}
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
for (let i = 1; i < frames.length; i += 1)
assert.ok(
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
);
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
// ...and given back the same way.
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
// What keeps the eye out of the floor itself is never eased.
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
assert.equal(
easeCockpitLift(0.2, -3, -3, 0.016),
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
);
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
});
+182
View File
@@ -0,0 +1,182 @@
/**
* @module cockpitProgress
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
* at a width in pixels: where each tick, label and the marker go.
*
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
* with each other at their mean place. A merged tick takes the strongest tone
* of those it holds. Labels never overlap: where two would, the one of lower
* priority is left out (on a tie, the later one).
*
* Pure: no DOM.
*/
/** Bare ticks closer than this merge (px). */
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
/** Room kept between two labels (px). */
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
/** Tones from weakest to strongest, for a merged tick. */
const TONE_RANK = Object.freeze([
'muted',
'normal',
'live',
'estimate',
'warning',
'alert',
]);
const stronger = (a, b) =>
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
/** A label's width estimate (px): its longer line in small capitals. */
export function estimateProgressLabelPx(label) {
const longest = Math.max(
String(label?.text ?? '').length,
String(label?.detail ?? '').length,
);
return longest * 6.4 + 8;
}
/**
* The progress line at `widthPx`, or null when there is nothing to draw.
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
* @param {number} widthPx
* @param {{minTickPx?: number, gapPx?: number,
* measure?: (label: object) => number}} [options]
* @returns {{ticks: Array<{x: number, tone: string, count: number,
* labelled: boolean}>, labels: Array<{x: number, left: number,
* width: number, align: string, text: string, detail: string,
* tone: string}>, marker: {x: number, dwell: boolean}|null,
* travelledX: number, tag: string}|null}
*/
export function cockpitProgressLayout(
progress,
widthPx,
{
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
measure = estimateProgressLabelPx,
} = {},
) {
const ticks = progress?.ticks;
if (!Array.isArray(ticks) || ticks.length < 2) return null;
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
const placed = ticks.map((tick, order) => ({
x: tick.at * widthPx,
tone: tick.tone,
label: tick.label,
order,
}));
const labelled = placed.filter((tick) => tick.label);
// Bare ticks beside a labelled one go into it.
const kept = labelled.map((tick) => ({
x: tick.x,
tone: tick.tone,
count: 1,
labelled: true,
label: tick.label,
order: tick.order,
}));
const bare = [];
for (const tick of placed) {
if (tick.label) continue;
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
if (host) {
host.tone = stronger(host.tone, tick.tone);
host.count += 1;
} else bare.push(tick);
}
// Bare ticks in a chain closer than minTickPx merge at their mean place.
let cluster = null;
const flush = () => {
if (!cluster) return;
kept.push({
x: cluster.sum / cluster.count,
tone: cluster.tone,
count: cluster.count,
labelled: false,
label: null,
order: cluster.order,
});
cluster = null;
};
for (const tick of bare) {
if (cluster && tick.x - cluster.last < minTickPx) {
cluster.sum += tick.x;
cluster.count += 1;
cluster.last = tick.x;
cluster.tone = stronger(cluster.tone, tick.tone);
} else {
flush();
cluster = {
sum: tick.x,
count: 1,
last: tick.x,
tone: tick.tone,
order: tick.order,
};
}
}
flush();
kept.sort((a, b) => a.x - b.x || a.order - b.order);
// Labels by priority; one that would overlap a placed label is left out.
const boxes = [];
const candidates = kept
.filter((tick) => tick.labelled)
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
for (const tick of candidates) {
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
let left =
tick.label.align === 'start'
? tick.x
: tick.label.align === 'end'
? tick.x - width
: tick.x - width / 2;
left = Math.min(Math.max(0, left), widthPx - width);
const right = left + width;
if (
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
)
continue;
boxes.push({
x: tick.x,
left,
right,
width,
align: tick.label.align,
text: tick.label.text,
detail: tick.label.detail,
tone: tick.tone,
order: tick.order,
});
}
boxes.sort((a, b) => a.left - b.left);
const marker = progress.marker
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
: null;
return {
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
x,
tone,
count,
labelled: isLabelled,
})),
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
x,
left,
width,
align,
text,
detail,
tone,
})),
marker,
travelledX: marker ? marker.x : 0,
tag: progress.tag || '',
};
}
+144
View File
@@ -0,0 +1,144 @@
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
// width: close bare ticks merge, labelled ticks keep their place, labels never
// overlap (the lower priority gives way) and the part before the marker is
// the travelled part.
import test from 'node:test';
import assert from 'node:assert/strict';
import { cockpitProgressLayout } from './cockpitProgress.js';
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
const label = (text, priority, align = 'center', detail = '') => ({
text,
detail,
priority,
align,
});
/** Every label is 50 px wide. */
const measure = () => 50;
test('nothing is drawn without two ticks or a width', () => {
assert.equal(cockpitProgressLayout(null, 500), null);
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
assert.equal(
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
null,
);
});
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.1),
tick(0.101, null, 'warning'),
tick(0.102),
tick(0.5),
tick(1, label('Z', 1, 'end')),
],
marker: { at: 0.3, dwell: false },
tag: 'TIMETABLE',
},
1000,
{ measure },
);
assert.deepEqual(
layout.ticks.map(({ x, count, tone, labelled }) => [
Math.round(x),
count,
tone,
labelled,
]),
[
[0, 1, 'normal', true],
[101, 3, 'warning', false],
[500, 1, 'normal', false],
[1000, 1, 'normal', true],
],
);
assert.deepEqual(layout.marker, { x: 300, dwell: false });
assert.equal(layout.travelledX, 300);
assert.equal(layout.tag, 'TIMETABLE');
});
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('A', 1, 'start')),
tick(0.498),
tick(0.5, label('Next', 3), 'alert'),
tick(0.503, null, 'warning'),
tick(1, label('Z', 1, 'end')),
],
marker: null,
},
1000,
{ measure },
);
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
assert.equal(next.count, 3);
assert.equal(next.labelled, true);
assert.equal(next.tone, 'alert');
assert.equal(layout.ticks.length, 3);
assert.equal(layout.marker, null);
assert.equal(layout.travelledX, 0);
});
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
const layout = cockpitProgressLayout(
{
ticks: [
tick(0, label('First', 1, 'start')),
tick(0.04, label('Previous', 2)),
tick(0.5, label('Next', 3)),
tick(0.52, label('Close', 0)),
tick(1, label('Last', 1, 'end')),
],
marker: { at: 0.2, dwell: true },
},
1000,
{ measure },
);
assert.deepEqual(
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
[
['Previous', 15],
['Next', 475],
['Last', 950],
],
);
// The first stop's label would cover the previous stop's: it gives way.
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
assert.deepEqual(layout.marker, { x: 200, dwell: true });
});
test('a centred label at an edge is clamped inside the line', () => {
const layout = cockpitProgressLayout(
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
300,
{ measure },
);
assert.equal(layout.labels[0].left, 0);
const right = cockpitProgressLayout(
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
300,
{ measure },
);
assert.equal(right.labels[0].left, 250);
});
test('a long run of calls still lays out, every one a tick or part of one', () => {
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
ticks[0].label = label('First', 1, 'start');
ticks[59].label = label('Last', 1, 'end');
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
const count = (layout) =>
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
assert.equal(count(narrow), 60);
assert.equal(count(wide), 60);
assert.ok(narrow.ticks.length < wide.ticks.length);
assert.equal(wide.ticks.length, 60);
});
+644
View File
@@ -0,0 +1,644 @@
/**
* @module cockpitSubjectProviders
* @description Subject providers: kinds of subject an application adds to the
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
* subject entry would take, holds it while the view rides it, hands the camera
* a frame each update and the HUD a view model, and releases it on exit. Core
* knows nothing else about it.
*
* Everything a provider returns is normalized here before the Cockpit uses
* it: a malformed provider is left out, a malformed frame ends the view, and a
* malformed HUD view is read field by field, never thrown on.
*
* Pure: no Cesium, no DOM.
*/
/** The kinds the Cockpit rides itself; no provider may take one. */
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
'aircraft',
'satellite',
'vessel',
]);
/** The tones a provider's HUD texts may take. */
export const COCKPIT_SUBJECT_TONES = Object.freeze([
'normal',
'muted',
'live',
'estimate',
'warning',
'alert',
]);
/**
* The heights a frame may give (m, ellipsoidal): below the deepest dry
* ground, above anything ridden near it; outside, the frame stands on the
* floor.
*/
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
/** The most readouts, sources and signals a HUD view shows. */
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
/** The most ticks a progress line takes. */
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
const REQUIRED_METHODS = Object.freeze([
'candidate',
'hold',
'read',
'hud',
'release',
]);
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
const TONES = new Set(COCKPIT_SUBJECT_TONES);
const isObject = (value) =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
/**
* `value` as display text: trimmed, at most `max` characters (the last one
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
* kept whole or left out); '' for anything that is not a string or a number.
*/
export function cockpitSubjectText(value, max) {
if (typeof value !== 'string' && !finite(value)) return '';
const text = String(value).replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
let end = max - 1;
const last = text.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
return `${text.slice(0, end)}…`;
}
const tone = (value) => (TONES.has(value) ? value : 'normal');
/** A Material Symbols entry face, or null. */
function faceOf(raw) {
if (!isObject(raw)) return null;
const label = cockpitSubjectText(raw.label, 16);
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
return null;
return Object.freeze({ icon: raw.icon, label });
}
/** The labels a provider gives, normalized, or null without the required. */
function labelsOf(raw) {
if (!isObject(raw)) return null;
const labels = {
view: cockpitSubjectText(raw.view, 48),
exit: cockpitSubjectText(raw.exit, 48),
exitText: cockpitSubjectText(raw.exitText, 24),
kicker: cockpitSubjectText(raw.kicker, 48),
};
return labels.view && labels.exit && labels.exitText
? Object.freeze(labels)
: null;
}
/** A provider's look, normalized, or null without an aheadLabel. */
function lookOf(raw) {
if (!isObject(raw)) return null;
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
if (!aheadLabel) return null;
return Object.freeze({
restPitchDeg: finite(raw.restPitchDeg)
? clamp(raw.restPitchDeg, -90, 90)
: 0,
aheadLabel,
});
}
/**
* A provider's worded member `key` as it reads now, normalized: the value,
* or what the function it is returns when called on `provider` (so it can
* follow the page's language); null when it is malformed or a read throws.
* Reading the member, and reading what it gives, runs provider code too (a
* getter), so both are guarded as the call is.
*/
function wordedNow(provider, key, normalize) {
try {
const value = provider[key];
return (
normalize(typeof value === 'function' ? value.call(provider) : value) ??
null
);
} catch {
return null;
}
}
/** A provider's name in a warning: its kind, cut, or its place in the list. */
function providerName(provider, order) {
try {
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
} catch {
// A kind getter that throws gives no name.
}
return `#${order}`;
}
/** Why `provider` cannot be kept, or null. */
function refusal(provider, kinds) {
if (!isObject(provider)) return 'not an object';
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
return 'malformed kind';
if (RESERVED.has(provider.kind)) return 'reserved kind';
if (kinds.has(provider.kind)) return 'repeated kind';
if (
typeof provider.layerId !== 'string' ||
!LAYER_ID_PATTERN.test(provider.layerId)
)
return 'malformed layer id';
for (const method of REQUIRED_METHODS)
if (typeof provider[method] !== 'function') return `no ${method}()`;
if (
provider.isEntryAllowed !== undefined &&
typeof provider.isEntryAllowed !== 'function'
)
return 'isEntryAllowed is not a function';
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
if (!wordedNow(provider, 'labels', labelsOf))
return 'labels need view, exit and exitText';
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
return 'look needs an aheadLabel';
return null;
}
/**
* The application's subject providers, checked once and frozen, in the order
* entry asks them: ascending `priority`, ties in list order. Each has
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
* - `priority`: a finite number (default 0);
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
* most 16 characters, in the page's language;
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
* button's accessible names, the exit button's text and the default kicker,
* in the page's language;
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
* ahead button named `aheadLabel`;
* `entryFace`, `labels` and `look` may each be a function returning the
* object: it is called on the provider again whenever they are shown and
* when the page's language changes, so its words can follow it
* (`./i18n`). A read that throws or is malformed keeps the last
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
* updates the camera every rendered frame;
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
* positive or Infinity, null for the Cockpit's default;
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
* synchronous (createCockpitSubjectCaller) and called on the provider, so
* a class instance's methods keep their `this`. `isEntryAllowed` allows
* entry only by returning `true`. `hold` returns `true` once it holds the
* subject, or `false` to refuse entry; any other answer refuses it too,
* with one warning. A refused hold, and one that throws or returns a
* promise, is followed by `release(id, {restore: false})`, and by another
* once that promise settles; `release` must therefore be harmless for a
* subject it does not hold or already gave back.
*
* A provider that is not so, or whose members throw when read (a getter), is
* left out with one warning naming it; the providers after it are kept.
* @param {unknown} list
* @param {{warn?: (message: string) => void}} [options]
* @returns {ReadonlyArray<object>}
*/
export function cockpitSubjectProviders(
list = [],
{ warn = (message) => console.warn(message) } = {},
) {
const kept = [];
const kinds = new Set();
(Array.isArray(list) ? list : []).forEach((provider, order) => {
let reason;
let copy = null;
try {
reason = refusal(provider, kinds);
if (!reason) copy = keptProvider(provider, order);
} catch {
reason = 'a member threw when read';
}
if (reason) {
warn(
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
);
return;
}
kinds.add(copy.kind);
kept.push(copy);
});
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
return Object.freeze(kept);
}
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
function keptProvider(provider, order) {
// Worded members are read again on each use (see above); the last good
// read stands in for one that is malformed or throws.
let face = wordedNow(provider, 'entryFace', faceOf);
let labels = wordedNow(provider, 'labels', labelsOf);
// On or off, and its rest pitch, as first read.
let look = wordedNow(provider, 'look', lookOf);
const restPitchDeg = look?.restPitchDeg ?? 0;
return Object.freeze({
kind: provider.kind,
layerId: provider.layerId,
priority: finite(provider.priority) ? provider.priority : 0,
order,
get entryFace() {
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
return face;
},
get labels() {
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
return labels;
},
get look() {
if (!look) return null;
const now = wordedNow(provider, 'look', lookOf);
if (now)
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
return look;
},
cameraIntervalMs:
finite(provider.cameraIntervalMs) &&
provider.cameraIntervalMs >= 0 &&
provider.cameraIntervalMs <= 1000
? provider.cameraIntervalMs
: null,
headingSlewDps:
provider.headingSlewDps === Infinity ||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
? provider.headingSlewDps
: null,
// Bound, so a method that uses `this` (a class instance's, say) still
// reads its own provider when called on this copy.
candidate: provider.candidate.bind(provider),
isEntryAllowed: provider.isEntryAllowed
? provider.isEntryAllowed.bind(provider)
: () => true,
hold: provider.hold.bind(provider),
read: provider.read.bind(provider),
hud: provider.hud.bind(provider),
release: provider.release.bind(provider),
});
}
/**
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
* usable id. `name` falls back to the id; `face` is null unless well formed;
* `selectedAt` (a number or a short string, else null) is a token the
* provider changes each time its subject is selected, even again: a
* subject selected after the built-in ones takes entry (entryCandidates).
* Null too when reading `raw` throws (a getter runs provider code).
* @param {unknown} raw What `candidate()` returned.
*/
export function cockpitSubjectCandidate(raw) {
try {
return candidateOf(raw);
} catch {
// A getter on what candidate() returned threw: no candidate.
return null;
}
}
function candidateOf(raw) {
if (!isObject(raw) || typeof raw.id !== 'string') return null;
const id = raw.id.trim();
if (!id || id.length > 128) return null;
const selectedAt =
finite(raw.selectedAt) ||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
? raw.selectedAt
: null;
return Object.freeze({
id,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
face: faceOf(raw.face),
selectedAt,
});
}
/**
* One camera frame from a provider, normalized, or null when it cannot place
* the camera (the view then ends): not an object, an id other than `heldId`,
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
* read of it that throws (a getter runs provider code).
* A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that
* is cold. The anchor is `'inertial'` only when asked for and the frame
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
* trimmed, as `cockpitSubjectCandidate` trims it.
* @param {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
* pitchDeg, minClearanceM}}`.
*/
export function cockpitSubjectFrame(raw, heldId) {
try {
return frameOf(raw, heldId);
} catch {
// A getter on what read() returned threw: as a malformed frame.
return null;
}
}
function frameOf(raw, heldId) {
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
return null;
const latitude = raw.latitude;
const longitude = raw.longitude;
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
return null;
const eye = isObject(raw.eye) ? raw.eye : null;
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
const heading = (value) =>
finite(value) ? ((value % 360) + 360) % 360 : null;
const headingDeg = heading(raw.headingDeg);
const speedMps =
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
return {
id: heldId,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
latitude,
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
heightM:
finite(raw.heightM) &&
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
? raw.heightM
: null,
headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps,
stale: raw.stale === true,
groundClamp: raw.groundClamp !== false,
// An inertial anchor runs on at the frame's speed; with none it would
// close on a moving subject at the correction's floor and fall behind.
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
eye: {
upM: eye.upM,
forwardM:
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
? eye.forwardM
: 0,
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
minClearanceM:
finite(eye.minClearanceM) && eye.minClearanceM >= 0
? eye.minClearanceM
: 0,
},
};
}
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
function rowsOf(raw, max, row) {
if (!Array.isArray(raw)) return [];
const rows = [];
const ids = new Set();
for (const entry of raw) {
if (rows.length >= max) break;
if (!isObject(entry)) continue;
const id = typeof entry.id === 'string' ? entry.id : '';
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
const read = row(entry);
if (!read) continue;
ids.add(id);
rows.push({ id, ...read });
}
return rows;
}
/** A progress line, or null without at least two ticks. */
function progressOf(raw) {
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
const ticks = [];
for (const tick of raw.ticks) {
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
continue;
let label = null;
if (isObject(tick.label)) {
const text = cockpitSubjectText(tick.label.text, 40);
if (text)
label = {
text,
detail: cockpitSubjectText(tick.label.detail, 64),
priority: finite(tick.label.priority)
? clamp(Math.round(tick.label.priority), 0, 9)
: 0,
align: ['start', 'center', 'end'].includes(tick.label.align)
? tick.label.align
: 'center',
};
}
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
}
if (ticks.length < 2) return null;
ticks.sort((a, b) => a.at - b.at);
const marker =
isObject(raw.marker) && finite(raw.marker.at)
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
: null;
return {
ticks,
marker,
tag: cockpitSubjectText(raw.tag, 16),
summary: cockpitSubjectText(raw.summary, 160),
};
}
/**
* A provider's HUD view model, normalized; never throws. Texts are coerced
* and cut to their caps, lists to their lengths, tones to
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
* Null when reading `raw` throws (a getter runs provider code), so the caller
* keeps the view it has, as when `hud()` itself throws.
* All text is shown as given: the provider words it in the page's language.
* @param {unknown} raw What `hud()` returned.
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
* sources: [{id, label, text, tone}],
* route: {title, from, to, status}|null,
* progress: {ticks: [{at, tone, label: {text, detail, priority,
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
* lookReadout, signals: [{id, tone, title, detail}]}`.
*/
export function cockpitSubjectHudView(raw) {
try {
return hudViewOf(raw);
} catch {
// A getter on what hud() returned threw: the caller keeps its last view.
return null;
}
}
function hudViewOf(raw) {
const view = isObject(raw) ? raw : {};
const meta = isObject(view.meta) ? view.meta : {};
const speed =
isObject(view.speed) &&
(view.speed.value === null ||
(finite(view.speed.value) && view.speed.value >= 0))
? {
value:
view.speed.value === null
? null
: Math.min(view.speed.value, 99_999),
unit: cockpitSubjectText(view.speed.unit, 12),
tag: cockpitSubjectText(view.speed.tag, 8),
tone: tone(view.speed.tone),
}
: null;
const route = isObject(view.route)
? {
title: cockpitSubjectText(view.route.title, 32),
from: cockpitSubjectText(view.route.from, 48),
to: cockpitSubjectText(view.route.to, 48),
status: cockpitSubjectText(view.route.status, 64),
}
: null;
const bannerTitle = isObject(view.banner)
? cockpitSubjectText(view.banner.title, 48)
: '';
const veil =
isObject(view.veil) && finite(view.veil.level)
? {
level: clamp(view.veil.level, 0, 1),
streaks: view.veil.streaks === true,
}
: null;
return {
kicker: cockpitSubjectText(view.kicker, 48),
title: cockpitSubjectText(view.title, 64),
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
speed,
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
const label = cockpitSubjectText(row.label, 24);
const value = cockpitSubjectText(row.value, 48);
return label || value
? {
label,
value,
detail: cockpitSubjectText(row.detail, 80),
tone: tone(row.tone),
}
: null;
}),
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
const text = cockpitSubjectText(row.text, 120);
return text
? {
label: cockpitSubjectText(row.label, 16),
text,
tone: tone(row.tone),
}
: null;
}),
route: route && (route.from || route.to) ? route : null,
progress: progressOf(view.progress),
banner: bannerTitle
? {
title: bannerTitle,
detail: cockpitSubjectText(view.banner.detail, 96),
tone: tone(view.banner.tone),
}
: null,
veil: veil && veil.level > 0 ? veil : null,
lookReadout: cockpitSubjectText(view.lookReadout, 48),
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
const title = cockpitSubjectText(row.title, 48);
return title
? {
title,
detail: cockpitSubjectText(row.detail, 96),
tone: tone(row.tone),
}
: null;
}),
};
}
/**
* A guarded caller for provider methods: `call(provider, method, args,
* fallback)` returns what the method returns, or `fallback` when it throws,
* warning once per provider and method.
*
* Provider methods are synchronous: the Cockpit calls them on its render
* cadence and uses the answer at once. One that returns a promise (an async
* function, say) breaks that contract: the promise is refused as a throw
* would be (its rejection, if any, is swallowed), with one warning, and
* `onSettled`, when given, runs once the promise has settled, so a caller
* can undo work that lands late.
*
* `call.warnOnce(provider, method, message, error)` warns on the same
* once-per-provider-and-method footing, for an answer the caller finds
* malformed.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
warn = (message, error) => console.warn(message, error),
} = {}) {
const warned = new Set();
const warnOnce = (provider, method, message, error) => {
const key = `${provider.kind}:${method}`;
if (warned.has(key)) return;
warned.add(key);
warn(
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
error,
);
};
function call(
provider,
method,
args = [],
fallback = null,
onSettled = null,
) {
let result;
let thenable;
try {
result = provider[method](...args);
// Reading `then` runs provider code too (a getter may throw), so it is
// guarded as the call is.
thenable =
result !== null &&
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function';
} catch (error) {
warnOnce(provider, method, 'failed', error);
return fallback;
}
if (thenable) {
// Its work may still land: `onSettled` runs once it has, either way.
const settled = () => {
if (typeof onSettled === 'function') onSettled();
};
Promise.resolve(result).then(settled, settled);
warnOnce(
provider,
method,
'returned a promise; provider methods are synchronous',
null,
);
return fallback;
}
return result;
}
call.warnOnce = warnOnce;
return call;
}
+698
View File
@@ -0,0 +1,698 @@
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
// are checked once and what they return is normalized before the Cockpit
// uses it: a malformed provider is left out, a malformed frame ends the view,
// and a malformed HUD view is read field by field and never thrown on.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
COCKPIT_BUILT_IN_KINDS,
COCKPIT_SUBJECT_MAX_READOUTS,
COCKPIT_SUBJECT_MAX_TICKS,
cockpitSubjectCandidate,
cockpitSubjectFrame,
cockpitSubjectHudView,
cockpitSubjectProviders,
cockpitSubjectText,
createCockpitSubjectCaller,
} from './cockpitSubjectProviders.js';
const noop = () => null;
/** A well-formed provider, with `overrides`. */
const provider = (overrides = {}) => ({
kind: 'example-kind',
layerId: 'example-layer',
entryFace: { icon: 'commute', label: 'RIDE' },
labels: {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
},
candidate: noop,
hold: () => true,
read: noop,
hud: noop,
release: noop,
...overrides,
});
const keep = (list) => {
const warnings = [];
const kept = cockpitSubjectProviders(list, {
warn: (message) => warnings.push(message),
});
return { kept, warnings };
};
test('well-formed providers are kept, frozen, in priority then list order', () => {
const { kept, warnings } = keep([
provider({ kind: 'late', priority: 5 }),
provider({ kind: 'first-of-two', priority: 1 }),
provider({ kind: 'second-of-two', priority: 1 }),
provider({ kind: 'default-priority' }),
]);
assert.deepEqual(warnings, []);
assert.deepEqual(
kept.map(({ kind }) => kind),
['default-priority', 'first-of-two', 'second-of-two', 'late'],
);
assert.ok(Object.isFrozen(kept));
assert.ok(Object.isFrozen(kept[0]));
const [first] = kept;
assert.equal(first.priority, 0);
assert.equal(first.look, null);
assert.equal(first.cameraIntervalMs, null);
assert.equal(first.headingSlewDps, null);
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
assert.deepEqual(first.labels, {
view: 'First-person view',
exit: 'Exit first-person view',
exitText: 'EXIT VIEW',
kicker: '',
});
});
test('malformed, reserved and repeated providers are left out with one warning each', () => {
const cases = [
[null, /not an object/],
[provider({ kind: 'X' }), /malformed kind/],
[provider({ kind: 'a' }), /malformed kind/],
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
[provider({ hold: undefined }), /no hold\(\)/],
[provider({ read: 'read' }), /no read\(\)/],
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
];
for (const [entry, reason] of cases) {
const { kept, warnings } = keep([entry]);
assert.equal(kept.length, 0, String(reason));
assert.equal(warnings.length, 1, String(reason));
assert.match(warnings[0], reason);
}
for (const kind of COCKPIT_BUILT_IN_KINDS) {
const { kept, warnings } = keep([provider({ kind })]);
assert.equal(kept.length, 0, kind);
assert.match(warnings[0], /reserved kind/);
}
const { kept, warnings } = keep([provider(), provider()]);
assert.equal(kept.length, 1);
assert.match(warnings[0], /repeated kind/);
assert.deepEqual(keep('not a list').kept, []);
});
test('optional settings are kept when in range and defaulted otherwise', () => {
const [ranged] = keep([
provider({
priority: -2,
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
cameraIntervalMs: 0,
headingSlewDps: Infinity,
labels: {
view: 'v',
exit: 'e',
exitText: 'x',
kicker: 'K'.repeat(80),
},
}),
]).kept;
assert.equal(ranged.priority, -2);
assert.deepEqual(ranged.look, {
restPitchDeg: -90,
aheadLabel: 'Look ahead',
});
assert.equal(ranged.cameraIntervalMs, 0);
assert.equal(ranged.headingSlewDps, Infinity);
assert.equal(ranged.labels.kicker.length, 48);
assert.ok(ranged.labels.kicker.endsWith('…'));
const [defaulted] = keep([
provider({
priority: NaN,
cameraIntervalMs: 5000,
headingSlewDps: -1,
}),
]).kept;
assert.equal(defaulted.priority, 0);
assert.equal(defaulted.cameraIntervalMs, null);
assert.equal(defaulted.headingSlewDps, null);
});
test('a candidate needs an id; its name and face are read with care', () => {
assert.equal(cockpitSubjectCandidate(null), null);
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
id: 'a1',
name: 'a1',
face: null,
selectedAt: null,
});
// A selection token is a number or a short string.
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
5,
);
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
'click-2',
);
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
null,
);
const read = cockpitSubjectCandidate({
id: 'a1',
name: ' Example 12 ',
face: { icon: 'tram', label: 'RIDE' },
});
assert.equal(read.name, 'Example 12');
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
assert.equal(
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
null,
);
});
test('a frame is the held subject at a finite place with an eye height, else null', () => {
const frame = (overrides = {}) => ({
id: 'a1',
latitude: 47,
longitude: 8,
eye: { upM: 3.5 },
...overrides,
});
const read = cockpitSubjectFrame(frame(), 'a1');
assert.deepEqual(read, {
id: 'a1',
name: 'a1',
latitude: 47,
longitude: 8,
heightM: null,
headingDeg: null,
courseDeg: null,
speedMps: null,
stale: false,
groundClamp: true,
// No speed to run an inertial anchor on: it sits on the subject.
anchor: 'exact',
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
});
assert.equal(
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
'inertial',
'with a speed it runs on',
);
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
for (const bad of [
null,
frame({ id: 'other' }),
frame({ latitude: 91 }),
frame({ latitude: NaN }),
frame({ longitude: Infinity }),
frame({ eye: null }),
frame({ eye: { upM: -1 } }),
frame({ eye: { upM: 2000 } }),
frame({ eye: { upM: '3' } }),
])
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
const full = cockpitSubjectFrame(
frame({
name: 'Example',
longitude: 190,
heightM: 412.5,
headingDeg: -10,
courseDeg: 725,
speedMps: 22,
stale: true,
groundClamp: false,
anchor: 'exact',
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
}),
'a1',
);
assert.equal(full.longitude, -170);
assert.equal(full.heightM, 412.5);
// A height outside any plausible ground reads as none.
for (const heightM of [-6_400_000, 1e9, Infinity])
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
assert.equal(
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
-400,
);
assert.equal(full.headingDeg, 350);
assert.equal(full.courseDeg, 5);
assert.equal(full.speedMps, 22);
assert.equal(full.stale, true);
assert.equal(full.groundClamp, false);
assert.equal(full.anchor, 'exact');
assert.deepEqual(full.eye, {
upM: 2,
forwardM: 0,
pitchDeg: -90,
minClearanceM: 1.5,
});
// Only exactly false turns the ground clamp off; course falls back to heading.
const loose = cockpitSubjectFrame(
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
'a1',
);
assert.equal(loose.groundClamp, true);
assert.equal(
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
'inertial',
);
assert.equal(loose.courseDeg, 90);
assert.equal(loose.speedMps, null);
});
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
const empty = cockpitSubjectHudView(null);
assert.deepEqual(empty, {
kicker: '',
title: '',
meta: { text: '', tone: 'normal' },
speed: null,
readouts: [],
sources: [],
route: null,
progress: null,
banner: null,
veil: null,
lookReadout: '',
signals: [],
});
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
const view = cockpitSubjectHudView({
kicker: 'LINE · OPERATOR',
title: 'Name → Destination',
meta: { text: 'LIVE', tone: 'bogus' },
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
readouts: [
...Array.from({ length: 6 }, (_, i) => ({
id: `r${i}`,
label: 'NEXT',
value: `Stop ${i}`,
tone: 'warning',
})),
],
sources: [
{ id: 'track', label: 'TRACK', text: 'operator shape' },
{ id: 'track', label: 'TRACK', text: 'repeated id' },
{ id: 'Bad Id', text: 'refused' },
{ id: 'empty', label: 'X', text: '' },
],
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
veil: { level: 4, streaks: 1 },
lookReadout: 'LOOK RIGHT',
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
});
assert.equal(view.meta.tone, 'normal');
assert.deepEqual(view.speed, {
value: 84.4,
unit: 'km/h',
tag: 'LIVE',
tone: 'live',
});
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
assert.equal(view.readouts[0].tone, 'warning');
assert.deepEqual(
view.sources.map(({ id }) => id),
['track'],
);
assert.deepEqual(view.route, {
title: 'ROUTE',
from: 'A',
to: 'B',
status: '3 stops',
});
assert.equal(view.banner.tone, 'alert');
assert.deepEqual(view.veil, { level: 1, streaks: false });
assert.equal(view.lookReadout, 'LOOK RIGHT');
assert.equal(view.signals[0].tone, 'warning');
// A veil at 0, a speed below 0 and a route with no ends are absent.
const absent = cockpitSubjectHudView({
veil: { level: 0 },
speed: { value: -3 },
route: { from: '', to: '' },
});
assert.equal(absent.veil, null);
assert.equal(absent.speed, null);
assert.equal(absent.route, null);
// A speed of null is a speed not yet known.
assert.equal(
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
null,
);
});
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
const read = cockpitSubjectHudView({
progress: {
ticks: [
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
{ at: 0.5, tone: 'warning' },
{ at: 2 },
{ at: -0.1 },
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
],
marker: { at: 1.4, dwell: true },
tag: 'TIMETABLE',
summary: 'From Start to End',
},
}).progress;
assert.deepEqual(
read.ticks.map(({ at }) => at),
[0, 0.25, 0.5, 1],
);
assert.equal(read.ticks[0].label.priority, 9);
assert.equal(read.ticks[0].label.align, 'start');
assert.equal(read.ticks[1].label, null);
assert.equal(read.ticks[2].tone, 'warning');
assert.deepEqual(read.marker, { at: 1, dwell: true });
assert.equal(read.tag, 'TIMETABLE');
assert.equal(read.summary, 'From Start to End');
assert.equal(
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
null,
);
const many = cockpitSubjectHudView({
progress: {
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
})),
},
}).progress;
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
});
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
assert.equal(cockpitSubjectText({}, 10), '');
assert.equal(cockpitSubjectText(NaN, 10), '');
});
test('a cut never splits a surrogate pair', () => {
// The cut would fall between the emoji's halves: it is left out whole.
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
// One that fits before the cut is kept whole.
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
const lone =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
for (let max = 2; max <= 24; max += 1)
assert.equal(
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
false,
`cut at ${max}`,
);
});
test('a provider method that throws returns the fallback and warns once', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const failing = {
kind: 'example-kind',
read() {
throw new Error('boom');
},
hud: (value) => value * 2,
};
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
assert.equal(call(failing, 'hud', [21]), 42);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind read\(\) failed/);
});
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const asynchronous = {
kind: 'example-kind',
hold: async () => true,
read: () => Promise.reject(new Error('late')),
};
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'read', [], null), null);
assert.equal(warnings.length, 2, 'once per method');
assert.match(warnings[0], /hold\(\) returned a promise/);
// The rejected promise is not left unhandled.
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('an answer whose `then` throws when read is handled as a throw', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const tricky = {
kind: 'example-kind',
hold: () => ({
get then() {
throw new Error('no then');
},
}),
};
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind hold\(\) failed/);
});
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const ride = {
kind: 'example-kind',
hold() {
throw new Error('boom');
},
};
call.warnOnce(ride, 'read', 'is odd');
call.warnOnce(ride, 'read', 'is odd again');
call(ride, 'hold', ['a1']);
call.warnOnce(ride, 'hold', 'is odd');
assert.deepEqual(warnings, [
'[Cockpit] subject provider example-kind read() is odd',
'[Cockpit] subject provider example-kind hold() failed',
]);
});
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
class Ride {
#word = 'RIDE';
#held = null;
kind = 'example-kind';
layerId = 'example-layer';
speak(word) {
this.#word = word;
}
entryFace() {
return { icon: 'commute', label: this.#word };
}
get labels() {
return {
view: `${this.#word} view`,
exit: 'Exit ride view',
exitText: 'EXIT RIDE',
};
}
look() {
return { aheadLabel: `Look ahead, ${this.#word}` };
}
candidate() {
return { id: 'a1', name: this.#word };
}
isEntryAllowed(candidate) {
return candidate.id === 'a1' && this.#held === null;
}
hold(id) {
this.#held = id;
return true;
}
read() {
return this.#held;
}
hud(frame) {
return { title: `${this.#word} ${frame}` };
}
release() {
this.#held = null;
return 'released';
}
}
const instance = new Ride();
const { kept, warnings } = keep([instance]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.labels.view, 'RIDE view');
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
instance.speak('FAHRT');
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
// Called on the kept copy, as the Cockpit calls them.
const failures = [];
const call = createCockpitSubjectCaller({
warn: (message) => failures.push(message),
});
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
assert.equal(call(ride, 'hold', ['a1']), true);
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
assert.equal(call(ride, 'read', [0, {}]), 'a1');
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
assert.equal(call(ride, 'read', [0, {}]), null);
assert.deepEqual(failures, []);
});
test('labels, entry face and look given as functions are read again on each use', () => {
let lang = 'en';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
};
const { kept, warnings } = keep([
provider({
labels: () => words[lang],
entryFace: () => ({
icon: 'commute',
label: { en: 'RIDE', de: 'FAHRT' }[lang],
}),
look: () => ({
restPitchDeg: lang === 'de' ? 40 : -3,
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
}),
}),
]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.labels.exitText, 'EXIT RIDE');
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.look.aheadLabel, 'Look ahead');
lang = 'de';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
assert.equal(ride.look.aheadLabel, 'Nach vorn');
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
// A read that fails or is malformed keeps the last good one.
lang = 'xx';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
// Functions that are malformed from the start leave the provider out.
const bad = keep([
provider({ kind: 'bad-labels', labels: () => null }),
provider({
kind: 'thrown-face',
entryFace: () => {
throw new Error('no face');
},
}),
]);
assert.deepEqual(bad.kept, []);
assert.equal(bad.warnings.length, 2);
});
/** `target` with a getter for `key` that throws (provider code runs on a read). */
const throwingGetter = (target, key) =>
Object.defineProperty(target, key, {
enumerable: true,
get() {
throw new Error(`${key} unavailable`);
},
});
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
const { kept, warnings } = keep([
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
throwingGetter(provider(), 'kind'),
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
// Read only once the provider is accepted, so its kind must stay free.
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
provider({ kind: 'example-kind' }),
]);
assert.deepEqual(
kept.map(({ kind }) => kind),
['example-kind'],
);
assert.deepEqual(warnings, [
'[Cockpit] subject provider thrown-face left out: malformed entry face',
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
'[Cockpit] subject provider thrown-look left out: a member threw when read',
'[Cockpit] subject provider #3 left out: a member threw when read',
'[Cockpit] subject provider thrown-method left out: a member threw when read',
'[Cockpit] subject provider example-kind left out: a member threw when read',
]);
});
test('a worded getter that starts throwing keeps the last good words', () => {
let failing = false;
const words = (value) => () => {
if (failing) throw new Error('translations unavailable');
return value;
};
const definition = provider();
for (const [key, value] of Object.entries({
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
entryFace: { icon: 'commute', label: 'RIDE' },
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
}))
Object.defineProperty(definition, key, { get: words(value) });
const { kept, warnings } = keep([definition]);
assert.deepEqual(warnings, []);
const [ride] = kept;
failing = true;
assert.equal(ride.labels.view, 'Ride view');
assert.equal(ride.entryFace.label, 'RIDE');
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
// So does a worded object whose own member throws when read.
failing = false;
Object.defineProperty(definition, 'labels', {
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
});
assert.equal(ride.labels.view, 'Ride view');
});
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
assert.equal(
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
null,
);
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
assert.equal(
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
null,
);
assert.equal(
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
null,
);
assert.equal(
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
null,
);
assert.equal(
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
null,
);
// Nothing to read is still the empty view, never null.
assert.equal(cockpitSubjectHudView(null).title, '');
});
+18
View File
@@ -481,6 +481,24 @@ export function orbitEntryAllowed({
); );
} }
/**
* Whether an application subject provider's candidate can be ridden. Like the
* bridge and orbit it needs only its own layer on, and waits out a Context
* change in flight; the provider then has its own say (`isEntryAllowed`).
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
* providerAllows: boolean}} state
* @returns {boolean}
*/
export function subjectEntryAllowed({
layerEnabled,
contextModeChanging,
providerAllows,
}) {
return (
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
);
}
/** /**
* Internal context-mode id → the word the voice tools accept for it. * Internal context-mode id → the word the voice tools accept for it.
* *
+5 -1
View File
@@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const geometricFeet = finiteNumber(aircraft?.alt_geom); const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs); const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track); // A parked or slow aircraft reports no ground track, but often its true
// heading (surface messages): the way its nose points, which is what an
// aircraft standing still is drawn by. Magnetic heading is not true north.
const track =
finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading);
return [ return [
hex, hex,
+12
View File
@@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
assert.equal(normalized.states[0][7], null); assert.equal(normalized.states[0][7], null);
assert.equal(normalized.states[0][8], true); assert.equal(normalized.states[0][8], true);
}); });
test('an aircraft with no track takes its true heading, never its magnetic one', () => {
const at = (extra) =>
normalizeAdsbLolAircraftState(
{ hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra },
1000,
)[10];
assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first');
assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading');
assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic');
assert.equal(at({}), null, 'none');
});
+2 -1
View File
@@ -458,7 +458,8 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
); );
flightState._trackedIcao = null; flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null }); flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal); // An ordinary model draws its mesh's own colours, whatever the billboard's.
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
} }
}); });
+19 -5
View File
@@ -1,3 +1,5 @@
import * as Cesium from 'cesium';
/** /**
* Lighting options shared by every aircraft 3D model (Flights, Military * Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B). * Flights, Local ADS-B).
@@ -6,12 +8,24 @@
* the model and reads the result back from the GPU (`readPixels`) to light it, * the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when * when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight * the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour * lighting. Without it they are lit from Cesium's default spherical
* (`colorBlendAmount`), so that lighting contributes little; without it they * harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24 * scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms.
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/ */
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({ export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false, enabled: false,
}); });
/**
* How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so
* white draws the mesh's own colours as built, and the tracked or emergency
* colour tints the mesh without hiding its paint. The IR boost draws the
* model flat white instead (MIX at 1, with its unlit shader).
* @param {boolean} irBoost
* @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}}
*/
export function aircraftModelBlend(irBoost) {
return irBoost
? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 }
: { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 };
}
+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 * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. * changing the model's colour blend mode or amount.
* @param {object} input * @param {object} input
* @param {object} input.model * @param {object} input.model
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor * @param {{withAlpha:(alpha:number)=>object}} input.baseColor
+54
View File
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
_setVesselStateForTest({ enabled: false }); _setVesselStateForTest({ enabled: false });
} }
}); });
test('an aid to navigation card shows its kind, never a speed or heading', () => {
const aid = makeRecord({
name: 'SF APPROACH N',
mmsi: '993672001',
type: 'Cardinal Mark N',
speed: 0,
course: 135,
heading: 135,
});
const card = buildVesselCard(aid);
assert.deepEqual(card.details, ['CARDINAL MARK N']);
assert.equal(card.accent, '217, 210, 192');
const selected = buildSelectedVesselCard(aid);
assert.equal(selected.details[0], 'CARDINAL MARK N');
assert.equal(selected.accent, '217, 210, 192');
// Known only by its MMSI, an aid is named as one.
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
});
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, {
type: 'Port hand Mark',
speed: 0,
heading: 135,
course: 135,
});
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
harness.record.virtual = true;
aisLiveVesselsLayer.selectById(harness.record.mmsi);
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
} finally {
harness.cleanup();
}
});
test('a virtual aid to navigation card says it is virtual', () => {
const aid = makeRecord({
mmsi: '993672001',
type: 'Isolated danger',
virtual: true,
});
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
assert.equal(
buildSelectedVesselCard(aid).details[0],
'ISOLATED DANGER · VIRTUAL',
);
});
+94
View File
@@ -316,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
assert.equal(row.length, 12); assert.equal(row.length, 12);
assert.equal(row.beam, 4); assert.equal(row.beam, 4);
}); });
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: {
UserID: 993672001,
Type: 20,
Name: 'SF APPROACH N',
},
},
MetaData: {
MMSI: '993672001',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('993672001');
assert.equal(row.type, 'Cardinal Mark N');
assert.equal(row.name, 'SF APPROACH N');
assert.equal(row.speed, null);
});
test("an aid's name runs on into its name extension", () => {
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: {
UserID: 993672002,
Type: 13,
Name: 'SAN FRANCISCO MAIN S',
NameExtension: 'HIP CHANNEL 2',
},
},
MetaData: {
MMSI: '993672002',
ShipName: 'SAN FRANCISCO MAIN S',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
});
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
// The aid reports last, so it is the newest row.
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
ingestAisStreamEnvelope({
MessageType: 'AidsToNavigationReport',
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
MetaData: {
MMSI: '993672003',
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rows = aisStreamRows(50000);
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
const vesselCount =
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
assert.ok(
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
);
});
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
const aidReport = (mmsi, VirtualAtoN) => ({
MessageType: 'AidsToNavigationReport',
Message: {
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
},
MetaData: {
MMSI: mmsi,
latitude: 37.79,
longitude: -122.52,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
ingestAisStreamEnvelope(aidReport('993672004', true));
ingestAisStreamEnvelope(aidReport('993672005', false));
assert.equal(rowFor('993672004').virtual, true);
assert.equal('virtual' in rowFor('993672005'), false);
// A later real report of the same aid drops the flag.
ingestAisStreamEnvelope(aidReport('993672004', false));
assert.equal('virtual' in rowFor('993672004'), false);
});
+11 -139
View File
@@ -51,7 +51,6 @@ import {
} from './detectionPolicy.js'; } from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js'; import { detectionBracketOpacity } from './detectionPresentation.js';
import { SurfaceOcclusion } from './detectionOcclusion.js'; import { SurfaceOcclusion } from './detectionOcclusion.js';
import { createDetectionPaintCache } from './detectionPaintCache.js';
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js'; import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
import { import {
createCyberSonarSampler, createCyberSonarSampler,
@@ -244,15 +243,6 @@ let _hostLane = null;
let _calloutLane = null; let _calloutLane = null;
let _transitBracketPaths = new Map(); let _transitBracketPaths = new Map();
let _transitBracketAlpha = 1; let _transitBracketAlpha = 1;
/**
* What the last full sweep of the objects built, kept for the paints that
* follow it with the same view and the same objects. See `_drawOverlay`.
*/
const _paintCache = createDetectionPaintCache();
/** Scratch for `_paintViewOf`; the cache copies it when it keeps a paint. */
const _paintView = [];
/** Paints since `initDetection` that reused a kept sweep (diagnostics). */
let _paintReuseCount = 0;
/** /**
* Callouts solved by the sensor lane this frame, replayed by the callout lane. * Callouts solved by the sensor lane this frame, replayed by the callout lane.
* The array and its rows are POOLED — `_calloutCount` is the live length — so a * The array and its rows are POOLED — `_calloutCount` is the live length — so a
@@ -389,8 +379,6 @@ export function destroyDetection() {
_calloutLane?.unregister?.(); _calloutLane?.unregister?.();
_calloutLane = null; _calloutLane = null;
_transitBracketPaths.clear(); _transitBracketPaths.clear();
_paintCache.clear();
_paintReuseCount = 0;
_calloutCount = 0; _calloutCount = 0;
_viewer = null; _viewer = null;
_layers = []; _layers = [];
@@ -655,7 +643,6 @@ function _publishDiagnostics() {
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0); dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0); dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0); dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0);
} }
/** /**
@@ -731,7 +718,6 @@ function _applyModeState() {
_syncSurfaceVisibility(); _syncSurfaceVisibility();
_setLanesActive(false); _setLanesActive(false);
_labelArbiter.clear(); _labelArbiter.clear();
_paintCache.clear();
_surfaceOcclusion.clear(); _surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear(); _surfaceSuppressedKeys.clear();
_occlusionProbe.reset(); _occlusionProbe.reset();
@@ -1273,53 +1259,15 @@ function _materializeCandidate(
}; };
} }
/**
* Every value of the view that the object sweep in `_drawOverlay` and the
* callout candidates it builds read, in a fixed order, for
* `detectionPaintCache`. A value the sweep starts to read belongs here.
*/
function _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) {
const view = _paintView;
const { keyhole, cameraPosition, viewProjectionMatrix, uiRects } = frame;
let n = 0;
view[n++] = _mode;
view[n++] = _theme;
view[n++] = _charWidth;
view[n++] = frame.width;
view[n++] = frame.height;
view[n++] = keyholeOutsideOpacity;
view[n++] = cyberMapActive;
view[n++] = keyhole?.centerX;
view[n++] = keyhole?.centerY;
view[n++] = keyhole?.radius;
view[n++] = keyhole?.featherPx;
view[n++] = cameraPosition?.x;
view[n++] = cameraPosition?.y;
view[n++] = cameraPosition?.z;
for (let i = 0; i < 16; i++) view[n++] = viewProjectionMatrix[i];
const rectCount = Array.isArray(uiRects) ? uiRects.length : -1;
view[n++] = rectCount;
for (let i = 0; i < rectCount; i++) {
const rect = uiRects[i];
view[n++] = rect?.x;
view[n++] = rect?.y;
view[n++] = rect?.w;
view[n++] = rect?.h;
}
view.length = n;
return view;
}
/** /**
* Paint detection against the shared host frame. Brackets remain broad and * Paint detection against the shared host frame. Brackets remain broad and
* per-frame; rich placements rebuild only for selected/fading identities and * per-frame; rich placements rebuild only for selected/fading identities and
* for a bounded cohort on solve ticks. * for a bounded cohort on solve ticks.
*/ */
function _drawOverlay(frame) { function _drawOverlay(frame) {
_transitBracketPaths.clear();
const { width, height } = frame; const { width, height } = frame;
if (!_ctx || width <= 0 || height <= 0) { if (!_ctx || width <= 0 || height <= 0) {
_transitBracketPaths.clear();
_paintCache.forget();
return { return {
didSolve: false, didSolve: false,
solveMs: 0, solveMs: 0,
@@ -1361,8 +1309,6 @@ function _drawOverlay(frame) {
// callout lane repaints the last solve on frames the sensor lane skips, // callout lane repaints the last solve on frames the sensor lane skips,
// leaving rows here would strand the final callsigns on screen after the // leaving rows here would strand the final callsigns on screen after the
// last data layer is switched off. // last data layer is switched off.
_transitBracketPaths.clear();
_paintCache.clear();
_calloutCount = 0; _calloutCount = 0;
_ctx.font = FONT; _ctx.font = FONT;
_drawScanlines(width, height, now); _drawScanlines(width, height, now);
@@ -1415,9 +1361,6 @@ function _drawOverlay(frame) {
// every host lane. Detection retains the manual scalar projection below. // every host lane. Detection retains the manual scalar projection below.
const occluder = frame.occluder; const occluder = frame.occluder;
const camPos = frame.cameraPosition; const camPos = frame.cameraPosition;
const camX = camPos.x;
const camY = camPos.y;
const camZ = camPos.z;
// Answers to the occlusion tests started on earlier frames land here. // Answers to the occlusion tests started on earlier frames land here.
_surfaceOcclusion.beginFrame(now); _surfaceOcclusion.beginFrame(now);
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now); _occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
@@ -1447,7 +1390,7 @@ function _drawOverlay(frame) {
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6; if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
const tiers = _theme.tiers || null; const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line; const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
let bracketPaths = new Map(); const bracketPaths = new Map();
// Global Context sonar echoes: a second bracket, banded by strength, around // Global Context sonar echoes: a second bracket, banded by strength, around
// each flight or vessel a pulse front has just crossed. // each flight or vessel a pulse front has just crossed.
// Resting pulses light nothing, so the per-contact echo work is skipped. // Resting pulses light nothing, so the per-contact echo work is skipped.
@@ -1472,41 +1415,14 @@ function _drawOverlay(frame) {
}; };
let visibleCount = 0; let visibleCount = 0;
let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 }; const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
let aircraftBracketSectors = { left: 0, front: 0, right: 0 }; const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
let protectedVisibleCount = 0; let protectedVisibleCount = 0;
let candidateMap = new Map(); const candidateMap = new Map();
const cohortBuilders = shouldSolve ? new Map() : null; const cohortBuilders = shouldSolve ? new Map() : null;
const demandByLayer = shouldSolve ? new Map() : null; const demandByLayer = shouldSolve ? new Map() : null;
let placementBuildCount = 0; let placementBuildCount = 0;
for (let i = 0; i < objects.length; i++) {
// The sweep below is the cost of this paint: every object projected, faded
// and given its bracket. What it builds depends only on the view and on the
// objects, so when both are exactly what the last sweep had, that sweep's
// paths, counts and callout candidates are used again and only the strokes
// and the callouts are repainted. Three things keep a paint from being
// reused or kept:
// - the Cyber sonar sweep and Global Context echoes change brackets with the
// clock alone;
// - a solve needs the sweep to gather its cohort;
// - a depth-tested contact gets its answer from the scene, not from here.
// A kept solve paint holds the solve's own candidates too, so the callouts a
// solve has just chosen are placed from the same map either way.
const paintKeepable = !sonarActive && !echoField;
const paintView = paintKeepable
? _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive)
: null;
const kept =
paintKeepable && !shouldSolve
? _paintCache.reuse(paintView, objects)
: null;
let depthTestedSwept = false;
let identityLayerId;
let renderSources;
let selectedSources;
if (!kept) _transitBracketPaths.clear();
const sweepCount = kept ? 0 : objects.length;
for (let i = 0; i < sweepCount; i++) {
const obj = objects[i]; const obj = objects[i];
if (!obj.position) continue; if (!obj.position) continue;
// Skip objects occluded by the ellipsoid (behind the horizon) // Skip objects occluded by the ellipsoid (behind the horizon)
@@ -1534,12 +1450,8 @@ function _drawOverlay(frame) {
let halfW; let halfW;
let halfH; let halfH;
if (obj.type === 'AIR') { if (obj.type === 'AIR') {
// Cartesian3.distance, without its two argument checks per object.
const dx = camX - px;
const dy = camY - py;
const dz = camZ - pz;
const bscale = nearFarScale( const bscale = nearFarScale(
Math.sqrt(dx * dx + dy * dy + dz * dz), Cesium.Cartesian3.distance(camPos, obj.position),
BILL_NEAR, BILL_NEAR,
BILL_NEAR_SCALE, BILL_NEAR_SCALE,
BILL_FAR, BILL_FAR,
@@ -1565,7 +1477,6 @@ function _drawOverlay(frame) {
// Something the scene drew in front of a depth-tested contact hides its // Something the scene drew in front of a depth-tested contact hides its
// bracket and callout too, on every frame, not only on solves. // bracket and callout too, on every frame, not only on solves.
if (obj.depthTested === true) { if (obj.depthTested === true) {
depthTestedSwept = true;
const testedLayerId = obj._layerId || 'unknown'; const testedLayerId = obj._layerId || 'unknown';
const testedSourceId = obj.sourceId ?? obj.id ?? i; const testedSourceId = obj.sourceId ?? obj.id ?? i;
const hidden = _surfaceOcclusion.isHidden( const hidden = _surfaceOcclusion.isHidden(
@@ -1646,29 +1557,14 @@ function _drawOverlay(frame) {
if (!primary && !micro) continue; if (!primary && !micro) continue;
const layerId = obj._layerId || 'unknown'; const layerId = obj._layerId || 'unknown';
const sourceId = obj.sourceId ?? obj.id ?? i; const sourceId = obj.sourceId ?? obj.id ?? i;
// Objects arrive a layer at a time, so the layer's sets are looked up once const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false;
// per run of objects, not once per object.
if (layerId !== identityLayerId) {
identityLayerId = layerId;
renderSources = renderIdentities.get(layerId);
selectedSources = selectedIdentities?.get(layerId);
}
const liveForRender = renderSources?.has(sourceId) || false;
if (!shouldSolve && !liveForRender) continue; if (!shouldSolve && !liveForRender) continue;
obj._cohortSourceId = sourceId; obj._cohortSourceId = sourceId;
obj._cohortPriority = _semanticPriority(obj); obj._cohortPriority = _semanticPriority(obj);
obj._cohortBand = obj._cohortBand =
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8); admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
// The hash is of the identity alone, so it is worked out once per identity. obj._cohortHash = stableIdentityHash(layerId, sourceId);
if (
obj._cohortHashLayerId !== layerId ||
obj._cohortHashSourceId !== sourceId
) {
obj._cohortHash = stableIdentityHash(layerId, sourceId);
obj._cohortHashLayerId = layerId;
obj._cohortHashSourceId = sourceId;
}
obj._candidateScreenX = sx; obj._candidateScreenX = sx;
obj._candidateScreenY = sy; obj._candidateScreenY = sy;
obj._candidateHalfW = halfW; obj._candidateHalfW = halfW;
@@ -1682,7 +1578,8 @@ function _drawOverlay(frame) {
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1); demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
if (!cohortBuilders.has(layerId)) if (!cohortBuilders.has(layerId))
cohortBuilders.set(layerId, new BoundedCohort(256)); cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent = selectedSources?.has(sourceId) || false; const incumbent =
selectedIdentities?.get(layerId)?.has(sourceId) || false;
cohortBuilders.get(layerId).consider(obj, incumbent); cohortBuilders.get(layerId).consider(obj, incumbent);
} }
@@ -1702,29 +1599,6 @@ function _drawOverlay(frame) {
} }
// Start this frame's occlusion tests; nothing here waits for them. // Start this frame's occlusion tests; nothing here waits for them.
_occlusionProbe.endFrame(); _occlusionProbe.endFrame();
if (kept) {
_paintReuseCount++;
({
bracketPaths,
visibleCount,
bracketOpacityCounts,
aircraftBracketSectors,
protectedVisibleCount,
candidateMap,
} = kept);
} else if (paintKeepable && !depthTestedSwept) {
// `candidateMap` is kept by reference: a solve below adds to it.
_paintCache.keep(paintView, objects, {
bracketPaths,
visibleCount,
bracketOpacityCounts,
aircraftBracketSectors,
protectedVisibleCount,
candidateMap,
});
} else {
_paintCache.forget();
}
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9; const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct); const collectiveBudget = labelBudgetFor(altitude, _densityPct);
@@ -1908,7 +1782,6 @@ function _drawOverlay(frame) {
// drawing them cost it. // drawing them cost it.
surfaceProbeTests: _occlusionProbe.lastFrameTests, surfaceProbeTests: _occlusionProbe.lastFrameTests,
surfaceProbeMs: _occlusionProbe.lastFrameMs, surfaceProbeMs: _occlusionProbe.lastFrameMs,
paintReuseCount: _paintReuseCount,
}; };
} else { } else {
const selectedCount = _labelArbiter.selectedKeys.size; const selectedCount = _labelArbiter.selectedKeys.size;
@@ -1922,7 +1795,6 @@ function _drawOverlay(frame) {
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors; _lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity; _lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount; _lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
_lastDiagnostics.paintReuseCount = _paintReuseCount;
} }
_drawScanlines(width, height, now); _drawScanlines(width, height, now);
-231
View File
@@ -1661,234 +1661,3 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
env.cleanup(); env.cleanup();
} }
}); });
// --- Reusing a paint -------------------------------------------------------
/**
* Contacts as the fleet layers return them: the same objects on every paint,
* changed in place. `fresh` returns equal copies instead, which no paint can
* reuse, so the two can be compared.
*/
function steadyContacts({ fresh = false } = {}) {
const objects = [
{ position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), sourceId: 'air-1', id: 'AIRONE', metric: 'FL350', type: 'AIR' },
{ position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752), sourceId: 'sat-1', id: 'SATONE', metric: '412KM', type: 'SAT' },
{ position: new Cesium.Cartesian3(0.1, 0.5, 6_356_752), sourceId: 'sea-1', id: 'SEAONE', metric: '12 kn', type: 'SEA' },
{ position: new Cesium.Cartesian3(-0.2, -0.5, 6_356_752), sourceId: 'mil-1', id: 'MILONE', metric: 'FL410', type: 'AIR', tier: 'military' },
];
let calls = 0;
return {
objects,
get calls() { return calls; },
layer: {
id: 'flights',
getDetectableObjects() {
calls += 1;
return fresh
? objects.map((object) => ({ ...object, position: Cesium.Cartesian3.clone(object.position) }))
: objects;
},
},
};
}
/** Everything painted since the trace was last emptied, with each path as its points. */
function takePainted(env) {
const painted = env.paintTrace.map((call) =>
call.map((value) => (value instanceof MockPath2D ? value.points.map((point) => [...point]) : value)));
env.paintTrace.length = 0;
return painted;
}
const bracketPointCount = (env) => env.detectionCtx.calls
.filter(([name]) => name === 'stroke')
.reduce((sum, [, path]) => sum + (path?.points?.length ?? 0), 0);
test('a paint with the same view and the same contacts reuses the last sweep, and paints exactly what a full sweep paints', () => {
// The same frames twice: once with contacts a paint can reuse, once with
// equal copies it cannot. Every canvas call must match, frame for frame.
const frames = (fresh) => {
const env = installEnvironment();
const contacts = steadyContacts({ fresh });
const painted = [];
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
painted.push(takePainted(env));
for (let frame = 1; frame <= 40; frame++) {
// Through the run: a contact moves, one is relabelled, one changes
// tier, and the camera turns.
if (frame === 9) contacts.objects[0].position.x += 0.05;
if (frame === 17) contacts.objects[1].metric = '398KM';
if (frame === 23) contacts.objects[2].tier = 'military';
if (frame === 31) env.viewer.camera.viewMatrix[12] = 0.03;
nextFrame(env);
painted.push(takePainted(env));
}
return { painted, reused: getDetectionDiagnostics().paintReuseCount };
} finally {
env.cleanup();
}
};
const reusing = frames(false);
const sweeping = frames(true);
assert.equal(sweeping.reused, 0, 'copies are swept on every paint');
// 40 frames 16 ms apart: a solve about every eighth, four changes, and the
// rest reuse the sweep before them.
assert.ok(reusing.reused >= 28, `${reusing.reused} of 40 paints reused a sweep`);
assert.equal(reusing.painted.length, sweeping.painted.length);
reusing.painted.forEach((frame, index) => {
assert.ok(frame.some(([target, name]) => target === 'detection' && name === 'stroke'), `frame ${index} strokes brackets`);
assert.deepEqual(frame, sweeping.painted[index], `frame ${index}`);
});
assert.ok(
reusing.painted.some((frame) => frame.some(([, name, text]) => name === 'fillText' && text === 'AIRONE')),
'callouts were painted during the run',
);
});
test('a reused paint builds no bracket paths and still strokes the kept ones', () => {
const env = installEnvironment();
const contacts = steadyContacts();
const builtPaths = [];
class CountingPath2D extends MockPath2D {
constructor() { super(); builtPaths.push(this); }
}
try {
globalThis.Path2D = CountingPath2D;
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 0);
const built = builtPaths.length;
const points = builtPaths.reduce((sum, path) => sum + path.points.length, 0);
assert.ok(points >= 4 * 12, 'four brackets of twelve points were built');
env.detectionCtx.calls.length = 0;
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
assert.equal(builtPaths.length, built, 'no path was built');
assert.equal(builtPaths.reduce((sum, path) => sum + path.points.length, 0), points, 'no point was added to a kept path');
assert.equal(bracketPointCount(env), 4 * 12, 'the kept brackets are stroked');
assert.equal(getDetectionDiagnostics().visibleCount, 4);
assert.equal(getDetectionDiagnostics().placementBuildCount, 0, 'no placement was built on the reused paint');
} finally {
env.cleanup();
}
});
test('any change to a contact or to the view is swept on the very next paint', () => {
const changes = {
'a contact moves': (contacts) => { contacts.objects[0].position.y -= 0.01; },
'a contact gets a new position object': (contacts) => {
contacts.objects[0].position = new Cesium.Cartesian3(0.3, 0.3, 6_356_752);
},
'a contact is renamed': (contacts) => { contacts.objects[1].id = 'SATTWO'; },
'a contact reading changes': (contacts) => { contacts.objects[0].metric = 'FL360'; },
'a contact becomes the tracked one': (contacts) => { contacts.objects[0].skipLabel = true; },
'a contact changes tier': (contacts) => { contacts.objects[3].tier = undefined; },
'a contact changes type': (contacts) => { contacts.objects[2].type = 'VEH'; },
'a contact joins': (contacts) => {
contacts.objects.push({ position: new Cesium.Cartesian3(0.6, 0.6, 6_356_752), sourceId: 'air-2', id: 'AIRTWO', type: 'AIR' });
},
'a contact leaves': (contacts) => { contacts.objects.pop(); },
'a contact is replaced by an equal one': (contacts) => { contacts.objects[1] = { ...contacts.objects[1] }; },
'two contacts swap places in the list': (contacts) => { contacts.objects.reverse(); },
'the camera turns': (contacts, env) => { env.viewer.camera.viewMatrix[13] = 0.02; },
'the camera moves': (contacts, env) => {
env.viewer.camera.positionWC = new Cesium.Cartesian3(0, 100, 10_000_000);
},
'the style changes': () => { setDetectionStyle('thermal'); },
'the profile changes': () => { setMode('SPARSE'); },
};
for (const [name, change] of Object.entries(changes)) {
const env = installEnvironment();
const contacts = steadyContacts();
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setDetectionStyle('normal');
setMode('DENSE');
settleFrame(env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: steady paints reuse`);
change(contacts, env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: swept again`);
assert.equal(getDetectionDiagnostics().observationCount, contacts.objects.length, name);
} finally {
env.cleanup();
}
}
});
test('a depth-tested contact, the sonar sweep and echoes are swept on every paint', () => {
const run = ({ depthTested = false, sonar = false, echo = false } = {}) => {
const env = installEnvironment();
const contacts = steadyContacts();
if (depthTested) {
contacts.objects[0].type = 'VEH';
contacts.objects[0].depthTested = true;
}
if (sonar) {
env.document.documentElement = {
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (className) => className === 'active' };
env.document.body.appendChild(hud);
}
try {
if (echo)
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo: { layerIds: ['flights'], fadeMs: 60_000, color: 'rgb(1, 2, 3)', spreadPx: 8, lineWidth: 3.25 },
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
const sweeps = contacts.calls;
for (let i = 0; i < 30; i++) nextFrame(env);
assert.ok(contacts.calls > sweeps, 'frames painted');
return getDetectionDiagnostics().paintReuseCount;
} finally {
clearAwarenessEchoField();
env.cleanup();
}
};
assert.ok(run() > 15, 'the same contacts with none of these are reused');
assert.equal(run({ depthTested: true }), 0, 'depth-tested');
assert.equal(run({ sonar: true }), 0, 'sonar');
assert.equal(run({ echo: true }), 0, 'echo');
});
test('turning detection off lets go of the kept sweep', () => {
const env = installEnvironment();
const contacts = steadyContacts();
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contacts.layer], () => {});
setMode('DENSE');
settleFrame(env);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
setMode('OFF');
setMode('DENSE');
// The first paint back is a solve, and the one after it reuses that.
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
nextFrame(env);
assert.equal(getDetectionDiagnostics().paintReuseCount, 2);
} finally {
env.cleanup();
}
});
-206
View File
@@ -1,206 +0,0 @@
/**
* @module detectionPaintCache
* @description Lets the detection overlay keep what one paint built (bracket
* paths, counts, callout candidates) and use it again on the next paint when
* that paint would build exactly the same thing.
*
* Nothing announces a change to this cache. Each paint hands over the values
* the sweep reads from the view and the objects the layers returned, and the
* cache compares them, value by value, with what the kept paint was built
* from. A layer therefore cannot leave a stale picture behind by forgetting to
* say it changed: an object that moved, appeared, left or was relabelled is
* seen here, and the sweep runs again.
*
* Pure: no Cesium, no DOM.
*/
/**
* The object fields the detection sweep and its callout candidates read.
* Position is compared separately, by coordinate. `sameFields` and
* `recordFields` below name each of these outright, in this order, because a
* field read by a name held in a variable costs several times as much, and
* this runs for every object on every paint.
*/
export const PAINTED_OBJECT_FIELDS = Object.freeze([
'_layerId',
'type',
'tier',
'skipLabel',
'id',
'metric',
'sourceId',
'bracketHalfWidth',
'bracketHalfHeight',
'depthTested',
'cardSourceId',
'cardEntryId',
]);
/**
* After this many sweeps in a row with no reuse between them, the objects are
* only recorded on every RECORD_STRIDE-th sweep. A scene whose objects move on
* every paint can never reuse one, so recording each of them would be work
* with no return; a scene that settles is picked up again within RECORD_STRIDE
* paints.
*/
export const SWEEPS_BEFORE_BACKOFF = 8;
export const RECORD_STRIDE = 8;
const FIELD_COUNT = PAINTED_OBJECT_FIELDS.length;
function sameFields(object, fields, f) {
return (
object._layerId === fields[f] &&
object.type === fields[f + 1] &&
object.tier === fields[f + 2] &&
object.skipLabel === fields[f + 3] &&
object.id === fields[f + 4] &&
object.metric === fields[f + 5] &&
object.sourceId === fields[f + 6] &&
object.bracketHalfWidth === fields[f + 7] &&
object.bracketHalfHeight === fields[f + 8] &&
object.depthTested === fields[f + 9] &&
object.cardSourceId === fields[f + 10] &&
object.cardEntryId === fields[f + 11]
);
}
/** Most fields are what they were at the last sweep; only the others are written. */
function recordFields(object, fields, f) {
let value;
if ((value = object._layerId) !== fields[f]) fields[f] = value;
if ((value = object.type) !== fields[f + 1]) fields[f + 1] = value;
if ((value = object.tier) !== fields[f + 2]) fields[f + 2] = value;
if ((value = object.skipLabel) !== fields[f + 3]) fields[f + 3] = value;
if ((value = object.id) !== fields[f + 4]) fields[f + 4] = value;
if ((value = object.metric) !== fields[f + 5]) fields[f + 5] = value;
if ((value = object.sourceId) !== fields[f + 6]) fields[f + 6] = value;
if ((value = object.bracketHalfWidth) !== fields[f + 7])
fields[f + 7] = value;
if ((value = object.bracketHalfHeight) !== fields[f + 8])
fields[f + 8] = value;
if ((value = object.depthTested) !== fields[f + 9]) fields[f + 9] = value;
if ((value = object.cardSourceId) !== fields[f + 10]) fields[f + 10] = value;
if ((value = object.cardEntryId) !== fields[f + 11]) fields[f + 11] = value;
}
/**
* @returns {{
* reuse: (view: Array, objects: Array<object>) => (object|null),
* keep: (view: Array, objects: Array<object>, built: object) => boolean,
* forget: () => void,
* clear: () => void,
* }}
*/
export function createDetectionPaintCache() {
let built = null;
let viewKept = [];
let count = 0;
let references = [];
let fields = [];
let coordinates = new Float64Array(0);
let positioned = new Uint8Array(0);
let sweepsSinceReuse = 0;
function sameView(view) {
if (view.length !== viewKept.length) return false;
for (let i = 0; i < view.length; i++)
if (view[i] !== viewKept[i]) return false;
return true;
}
function sameObjects(objects) {
if (objects.length !== count) return false;
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
const object = objects[i];
if (object !== references[i]) return false;
const position = object.position;
if (position) {
// A coordinate that is not a number never equals the kept one, so such
// an object is swept again on every paint.
if (
positioned[i] !== 1 ||
position.x !== coordinates[c] ||
position.y !== coordinates[c + 1] ||
position.z !== coordinates[c + 2]
)
return false;
} else if (positioned[i] !== 0) {
return false;
}
if (!sameFields(object, fields, f)) return false;
}
return true;
}
return {
/**
* What the kept paint built, when `view` and `objects` are what it was
* built from; otherwise null.
* @param {Array} view - Every value of the view the sweep reads, in a fixed order.
* @param {Array<object>} objects - The objects the layers returned this paint.
*/
reuse(view, objects) {
if (!built || !sameView(view) || !sameObjects(objects)) return null;
sweepsSinceReuse = 0;
return built;
},
/**
* Keep `result` as the result of sweeping `objects` under `view`.
* @returns {boolean} False when recording was skipped (see RECORD_STRIDE).
*/
keep(view, objects, result) {
sweepsSinceReuse++;
if (
sweepsSinceReuse > SWEEPS_BEFORE_BACKOFF &&
(sweepsSinceReuse - SWEEPS_BEFORE_BACKOFF) % RECORD_STRIDE !== 0
) {
built = null;
return false;
}
viewKept = view.slice();
count = objects.length;
if (positioned.length < count) {
// Room to spare, so a feed that grows by a few contacts a poll does
// not allocate on each of them.
const room = Math.ceil(count * 1.25);
coordinates = new Float64Array(room * 3);
positioned = new Uint8Array(room);
references = new Array(room).fill(undefined);
fields = new Array(room * FIELD_COUNT).fill(undefined);
}
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
const object = objects[i];
if (references[i] !== object) references[i] = object;
const position = object.position;
positioned[i] = position ? 1 : 0;
if (position) {
coordinates[c] = position.x;
coordinates[c + 1] = position.y;
coordinates[c + 2] = position.z;
}
recordFields(object, fields, f);
}
built = result;
return true;
},
/** Reuse nothing until the next `keep`. */
forget() {
built = null;
},
/** Forget the kept paint and let go of its objects. */
clear() {
built = null;
viewKept = [];
count = 0;
references = [];
fields = [];
coordinates = new Float64Array(0);
positioned = new Uint8Array(0);
sweepsSinceReuse = 0;
},
};
}
-197
View File
@@ -1,197 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDetectionPaintCache,
PAINTED_OBJECT_FIELDS,
RECORD_STRIDE,
SWEEPS_BEFORE_BACKOFF,
} from './detectionPaintCache.js';
const contacts = () => [
{
position: { x: 1, y: 2, z: 3 },
_layerId: 'flights',
sourceId: 'a',
id: 'A',
metric: 'FL300',
type: 'AIR',
},
{
position: { x: 4, y: 5, z: 6 },
_layerId: 'vessels',
sourceId: 'b',
id: 'B',
type: 'SEA',
},
{ _layerId: 'cctv', sourceId: 'c', id: 'C', type: 'CAM' },
];
const view = () => ['DENSE', 800, 600, 0.25, 1, 0, 0, 1];
function kept() {
const cache = createDetectionPaintCache();
const objects = contacts();
const built = { paths: 'kept' };
assert.equal(cache.keep(view(), objects, built), true);
return { cache, objects, built };
}
test('the kept sweep is handed back for the same view and the same objects', () => {
const { cache, objects, built } = kept();
assert.equal(cache.reuse(view(), objects), built);
assert.equal(cache.reuse(view(), objects), built, 'and again');
// The same objects in a new list are still the same objects.
assert.equal(cache.reuse(view(), [...objects]), built);
});
test('nothing is handed back before a sweep is kept, or after it is forgotten', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
assert.equal(cache.reuse(view(), objects), null);
cache.keep(view(), objects, {});
cache.forget();
assert.equal(cache.reuse(view(), objects), null);
cache.keep(view(), objects, {});
cache.clear();
assert.equal(cache.reuse(view(), objects), null);
});
test('any value of the view that differs is a miss', () => {
for (let i = 0; i < view().length; i++) {
const { cache, objects } = kept();
const changed = view();
changed[i] = typeof changed[i] === 'number' ? changed[i] + 1e-9 : 'SPARSE';
assert.equal(cache.reuse(changed, objects), null, `view value ${i}`);
}
const { cache, objects } = kept();
assert.equal(cache.reuse([...view(), 0], objects), null, 'one value more');
assert.equal(
cache.reuse(view().slice(0, -1), objects),
null,
'one value fewer',
);
});
test('a view value that is not a number never matches itself', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
cache.keep([NaN], objects, {});
assert.equal(cache.reuse([NaN], objects), null);
});
test('the view is copied when kept, so the caller may write the next one over it', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
const scratch = view();
const built = {};
cache.keep(scratch, objects, built);
scratch[1] = 1024;
assert.equal(cache.reuse(scratch, objects), null);
scratch[1] = 800;
assert.equal(cache.reuse(scratch, objects), built);
});
test('a coordinate changed in place is a miss, however small', () => {
for (const axis of ['x', 'y', 'z']) {
const { cache, objects } = kept();
objects[1].position[axis] += 1e-9;
assert.equal(cache.reuse(view(), objects), null, axis);
}
});
test('a position object swapped for an equal one is still the same paint', () => {
const { cache, objects, built } = kept();
objects[0].position = { x: 1, y: 2, z: 3 };
assert.equal(cache.reuse(view(), objects), built);
});
test('a position that appears, goes away or is not a number is a miss', () => {
let { cache, objects } = kept();
objects[2].position = { x: 0, y: 0, z: 0 };
assert.equal(cache.reuse(view(), objects), null, 'appears');
({ cache, objects } = kept());
objects[0].position = null;
assert.equal(cache.reuse(view(), objects), null, 'goes away');
cache = createDetectionPaintCache();
objects = contacts();
objects[0].position.x = NaN;
cache.keep(view(), objects, {});
assert.equal(cache.reuse(view(), objects), null, 'not a number');
});
test('every field the sweep reads is compared', () => {
for (const field of PAINTED_OBJECT_FIELDS) {
const { cache, objects, built } = kept();
const before = objects[0][field];
objects[0][field] = before === 'changed' ? 'again' : 'changed';
assert.equal(cache.reuse(view(), objects), null, field);
objects[0][field] = before;
assert.equal(cache.reuse(view(), objects), built, `${field} put back`);
}
});
test('a field the sweep does not read may change freely', () => {
const { cache, objects, built } = kept();
objects[0]._candidateScreenX = 412;
objects[0]._cohortHash = 7;
assert.equal(cache.reuse(view(), objects), built);
});
test('a different object, count or order is a miss', () => {
let { cache, objects } = kept();
assert.equal(cache.reuse(view(), contacts()), null, 'equal copies');
({ cache, objects } = kept());
assert.equal(cache.reuse(view(), objects.slice(0, 2)), null, 'one fewer');
assert.equal(cache.reuse(view(), [...objects, objects[0]]), null, 'one more');
({ cache, objects } = kept());
assert.equal(
cache.reuse(view(), [objects[1], objects[0], objects[2]]),
null,
'order',
);
});
test('keeping again replaces what was kept, for more or fewer objects', () => {
const { cache, objects, built } = kept();
const more = [...objects, ...contacts(), ...contacts()];
const builtMore = {};
cache.keep(view(), more, builtMore);
assert.equal(cache.reuse(view(), objects), null);
assert.equal(cache.reuse(view(), more), builtMore);
cache.keep(view(), objects, built);
assert.equal(cache.reuse(view(), more), null);
assert.equal(cache.reuse(view(), objects), built);
});
test('objects that change on every paint are recorded only now and then', () => {
const cache = createDetectionPaintCache();
const objects = contacts();
const recorded = [];
const sweep = () => {
objects[0].position.x += 1;
assert.equal(cache.reuse(view(), objects), null);
recorded.push(cache.keep(view(), objects, {}));
};
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) sweep();
assert.ok(recorded.every(Boolean), 'the first sweeps are all recorded');
recorded.length = 0;
for (let i = 0; i < RECORD_STRIDE * 5; i++) sweep();
assert.equal(recorded.filter(Boolean).length, 5, 'then one in every stride');
// A skipped sweep leaves nothing behind to reuse by mistake.
const built = {};
let wasRecorded = cache.keep(view(), objects, built);
assert.equal(wasRecorded, false);
assert.equal(cache.reuse(view(), objects), null);
// Once the objects hold still, the next recorded sweep is reused, and
// every sweep after a reuse is recorded again.
while (!wasRecorded) wasRecorded = cache.keep(view(), objects, built);
assert.equal(cache.reuse(view(), objects), built);
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++)
assert.equal(cache.keep(view(), objects, built), true);
});
+157 -23
View File
@@ -11,6 +11,9 @@
* vehicle's road or rail top), and is drawn life size: a type's published * vehicle's road or rail top), and is drawn life size: a type's published
* length over the model's built length is its scale. * length over the model's built length is its scale.
* *
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
* the set's buoy of its IALA mark, by the mark its type names.
*
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet` * The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
* fetches the JSON and every GLB once, up front, so no model is fetched * fetches the JSON and every GLB once, up front, so no model is fetched
* mid-session. * mid-session.
@@ -18,6 +21,7 @@
import { classifyAircraft } from './aircraftClass.js'; import { classifyAircraft } from './aircraftClass.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { isAidToNavigation } from './vesselTypes.js';
/** Where the model set is served, relative to the application base. */ /** Where the model set is served, relative to the application base. */
export const FLEET_MODEL_BASE = '/models/fleet/'; export const FLEET_MODEL_BASE = '/models/fleet/';
@@ -85,16 +89,62 @@ const TEXT_SHIP_TYPES = [
]; ];
/** /**
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B * The buoy an aid to navigation is drawn as, by the mark its type names
* emitter category `category`: the type's, else the category's, else * ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
* `default_aircraft`. * that matches. Beacons, lights, RACONs and reference points with no mark of
* their own are the special mark.
*/
const AID_MODELS = [
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
[/isolated danger/i, 'buoy_isolated_danger'],
[/safe water/i, 'buoy_safe_water'],
[/port hand/i, 'buoy_lateral_port'],
[/starboard hand/i, 'buoy_lateral_starboard'],
[/special mark|light vessel|lanby/i, 'buoy_special'],
[
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
'buoy_special',
],
];
/** The buoy an aid to navigation whose type names no mark is drawn as. */
export const DEFAULT_AID_MODEL = 'can_red';
/**
* The buoy model of an aid to navigation's type, else undefined when the
* type names no mark.
* @param {string|null} [type]
* @returns {string|undefined}
*/
export function aidToNavigationModel(type) {
const text = String(type ?? '').trim();
for (const [pattern, model] of AID_MODELS)
if (pattern.test(text)) return model;
return undefined;
}
/**
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
* emitter category `category` and callsign `callsign`: the first of the
* type's callsign rules (`aircraft_callsign`) the callsign matches, else the
* type's, else the category's, else `default_aircraft`. An airport vehicle
* (type ZVEH) is a fire truck, tug or van by its callsign, else a car.
* @param {object} types Parsed `types.json`. * @param {object} types Parsed `types.json`.
* @param {string} [type] * @param {string} [type]
* @param {string} [category] * @param {string} [category]
* @param {string} [callsign]
* @returns {{model: string, length: number|null}} * @returns {{model: string, length: number|null}}
*/ */
export function resolveAircraft(types, type, category) { export function resolveAircraft(types, type, category, callsign) {
const t = type?.trim().toUpperCase(); const t = type?.trim().toUpperCase();
const index = aircraftCallsignRule(types, t, callsign);
if (index >= 0) {
const rule = types.aircraft_callsign[t][index];
return { model: rule.model, length: rule.length };
}
const [model, length] = const [model, length] =
(t && (types.aircraft[t] ?? types.helicopters[t])) || (t && (types.aircraft[t] ?? types.helicopters[t])) ||
(category && types.categories[category]) || (category && types.categories[category]) ||
@@ -102,6 +152,51 @@ export function resolveAircraft(types, type, category) {
return { model, length }; return { model, length };
} }
/**
* A callsign as the rules read it: upper case, spaces removed.
* @param {string} callsign
* @returns {string}
*/
function callsignKey(callsign) {
return String(callsign).toUpperCase().replace(/\s+/g, '');
}
/**
* Which of type `type`'s callsign rules (`types.json`'s
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
* wins) `callsign` matches: its index, -1 when none does or the type has
* none. A callsign matches a rule when, upper-cased with spaces removed, it
* starts with one of the rule's prefixes.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type] ICAO type.
* @param {string|null} [callsign]
* @returns {number}
*/
export function aircraftCallsignRule(types, type, callsign) {
const t = String(type ?? '')
.trim()
.toUpperCase();
const rules = t ? types.aircraft_callsign?.[t] : undefined;
if (!rules || !callsign) return -1;
const c = callsignKey(callsign);
return rules.findIndex((rule) =>
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
);
}
/**
* Whether an aircraft of ICAO type `type` picks its model by callsign.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type]
* @returns {boolean}
*/
export function aircraftModelByCallsign(types, type) {
const t = String(type ?? '')
.trim()
.toUpperCase();
return Boolean(t && types.aircraft_callsign?.[t]);
}
/** /**
* A reported length as one a hull can have, else undefined (drawn as built). * A reported length as one a hull can have, else undefined (drawn as built).
* @param {number} [length] * @param {number} [length]
@@ -133,8 +228,9 @@ export function hullBeam(beam) {
/** /**
* The model for a vessel of AIS ship type `shipType` and the reported length * The model for a vessel of AIS ship type `shipType` and the reported length
* and beam (metres) and name: the first rule that matches in the first type * and beam (metres) and name: the first rule that matches in the first type
* range that has one, else `default_vessel`. Drawn at the reported length, as * range that has one, else `default_vessel`. Drawn at the reported length;
* built when it is unknown. * when it is unknown (most live feeds send none), chosen and drawn at the
* range's `typical_len`, else as built.
* @param {object} types Parsed `types.json`. * @param {object} types Parsed `types.json`.
* @param {number} [shipType] * @param {number} [shipType]
* @param {number} [length] * @param {number} [length]
@@ -145,13 +241,14 @@ export function hullBeam(beam) {
export function resolveVessel(types, shipType, length, beam, name) { export function resolveVessel(types, shipType, length, beam, name) {
const L = hullLength(length) ?? 0; const L = hullLength(length) ?? 0;
const B = hullBeam(beam) ?? 0; const B = hullBeam(beam) ?? 0;
const drawn = L > 0 ? L : null;
if (shipType !== undefined && shipType !== null) { if (shipType !== undefined && shipType !== null) {
for (const group of types.vessels) { for (const group of types.vessels) {
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue; if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
const len = L > 0 ? L : (group.typical_len ?? 0);
const drawn = len > 0 ? len : null;
for (const rule of group.rules) { for (const rule of group.rules) {
if (rule.min_len && L < rule.min_len) continue; if (rule.min_len && len < rule.min_len) continue;
if (rule.max_len && L > rule.max_len) continue; if (rule.max_len && len > rule.max_len) continue;
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? '')) if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
continue; continue;
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue; if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
@@ -159,7 +256,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
} }
} }
} }
return { model: types.default_vessel, length: drawn }; return { model: types.default_vessel, length: L > 0 ? L : null };
} }
/** /**
@@ -304,24 +401,32 @@ function specOf(set, resolved, fallback, options) {
} }
/** /**
* The model an aircraft is drawn with. The type's entry first; then, for a * The model an aircraft is drawn with. The type's callsign rules (an airport
* vehicle by its callsign), then the type's entry; then, for a
* type the map lacks, the model of its class (a fast jet, a helicopter, a * type the map lacks, the model of its class (a fast jet, a helicopter, a
* glider) when the type names one; then the emitter category's; then * glider) when the type names one; then the emitter category's; then
* `default_aircraft`. * `default_aircraft`.
* @param {{catalog: object, types: object}} set * @param {{catalog: object, types: object}} set
* @param {{typeCode?: string|null, type?: string|null, * @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}} aircraft * category?: string|number|null, callsign?: string|null}} aircraft
* @returns {object} {@link fleetModelSpec} * @returns {object} {@link fleetModelSpec}
*/ */
export function aircraftModelSpec(set, { typeCode, type, category } = {}) { export function aircraftModelSpec(
set,
{ typeCode, type, category, callsign } = {},
) {
const { types, catalog } = set; const { types, catalog } = set;
const code = String(typeCode ?? type ?? '') const code = String(typeCode ?? type ?? '')
.trim() .trim()
.toUpperCase(); .toUpperCase();
const emitter = emitterCategory(category); const emitter = emitterCategory(category);
let resolved = resolveAircraft(types, code, emitter); const sign = callsign ? String(callsign) : undefined;
let resolved = resolveAircraft(types, code, emitter, sign);
// A callsign rule names the model outright; a type's class only stands in
// for a type the map lacks.
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
const typed = code && (types.aircraft[code] || types.helicopters[code]); const typed = code && (types.aircraft[code] || types.helicopters[code]);
if (!typed && code) { if (!ruled && !typed && code) {
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })]; const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
if (model && catalog[model]) if (model && catalog[model])
resolved = { model, length: catalog[model].length }; resolved = { model, length: catalog[model].length };
@@ -334,25 +439,49 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
/** /**
* The model a vessel is drawn with, from its AIS ship type (a code or a text * The model a vessel is drawn with, from its AIS ship type (a code or a text
* family), reported length and beam (metres) and name. * family), reported length and beam (metres) and name. An aid to navigation
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
* light, or its MMSI is an aid's) is the buoy of its mark
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
* @param {{catalog: object, types: object}} set * @param {{catalog: object, types: object}} set
* @param {{type?: string|number|null, length?: number|null, * @param {{type?: string|number|null, mmsi?: string|number|null,
* beam?: number|null, name?: string|null}} vessel * virtual?: boolean, length?: number|null, beam?: number|null,
* @returns {object} {@link fleetModelSpec} * name?: string|null}} vessel
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
* null for a virtual aid
*/ */
export function vesselModelSpec(set, { type, length, beam, name } = {}) { export function vesselModelSpec(
set,
{ type, mmsi, virtual, length, beam, name } = {},
) {
if (virtual === true) return null;
if (isAidToNavigation({ type, mmsi })) {
const model = aidToNavigationModel(type);
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
if (set.catalog[known])
return Object.freeze({
...fleetModelSpec(set, known, null),
fixed: true,
});
}
// A beam counts only beside a reported length: a vessel drawn at its type's
// typical length keeps the model's proportions.
const reported = hullLength(length ?? undefined);
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
const resolved = resolveVessel( const resolved = resolveVessel(
set.types, set.types,
aisShipTypeCode(type), aisShipTypeCode(type),
length ?? undefined, reported,
beam ?? undefined, hullWidth,
name ?? undefined, name ?? undefined,
); );
return specOf( return specOf(
set, set,
resolved, resolved,
(r) => ({ model: set.types.default_vessel, length: r.length }), (r) => ({ model: set.types.default_vessel, length: r.length }),
{ beam: beam ?? undefined }, { beam: hullWidth },
); );
} }
@@ -381,6 +510,9 @@ export function namedFleetModels(types) {
...pairs(types.aircraft), ...pairs(types.aircraft),
...pairs(types.helicopters), ...pairs(types.helicopters),
...pairs(types.categories), ...pairs(types.categories),
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
rules.map((rule) => rule.model),
),
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)), ...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
types.default_aircraft[0], types.default_aircraft[0],
types.default_vessel, types.default_vessel,
@@ -390,6 +522,8 @@ export function namedFleetModels(types) {
transit.default[0], transit.default[0],
...Object.keys(transit.elevation), ...Object.keys(transit.elevation),
...Object.values(CLASS_MODELS), ...Object.values(CLASS_MODELS),
...AID_MODELS.map(([, model]) => model),
DEFAULT_AID_MODEL,
]); ]);
} }
+169
View File
@@ -4,7 +4,9 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
DEFAULT_AID_MODEL,
FLEET_MODEL_BASE, FLEET_MODEL_BASE,
aidToNavigationModel,
aircraftModelSpec, aircraftModelSpec,
aisShipTypeCode, aisShipTypeCode,
emitterCategory, emitterCategory,
@@ -51,12 +53,22 @@ const sliceTypes = {
A21N: ['a320', 44.51], A21N: ['a320', 44.51],
A319: ['a320', 33.84], A319: ['a320', 33.84],
B38M: ['b738', 39.52], B38M: ['b738', 39.52],
ZVEH: ['car', 4.61],
GRND: ['radio_mast', 1.9],
}, },
helicopters: { EC35: ['ec35', 10.2] }, helicopters: { EC35: ['ec35', 10.2] },
aircraft_callsign: {
ZVEH: [
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
],
},
categories: { A1: ['c172', 8.3] }, categories: { A1: ['c172', 8.3] },
vessels: [ vessels: [
{ {
types: [70, 79], types: [70, 79],
typical_len: 300,
rules: [ rules: [
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' }, { min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
{ {
@@ -111,6 +123,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
}); });
}); });
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
const zveh = (callsign) =>
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
model: 'fire_truck',
length: 11.01,
});
assert.equal(zveh('PUSH 14').model, 'pushback');
assert.equal(zveh('TUG3').model, 'pushback');
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
assert.equal(zveh('SQUID20').model, 'van');
assert.equal(zveh('SQB9').model, 'van');
assert.equal(zveh('S Q B 9').model, 'van');
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
assert.equal(
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
'a320',
);
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
model: 'radio_mast',
length: 1.9,
});
});
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => { test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship'); assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
assert.equal( assert.equal(
@@ -133,6 +172,29 @@ test('vessels: the first matching rule of the first type range with one, else th
}); });
}); });
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
assert.deepEqual(resolveVessel(sliceTypes, 70), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
model: 'general_cargo',
length: 90,
});
assert.deepEqual(resolveVessel(sliceTypes, 31), {
model: 'tug',
length: null,
});
// Every passenger, cargo and tanker range of the set has one.
for (const code of [60, 70, 80]) {
assert.ok(resolveVessel(types, code).length > 100, String(code));
}
});
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => { test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), { assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
model: 'tug', model: 'tug',
@@ -201,6 +263,46 @@ test('transit bodies mark the share of the route colour they take; other models
} }
}); });
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
const zveh = (callsign) =>
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
.model;
assert.equal(zveh('FIRE 2'), 'fire_truck');
assert.equal(zveh('PUSHBACK1'), 'pushback');
assert.equal(zveh('TUG 4'), 'pushback');
assert.equal(zveh('DEICER 4'), 'van');
assert.equal(zveh('SQUID20'), 'van');
assert.equal(zveh('SQB9'), 'van');
assert.equal(zveh('LEADER 7'), 'car');
assert.equal(zveh('AOPS'), 'car');
assert.equal(zveh(null), 'car');
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
assert.equal(mast.model, 'radio_mast');
assert.equal(mast.scale, 1);
});
test('a callsign rule outranks the class model of a type the map lacks', () => {
const ruled = {
...set,
types: {
...set.types,
aircraft_callsign: {
...set.types.aircraft_callsign,
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
},
},
};
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
'fire_truck',
);
// No rule matched: the class model, as before.
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
'generic_heli',
);
});
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => { test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320'); assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744'); assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
@@ -267,6 +369,12 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
const [sx, sy, sz] = box.scaleXYZ; const [sx, sy, sz] = box.scaleXYZ;
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12); assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12); assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
// A beam with no reported length: the type's typical length, in the model's
// own proportions, not a typical-length hull squeezed to that beam.
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
assert.equal(unsized.model, 'container_ship');
const [ux, uy, uz] = unsized.scaleXYZ;
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug'); assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat'); assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
// No type, no size: the generic vessel as built. // No type, no size: the generic vessel as built.
@@ -275,6 +383,67 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
assert.equal(unknown.scale, 1); assert.equal(unknown.scale, 1);
}); });
test('aids to navigation are the buoy of the mark their type names', () => {
const cases = [
['Cardinal Mark N', 'buoy_cardinal_north'],
['Beacon, Cardinal E', 'buoy_cardinal_east'],
['Cardinal Mark S', 'buoy_cardinal_south'],
['Beacon, Cardinal W', 'buoy_cardinal_west'],
['Port Hand Mark', 'buoy_lateral_port'],
['Beacon, Port hand', 'buoy_lateral_port'],
['Starboard Hand Mark', 'buoy_lateral_starboard'],
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
['Safe Water', 'buoy_safe_water'],
['Isolated danger', 'buoy_isolated_danger'],
['Beacon, Special Mark', 'buoy_special'],
['Special Mark', 'buoy_special'],
['Light Vessel / LANBY / Rigs', 'buoy_special'],
['RACON', 'buoy_special'],
['Light, with Sectors', 'buoy_special'],
['Reference point', 'buoy_special'],
['Navigation aid', 'buoy_special'],
];
for (const [type, model] of cases) {
assert.equal(aidToNavigationModel(type), model, type);
assert.ok(catalog[model], model);
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
assert.equal(spec.model, model, type);
assert.equal(spec.category, 'buoys', type);
assert.equal(spec.fixed, true, type);
// A buoy is drawn as built, whatever length the report gives.
assert.equal(spec.scale, 1, type);
}
assert.equal(
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
1,
);
// An aid of no stated kind, known only by its MMSI, is the default can.
assert.ok(catalog[DEFAULT_AID_MODEL]);
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
assert.equal(unnamed.fixed, true);
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
// A virtual aid is broadcast where nothing stands: it keeps its disc.
assert.equal(
vesselModelSpec(set, {
type: 'Cardinal Mark N',
mmsi: '993672001',
virtual: true,
}),
null,
);
// Vessels, including those that work on buoys, keep their hulls.
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
{ type: '70', mmsi: '366999002' },
{ type: 'Tug', mmsi: '' },
]) {
const spec = vesselModelSpec(set, vessel);
assert.notEqual(spec.category, 'buoys', vessel.type);
assert.equal(spec.fixed, undefined, vessel.type);
}
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => { test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity', model: 'intercity',
+5 -4
View File
@@ -32,7 +32,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
c172: Object.freeze([4.0507, 0.4481, 0]), c172: Object.freeze([4.0507, 0.4481, 0]),
c30j: Object.freeze([13.568, 0.9917, 0]), c30j: Object.freeze([13.568, 0.9917, 0]),
c56x: Object.freeze([7.4627, 0.4526, 0]), c56x: Object.freeze([7.4627, 0.4526, 0]),
car: Object.freeze([2.3, 0.418, 0]), car: Object.freeze([2.1851, 0.301, 0]),
cl35: Object.freeze([10.4607, 0.8247, 0]), cl35: Object.freeze([10.4607, 0.8247, 0]),
crj9: Object.freeze([17.8413, 0.8624, 0]), crj9: Object.freeze([17.8413, 0.8624, 0]),
dh8d: Object.freeze([15.7902, 0.7501, 0]), dh8d: Object.freeze([15.7902, 0.7501, 0]),
@@ -40,7 +40,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
e55p: Object.freeze([7.6566, 0.503, 0]), e55p: Object.freeze([7.6566, 0.503, 0]),
ec35: Object.freeze([5.1003, 0.1471, 0]), ec35: Object.freeze([5.1003, 0.1471, 0]),
ec45: Object.freeze([5.8004, 0.555, 0]), ec45: Object.freeze([5.8004, 0.555, 0]),
fire_truck: Object.freeze([5.5, 0.6051, 0]), fire_truck: Object.freeze([5.39, 0.4227, 0]),
generic_heli: Object.freeze([5.4997, 0.1604, 0]), generic_heli: Object.freeze([5.4997, 0.1604, 0]),
generic_plane: Object.freeze([16.5228, 0.712, 0]), generic_plane: Object.freeze([16.5228, 0.712, 0]),
glf4: Object.freeze([13.4603, 0.8243, 0]), glf4: Object.freeze([13.4603, 0.8243, 0]),
@@ -53,8 +53,9 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
mq9: Object.freeze([5.8498, -0.062, 0]), mq9: Object.freeze([5.8498, -0.062, 0]),
p28a: Object.freeze([3.3695, 0.3959, 0]), p28a: Object.freeze([3.3695, 0.3959, 0]),
pc12: Object.freeze([6.6416, 0.5288, 0]), pc12: Object.freeze([6.6416, 0.5288, 0]),
pushback: Object.freeze([3, 0.2499, 0]), pushback: Object.freeze([2.9536, 0.388, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]), r44: Object.freeze([4.4219, -0.3001, 0]),
radio_mast: Object.freeze([1.2, 0, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]), s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]), sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]), t38: Object.freeze([7.0246, -0.3791, 0]),
@@ -62,5 +63,5 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
tower: Object.freeze([1.2952, -0.0007, 0]), tower: Object.freeze([1.2952, -0.0007, 0]),
ultralight: Object.freeze([3.2271, 0.3259, 0]), ultralight: Object.freeze([3.2271, 0.3259, 0]),
v22: Object.freeze([7.9868, 0.2956, 0]), v22: Object.freeze([7.9868, 0.2956, 0]),
van: Object.freeze([2.75, 0.418, 0]), van: Object.freeze([2.695, 0.2812, 0]),
}); });
+1
View File
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'infrastructureOverlayEntry.js', 'infrastructureOverlayEntry.js',
'localGeojsonCore.js', 'localGeojsonCore.js',
'localGeojsonLod.js', 'localGeojsonLod.js',
'terrainSurface.js',
]); ]);
assert.deepEqual( assert.deepEqual(
entry.imports, entry.imports,
+185 -5
View File
@@ -23,9 +23,10 @@ import {
} from '../renderGovernor.js'; } from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */ /** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) => const createLocalGeoJsonLayer = (options, services = {}) =>
createLayer(options, { createLayer(options, {
...localGeoJsonServices, ...localGeoJsonServices,
...services,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
}); });
@@ -58,10 +59,15 @@ class MockLayerEvent {
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability. * @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior * @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
* (default: throws like a scene whose tiles are not sampleable yet). * (default: throws like a scene whose tiles are not sampleable yet).
* @param {number[][]} [options.points] `[lon, lat]` sites to load
* instead of the single dam polygon.
* @param {object} [options.services] Extra services for the layer.
*/ */
async function createRealLocalLayerHarness({ async function createRealLocalLayerHarness({
sampleHeightSupported = false, sampleHeightSupported = false,
sampleHeight = () => { throw new Error('tiles not sampleable yet'); }, sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
points = null,
services = {},
} = {}) { } = {}) {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
globalThis.fetch = async () => ({ globalThis.fetch = async () => ({
ok: true, ok: true,
status: 200, status: 200,
text: async () => JSON.stringify({ text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
type: 'Feature',
id: `site-${index}`,
properties: { name: `Site ${index}` },
// A small outline: the layer stems its centre. (Cesium draws a point
// feature as a canvas pin, which Node cannot make.)
geometry: {
type: 'Polygon',
coordinates: [[
[lon - 0.001, lat - 0.001],
[lon + 0.001, lat - 0.001],
[lon, lat + 0.001],
[lon - 0.001, lat - 0.001],
]],
},
})).join('\n') : JSON.stringify({
type: 'Feature', type: 'Feature',
id: 'real-dam', id: 'real-dam',
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } }, properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
setInputAction() {}, setInputAction() {},
destroy() {}, destroy() {},
}), }),
}); }, services);
try { try {
await layer.enable(viewer); await layer.enable(viewer);
} finally { } finally {
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle'); assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
globe.tilesLoaded = true; globe.tilesLoaded = true;
clock.advance(500); // The terrain surface reuses a height for 2 s before reading the mesh again.
clock.advance(2_100);
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
terrain = 110; terrain = 110;
clock.advance(500); clock.advance(2_100);
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
terrain = 140; terrain = 140;
@@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
}); });
/** A loaded terrain globe that counts its ray casts. */
function countingTerrain(height) {
const globe = {
show: true,
tilesLoaded: true,
reads: 0,
getHeight: () => { globe.reads += 1; return height; },
};
return globe;
}
/** Height of every stem base, in entity order. */
function allBaseHeightsM(env) {
return env.dataSources[0].entities.values.map(
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
);
}
const SAMPLE_SITES = [
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
];
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
// 20 s parked: a few reads while the surface settles, then none.
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
const settled = globe.reads;
for (let walk = 0; walk < 20; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
});
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.setBaseInView(false);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(env.sampleCalls.count, 0);
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
});
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
const asked = [];
const terrainSurface = {
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
};
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
points: [[-97.695, 30.205]],
services: { terrainSurface },
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
clock.advance(500);
env.preRender.raise();
assert.ok(asked.length >= 2);
for (const call of asked) {
assert.equal(call.viewer, env.viewer);
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
`asked at ${call.lat}, ${call.lon}`);
}
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
});
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
const heights = new Map();
let clock = null;
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: (carto) => {
clock.advance(15); // one GPU depth render
const height = 100 + heights.size;
heights.set(`${carto.longitude},${carto.latitude}`, height);
return height;
},
});
_resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene });
governorScene.renders = 0; // discard the governor's own install settling frame
clock = installFakeClock(t);
t.after(() => {
env.layer.destroy(env.viewer);
env.cleanup();
_resetRenderGovernorForTest();
});
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
// The following frames arrive well inside the 450 ms walk interval.
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
}
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
const expected = env.dataSources[0].entities.values.map((entity) => {
const carto = entity.__localBaseCarto;
return heights.get(`${carto.longitude},${carto.latitude}`);
});
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
for (const entity of env.dataSources[0].entities.values) {
const stem = entity.polyline.positions.getValue();
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
}
});
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: () => 117,
});
installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
});
+134 -15
View File
@@ -11,6 +11,10 @@ import {
applyInfraEvictionGrace, applyInfraEvictionGrace,
shouldRecomputeInfraLod, shouldRecomputeInfraLod,
} from './localGeojsonLod.js'; } from './localGeojsonLod.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../services/terrainSurface.js';
const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_MAX = 900;
const DEFAULT_LABEL_GRID_PX = 132; const DEFAULT_LABEL_GRID_PX = 132;
@@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in. * 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
*/ */
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
/**
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
* offscreen depth render (about 15 ms measured), and a first walk near a dense
* cluster wants one per site in view: 168 of them held the main thread for
* 2.6 s. Past the budget the remaining sites queue, and each following frame
* takes as many as fit, so the sites reach the same heights a frame or a few
* apart instead of all inside one blocked frame.
*/
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ /** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5; export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
governorRequestRender, governorRequestRender,
showOsmCredit, showOsmCredit,
hideOsmCredit, hideOsmCredit,
terrainSurface,
}, },
) { ) {
/**
* What ground sampling shares across a layer's records: the terrain
* surface (the application's, so every layer reads a point once and spends
* one budget, or this layer's own), the sample time spent this frame, and
* the records the frame budget left for a later frame.
*/
const _ground = {
terrain: terrainSurface || createTerrainSurface(),
sampleMs: 0,
queued: [],
};
let _dataSource = null; let _dataSource = null;
let _enabled = false; let _enabled = false;
let _clickHandler = null; let _clickHandler = null;
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
clearTimeout(_groundRetryTimer); clearTimeout(_groundRetryTimer);
_groundRetryTimer = null; _groundRetryTimer = null;
} }
/**
* Sample the sites an earlier frame's budget left over, within this
* frame's. They were never attempted, so the 2 s retry spacing does not
* apply to them.
* @param {Cesium.Viewer} viewer
* @param {number} now
* @returns {void}
*/
function sampleQueuedGround(viewer, now) {
const queued = _ground.queued;
if (queued.length === 0) return;
_ground.queued = [];
for (const record of queued) {
record.groundSampleQueued = false;
// The camera may have left the site since it queued.
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
);
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
// Past the budget this puts the record back in the queue.
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
updateLocalStemGeometry(viewer, record, now, _ground, distance);
_groundRetryArms = 0;
}
}
}
/** Ask for the frame that samples what this one's budget left over. */
function requestQueuedGroundFrame() {
if (_ground.queued.length > 0)
governorRequestRender(`local-ground-sample:${id}`);
}
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
sourceId: id, sourceId: id,
host: overlayHost, host: overlayHost,
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
const source = _dataSource; const source = _dataSource;
_dataSource = null; _dataSource = null;
_stemRecords = []; _stemRecords = [];
_ground.queued = [];
_stemGeometryDirty = true; _stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY; _lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id); removeEntityContextsForLayer(id);
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
// Store references for bounded stem scaling and native picking. // Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto; feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base; feature.__localBaseCartesian = base;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const context = registerEntityContext(feature, { const context = registerEntityContext(feature, {
id: `${id}:${recordId}`, id: `${id}:${recordId}`,
layerId: id, layerId: id,
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
stemPositionBuffers, stemPositionBuffers,
stemPositionBufferIndex: 0, stemPositionBufferIndex: 0,
groundHeight, groundHeight,
// Degrees, as the terrain surface takes them.
latDeg,
lonDeg,
groundSampled: false, groundSampled: false,
groundSampleQueued: false,
lastGroundSampleMs: 0, lastGroundSampleMs: 0,
priority, priority,
entry: labels entry: labels
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
_preRenderRemover = viewer.scene.preRender.addEventListener(() => { _preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return; if (!_enabled || !_dataSource) return;
const now = performance.now(); const now = performance.now();
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; // One sample budget per frame, shared by the queue and the walk.
_ground.sampleMs = 0;
sampleQueuedGround(viewer, now);
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
requestQueuedGroundFrame();
return;
}
_lastVisibilityUpdate = now; _lastVisibilityUpdate = now;
const cameraPos = viewer.camera.positionWC; const cameraPos = viewer.camera.positionWC;
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
_lastGroundSampleCapability = canSampleGround; _lastGroundSampleCapability = canSampleGround;
let groundRetryPending = false; let groundRetryPending = false;
let groundSampleProgress = false; let groundSampleProgress = false;
// This walk asks for no height while tiles load, so it shows the
// surface the load itself; see terrainSurface.js observe().
_ground.terrain.observe?.(viewer);
// --- Globe-LOD: recompute the active-stem set on camera moves. --- // --- Globe-LOD: recompute the active-stem set on camera moves. ---
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the // `_stemGeometryDirty` is set by moveEnd, the first enable, and the
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
const terrainFloorChanged = refreshLocalTerrainFloor( const terrainFloorChanged = refreshLocalTerrainFloor(
viewer, viewer,
record, record,
_ground,
); );
if (refreshStemGeometry || terrainFloorChanged) { if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now); updateLocalStemGeometry(viewer, record, now, _ground);
} else if ( } else if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
); );
if ( if (
distance < GROUND_SAMPLE_MAX_DISTANCE_M && distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
sampleLocalGroundHeight(viewer, record, now) sampleLocalGroundHeight(viewer, record, now, _ground)
) { ) {
updateLocalStemGeometry(viewer, record, now, distance); updateLocalStemGeometry(viewer, record, now, _ground, distance);
} }
} }
if (!wasGroundSampled && record.groundSampled) if (!wasGroundSampled && record.groundSampled)
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
// the user happens to move. Schedule the frame the retry needs. // the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range, in-view records only, // Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the // so a far camera (or a keyless scene) stays fully idle; the
// distance is only computed for still-unsampled stems. // distance is only computed for still-unsampled stems. A record
// the frame budget queued is sampled by the frame the queue asks
// for, with no timer.
if ( if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
!record.groundSampleQueued &&
!groundRetryPending && !groundRetryPending &&
Cesium.Cartesian3.distance( Cesium.Cartesian3.distance(
viewer.camera.positionWC, viewer.camera.positionWC,
record.base, record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M && ) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record) groundSampleInView(viewer, record, _ground)
) { ) {
groundRetryPending = true; groundRetryPending = true;
} }
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
// so the records still waiting get their own bounded run of retries. // so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0; if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer); if (groundRetryPending) scheduleGroundRetryRender(viewer);
requestQueuedGroundFrame();
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort( const cohort = selectLocalInfrastructureOverlayCohort(
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
} }
function refreshLocalTerrainFloor(viewer, record) { function refreshLocalTerrainFloor(viewer, record, ground) {
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
return false; return false;
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
// Camera motion can refine terrain after the first successful depth sample. // Camera motion can refine terrain after the first successful depth sample.
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do // Reuse the loaded mesh on existing bounded walks; never arm a timer or do
// another GPU readback just to maintain this floor. Keep roof elevations. // another GPU readback just to maintain this floor. Keep roof elevations.
const height = globe.getHeight?.(record.carto); // The surface keeps the height per point and casts the ray again only after
// the camera moves or tiles load. MARKER: the answer is this point's own
// height, never a neighbour's, since a height taken here is kept for good.
const height = ground.terrain.height(
viewer,
record.latDeg,
record.lonDeg,
TERRAIN_NEED.MARKER,
);
if ( if (
!Number.isFinite(height) || !Number.isFinite(height) ||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
@@ -1323,11 +1413,20 @@ function groundSamplePoint(record, height, result) {
* sample can accept, so high or below-ellipsoid ground in view is never * sample can accept, so high or below-ellipsoid ground in view is never
* mistaken for an unseen base. Unseen ground is sampled on the first walk that * mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it. * sees it.
*
* The terrain height comes from the shared surface, which keeps it per point:
* this runs for every unsampled site in range on every walk, and a ray cast
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
* height only roughly, so a height the surface has not read yet, or tiles
* still loading, count as unknown ground.
*/ */
function groundSampleInView(viewer, record) { function groundSampleInView(viewer, record, ground) {
const camera = viewer.camera; const camera = viewer.camera;
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; const terrain =
globe?.show && globe.tilesLoaded !== false
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
: undefined;
const known = const known =
Number.isFinite(terrain) && Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
@@ -1368,18 +1467,30 @@ function groundSampleInView(viewer, record) {
); );
} }
function sampleLocalGroundHeight(viewer, record, now) { function sampleLocalGroundHeight(viewer, record, now, ground) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
if (!groundSampleInView(viewer, record)) return false; // This frame's samples have used its budget: leave the record for the next
// frame, unattempted, so the retry spacing does not hold it back.
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
if (!record.groundSampleQueued) {
record.groundSampleQueued = true;
ground.queued.push(record);
}
return false;
}
if (!groundSampleInView(viewer, record, ground)) return false;
record.lastGroundSampleMs = now; record.lastGroundSampleMs = now;
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false; if (globe?.show && globe.tilesLoaded === false) return false;
let sampled; let sampled;
const startedAt = performance.now();
try { try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch { } catch {
return false; // tiles not ready; retry on a later bounded update return false; // tiles not ready; retry on a later bounded update
} finally {
ground.sampleMs += performance.now() - startedAt;
} }
if ( if (
!Number.isFinite(sampled) || !Number.isFinite(sampled) ||
@@ -1389,7 +1500,9 @@ function sampleLocalGroundHeight(viewer, record, now) {
// A coarse scene-depth sample can lie below the terrain already loaded by // A coarse scene-depth sample can lie below the terrain already loaded by
// the visible globe. Keep that terrain as a floor while allowing roofs and // the visible globe. Keep that terrain as a floor while allowing roofs and
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its // valid below-sea-level elevations. Photoreal scenes hide the globe, so its
// inactive terrain must not constrain their geometry. // inactive terrain must not constrain their geometry. Read from the globe
// itself, once per site: the floor under a sample must be the mesh that is
// loaded now, not a height kept from a moment before.
const terrainHeight = globe?.show const terrainHeight = globe?.show
? globe.getHeight?.(record.carto) ? globe.getHeight?.(record.carto)
: undefined; : undefined;
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
record.entity.__localBaseCartesian = record.base; record.entity.__localBaseCartesian = record.base;
} }
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { function updateLocalStemGeometry(
viewer,
record,
now,
ground,
knownDistance = null,
) {
const distance = Number.isFinite(knownDistance) const distance = Number.isFinite(knownDistance)
? knownDistance ? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
sampleLocalGroundHeight(viewer, record, now); sampleLocalGroundHeight(viewer, record, now, ground);
// Keep the intended screen-size scaling in close-up views too. A 5 km // Keep the intended screen-size scaling in close-up views too. A 5 km
// minimum made a marker hundreds of metres tall beside a nearby building. // minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1); const effectiveDistance = Math.max(distance, 1);
+46 -1
View File
@@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf
); );
// A grounded aircraft stands on its wheels: its origin the scaled // A grounded aircraft stands on its wheels: its origin the scaled
// clearance above the ground, never below it. // clearance above the ground, never below it.
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); // A model built standing on its origin (a ground vehicle, a ground
// station's mast: its lowest point at z 0) has no clearance.
if (set.catalog[model].min[2] > -0.05)
assert.ok(
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
`${type}: built standing, clearance ${spec.clearanceM}`,
);
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
} }
} }
@@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a); assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
assert.ok(Object.isFrozen(a)); assert.ok(Object.isFrozen(a));
}); });
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
'../layers/flights/modelSpec.js'
);
const tender = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
set,
);
const car = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
set,
);
assert.equal(tender.model, 'fire_truck');
assert.equal(car.model, 'car');
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
);
assert.notEqual(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
);
// Keyed by the rule matched, not the callsign: at most one entry per rule
// and one for the type's own model.
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
);
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
);
assert.equal(
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
tender,
);
});
+1 -3
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); assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
}); });
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { test('civilian and military trail heads use the lower-centre model anchor', async () => {
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
[name, readLayerSource(new URL(`./${name}`, import.meta.url))] [name, readLayerSource(new URL(`./${name}`, import.meta.url))]
))); )));
@@ -300,7 +300,5 @@ test('civilian and military trail heads use the lower-centre model anchor and we
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
`${name} trail head uses the dedicated lower-centre model anchor`, `${name} trail head uses the dedicated lower-centre model anchor`,
); );
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
`${name} keeps diffuse texture contribution weak through code-side MIX`);
} }
}); });
+9
View File
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
]; ];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
/**
* An aid to navigation's disc and card accent: a bone white apart from every
* ship type's colour.
*/
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
css: '#d9d2c0',
accent: '217, 210, 192',
});
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES // Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them. // and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({ const TYPE_DISPLAY_KEYS = Object.freeze({
+107
View File
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
} }
/**
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
* floating marks by their cardinal quadrant, lateral side, safe water,
* isolated danger or special mark, and the lights, RACONs and reference
* points around them. Code 0 names no kind.
*/
const AID_TYPE_NAMES = Object.freeze([
'Navigation aid',
'Reference point',
'RACON',
'Fixed structure off shore',
'Emergency wreck marking buoy',
'Light, without sectors',
'Light, with sectors',
'Leading light front',
'Leading light rear',
'Beacon, Cardinal N',
'Beacon, Cardinal E',
'Beacon, Cardinal S',
'Beacon, Cardinal W',
'Beacon, Port hand',
'Beacon, Starboard hand',
'Beacon, Preferred channel port hand',
'Beacon, Preferred channel starboard hand',
'Beacon, Isolated danger',
'Beacon, Safe water',
'Beacon, Special mark',
'Cardinal Mark N',
'Cardinal Mark E',
'Cardinal Mark S',
'Cardinal Mark W',
'Port hand Mark',
'Starboard hand Mark',
'Preferred Channel Port hand',
'Preferred Channel Starboard hand',
'Isolated danger',
'Safe Water',
'Special Mark',
'Light Vessel / LANBY / Rigs',
]);
/**
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
* @param {string|number|null|undefined} code
* @returns {string}
*/
export function aidToNavigationTypeName(code) {
const text = String(code ?? '').trim();
const index = /^\d+$/.test(text) ? Number(text) : -1;
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
}
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
const AID_TYPE_NAME_SET = new Set(
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
);
/**
* Words a vessel type uses for an aid to navigation. A vessel that works on
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
* not a mark on the water.
*/
const AID_TYPE_TEXT =
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
const AID_MMSI = /^99\d{7}$/;
/**
* Whether each type text seen names an aid. Vessel layers ask per vessel on
* their draw passes, and a feed uses few distinct type texts, so each is
* matched once; the memo is bounded against a feed of free-form types.
*/
const AID_TYPE_MEMO = new Map();
const AID_TYPE_MEMO_MAX = 1024;
function typeNamesAid(text) {
let aid = AID_TYPE_MEMO.get(text);
if (aid !== undefined) return aid;
aid =
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
AID_TYPE_MEMO.set(text, aid);
return aid;
}
/**
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
* RACON or light) rather than a vessel: its type is one of the kinds an aid
* report names or uses a word for one, or its MMSI is an aid-to-navigation
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
* @param {{type?: string|number|null, mmsi?: string|number|null,
* virtual?: boolean}} vessel
* @returns {boolean}
*/
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
if (virtual === true) return true;
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
if (text && typeNamesAid(text)) return true;
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
}
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
test('an aid-to-navigation type code names its kind of aid', () => {
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
assert.equal(aidToNavigationTypeName(2), 'RACON');
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
// Code 0, a code past 31 and no code name no kind.
for (const code of [0, 32, -1, '', null, undefined, 'x'])
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
});
test('aids to navigation are known by their type, else their MMSI', () => {
for (const type of [
'Beacon, Special Mark',
'Cardinal Mark N',
'Port Hand Mark',
'Starboard Hand Mark',
'Safe Water',
'Isolated danger',
'Preferred Channel Port hand',
'RACON',
'Light Vessel / LANBY / Rigs',
'Reference point',
'Navigation aid',
'AtoN',
'Lighthouse',
'Buoy',
// Every kind an aid report names is an aid, whatever its MMSI.
'Light, with sectors',
'Leading light rear',
'Fixed structure off shore',
'Emergency wreck marking buoy',
])
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
// A virtual aid is an aid whatever its type or MMSI.
assert.equal(
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
true,
);
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001' },
{ type: 'Buoy-Laying Vessel', mmsi: '' },
{ type: 'Buoy Layer', mmsi: '' },
{ type: 'Lighter', mmsi: '' },
{ type: 'Flight deck', mmsi: '' },
{ type: 70, mmsi: 366999002 },
{ type: '70', mmsi: '366999002' },
{ type: '0', mmsi: '' },
{ type: 'Cargo', mmsi: '99' },
{ type: 'Passenger', mmsi: '9936720011' },
{},
])
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
});
+10 -1
View File
@@ -776,6 +776,8 @@
"TANKER": "ناقلة", "TANKER": "ناقلة",
"OTHER": "أخرى", "OTHER": "أخرى",
"VESSEL": "سفينة", "VESSEL": "سفينة",
"AID TO NAVIGATION": "مساعدة ملاحية",
"VIRTUAL": "افتراضي",
"POS: LIVE": "الموضع: مباشر", "POS: LIVE": "الموضع: مباشر",
"POS: {time}Z": "الموضع: {time}Z", "POS: {time}Z": "الموضع: {time}Z",
"AIS: --": "AIS: --", "AIS: --": "AIS: --",
@@ -3953,5 +3955,12 @@
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
"Showing {shown} of {total}": "عرض {shown} من {total}", "Showing {shown} of {total}": "عرض {shown} من {total}",
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات" "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
"Reload": "إعادة التحميل",
"Retry": "إعادة المحاولة",
"Reset view": "إعادة ضبط العرض"
} }
+10 -1
View File
@@ -416,6 +416,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "SCHIFF", "VESSEL": "SCHIFF",
"AID TO NAVIGATION": "SEEZEICHEN",
"VIRTUAL": "VIRTUELL",
"POS: LIVE": "POS: LIVE", "POS: LIVE": "POS: LIVE",
"POS: {time}Z": "POS: {time}Z", "POS: {time}Z": "POS: {time}Z",
"awaiting first AIS position…": "warten auf erste AIS-Position…", "awaiting first AIS position…": "warten auf erste AIS-Position…",
@@ -3605,5 +3607,12 @@
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen", "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
"Showing {shown} of {total}": "{shown} von {total} angezeigt", "Showing {shown} of {total}": "{shown} von {total} angezeigt",
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN" "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
"Reload": "Neu laden",
"Retry": "Erneut versuchen",
"Reset view": "Ansicht zurücksetzen"
} }
+10 -1
View File
@@ -650,6 +650,8 @@
"TANKER": "PETROLERO", "TANKER": "PETROLERO",
"OTHER": "OTRO", "OTHER": "OTRO",
"VESSEL": "BUQUE", "VESSEL": "BUQUE",
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
"VIRTUAL": "VIRTUAL",
"POS: LIVE": "POS: EN VIVO", "POS: LIVE": "POS: EN VIVO",
"POS: {time}Z": "POS: {time}Z", "POS: {time}Z": "POS: {time}Z",
"AIS: --": "AIS: --", "AIS: --": "AIS: --",
@@ -3692,5 +3694,12 @@
"500–1,000 m": "500–1000 m", "500–1,000 m": "500–1000 m",
"1,000–2,000 m": "1000–2000 m", "1,000–2,000 m": "1000–2000 m",
"2,000–3,000 m": "2000–3000 m", "2,000–3,000 m": "2000–3000 m",
"3,000 m and above": "3000 m o más" "3,000 m and above": "3000 m o más",
"The globe stopped drawing": "El globo dejó de dibujarse",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
"Reload": "Recargar",
"Retry": "Reintentar",
"Reset view": "Restablecer la vista"
} }
+10 -1
View File
@@ -1291,6 +1291,8 @@
"AIS: {name}": "AIS : {name}", "AIS: {name}": "AIS : {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
"VESSEL": "NAVIRE", "VESSEL": "NAVIRE",
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
"VIRTUAL": "VIRTUEL",
"{speed}KT": "{speed} ND", "{speed}KT": "{speed} ND",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS : DIRECT", "POS: LIVE": "POS : DIRECT",
@@ -3692,5 +3694,12 @@
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées", "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
"Showing {shown} of {total}": "Affichées : {shown} sur {total}", "Showing {shown} of {total}": "Affichées : {shown} sur {total}",
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
"The globe stopped drawing": "Le globe ne s’affiche plus",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
"Reload": "Recharger",
"Retry": "Réessayer",
"Reset view": "Réinitialiser la vue"
} }
+10 -1
View File
@@ -1149,6 +1149,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "אונייה", "VESSEL": "אונייה",
"AID TO NAVIGATION": "עזר ניווט",
"VIRTUAL": "וירטואלי",
"{speed}KT": "{speed} קשר", "{speed}KT": "{speed} קשר",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS: חי", "POS: LIVE": "POS: חי",
@@ -3692,5 +3694,12 @@
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים" "BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
"Reload": "טען מחדש",
"Retry": "נסה שוב",
"Reset view": "איפוס תצוגה"
} }
+10 -1
View File
@@ -1291,6 +1291,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
"VESSEL": "NAVE", "VESSEL": "NAVE",
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
"VIRTUAL": "VIRTUALE",
"{speed}KT": "{speed}KT", "{speed}KT": "{speed}KT",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS: LIVE", "POS: LIVE": "POS: LIVE",
@@ -3692,5 +3694,12 @@
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
"Showing {shown} of {total}": "Mostrati: {shown} su {total}", "Showing {shown} of {total}": "Mostrati: {shown} su {total}",
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
"The globe stopped drawing": "Il globo ha smesso di disegnare",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
"Reload": "Ricarica",
"Retry": "Riprova",
"Reset view": "Ripristina vista"
} }
+10 -1
View File
@@ -1253,6 +1253,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶", "VESSEL": "船舶",
"AID TO NAVIGATION": "航路標識",
"VIRTUAL": "仮想",
"{speed}KT": "{speed} kt", "{speed}KT": "{speed} kt",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS: ライブ", "POS: LIVE": "POS: ライブ",
@@ -3518,5 +3520,12 @@
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
"Showing {shown} of {total}": "{total}件中{shown}件を表示", "Showing {shown} of {total}": "{total}件中{shown}件を表示",
"BROWSE DATA LIBRARY": "データライブラリを見る" "BROWSE DATA LIBRARY": "データライブラリを見る",
"The globe stopped drawing": "地球儀の描画が止まりました",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
"Reload": "再読み込み",
"Retry": "再試行",
"Reset view": "ビューをリセット"
} }
+10 -1
View File
@@ -1289,6 +1289,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "EMBARCAÇÃO", "VESSEL": "EMBARCAÇÃO",
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
"VIRTUAL": "VIRTUAL",
"{speed}KT": "{speed} KT", "{speed}KT": "{speed} KT",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS: AO VIVO", "POS: LIVE": "POS: AO VIVO",
@@ -3692,5 +3694,12 @@
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas", "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
"Showing {shown} of {total}": "Mostrando {shown} de {total}", "Showing {shown} of {total}": "Mostrando {shown} de {total}",
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
"The globe stopped drawing": "O globo parou de desenhar",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
"Reload": "Recarregar",
"Retry": "Tentar novamente",
"Reset view": "Redefinir vista"
} }
+10 -1
View File
@@ -394,6 +394,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
"VESSEL": "СУДНО", "VESSEL": "СУДНО",
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
"AIS: --": "AIS: --", "AIS: --": "AIS: --",
"awaiting first AIS position…": "ожидание первой позиции AIS…", "awaiting first AIS position…": "ожидание первой позиции AIS…",
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…", "awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
@@ -3779,5 +3781,12 @@
"500–1,000 m": "500–1000 м", "500–1,000 m": "500–1000 м",
"1,000–2,000 m": "1000–2000 м", "1,000–2,000 m": "1000–2000 м",
"2,000–3,000 m": "2000–3000 м", "2,000–3,000 m": "2000–3000 м",
"3,000 m and above": "3000 м и выше" "3,000 m and above": "3000 м и выше",
"The globe stopped drawing": "Глобус перестал отрисовываться",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
"Reload": "Перезагрузить",
"Retry": "Повторить",
"Reset view": "Сбросить вид"
} }
+10 -1
View File
@@ -382,6 +382,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "FARTYG", "VESSEL": "FARTYG",
"AID TO NAVIGATION": "SJÖMÄRKE",
"VIRTUAL": "VIRTUELL",
"POS: LIVE": "POS: LIVE", "POS: LIVE": "POS: LIVE",
"POS: {time}Z": "POS: {time}Z", "POS: {time}Z": "POS: {time}Z",
"awaiting first AIS position…": "väntar på första AIS-positionen …", "awaiting first AIS position…": "väntar på första AIS-positionen …",
@@ -3605,5 +3607,12 @@
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager", "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
"Showing {shown} of {total}": "Visar {shown} av {total}", "Showing {shown} of {total}": "Visar {shown} av {total}",
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET" "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
"The globe stopped drawing": "Globen ritas inte längre",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
"Reload": "Ladda om",
"Retry": "Försök igen",
"Reset view": "Återställ vy"
} }
+10 -1
View File
@@ -1309,6 +1309,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
"VESSEL": "СУДНО", "VESSEL": "СУДНО",
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
"VIRTUAL": "ВІРТУАЛЬНИЙ",
"{speed}KT": "{speed} ВУЗ", "{speed}KT": "{speed} ВУЗ",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "ПОЗ.: ОНЛАЙН", "POS: LIVE": "ПОЗ.: ОНЛАЙН",
@@ -3779,5 +3781,12 @@
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
"Showing {shown} of {total}": "Показано {shown} з {total}", "Showing {shown} of {total}": "Показано {shown} з {total}",
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
"The globe stopped drawing": "Глобус перестав відмальовуватися",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
"Reload": "Перезавантажити",
"Retry": "Повторити",
"Reset view": "Скинути вигляд"
} }
+10 -1
View File
@@ -1229,6 +1229,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶", "VESSEL": "船舶",
"AID TO NAVIGATION": "助航标志",
"VIRTUAL": "虚拟",
"{speed}KT": "{speed} kn", "{speed}KT": "{speed} kn",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS:实时", "POS: LIVE": "POS:实时",
@@ -3518,5 +3520,12 @@
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
"Showing {shown} of {total}": "显示 {shown} / {total}", "Showing {shown} of {total}": "显示 {shown} / {total}",
"BROWSE DATA LIBRARY": "浏览数据资源库" "BROWSE DATA LIBRARY": "浏览数据资源库",
"The globe stopped drawing": "地球已停止绘制",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
"Reload": "重新加载",
"Retry": "重试",
"Reset view": "重置视图"
} }
+10 -1
View File
@@ -1224,6 +1224,8 @@
"AIS: {name}": "AIS: {name}", "AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶", "VESSEL": "船舶",
"AID TO NAVIGATION": "助航標誌",
"VIRTUAL": "虛擬",
"{speed}KT": "{speed} kn", "{speed}KT": "{speed} kn",
"{heading}DEG": "{heading}°", "{heading}DEG": "{heading}°",
"POS: LIVE": "POS:實時", "POS: LIVE": "POS:實時",
@@ -3518,5 +3520,12 @@
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
"Showing {shown} of {total}": "顯示 {shown} / {total}", "Showing {shown} of {total}": "顯示 {shown} / {total}",
"BROWSE DATA LIBRARY": "瀏覽數據資源庫" "BROWSE DATA LIBRARY": "瀏覽數據資源庫",
"The globe stopped drawing": "地球已停止繪製",
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
"Reload": "重新加載",
"Retry": "重試",
"Reset view": "重置視圖"
} }
+4 -2
View File
@@ -411,7 +411,8 @@
"MMSI: {id}", "MMSI: {id}",
"NORAD: {id}", "NORAD: {id}",
"Regional", "Regional",
"{datasets} · {tiles} · {packs}" "{datasets} · {tiles} · {packs}",
"VIRTUAL"
], ],
"fr": [ "fr": [
"1 min/s", "1 min/s",
@@ -1067,7 +1068,8 @@
"ICAO: {id}", "ICAO: {id}",
"MMSI: {id}", "MMSI: {id}",
"NORAD: {id}", "NORAD: {id}",
"Regional" "Regional",
"VIRTUAL"
], ],
"ru": [ "ru": [
"1900–1945", "1900–1945",
@@ -0,0 +1,331 @@
// Station ground heights. A city's stations are all drawn at once at the
// fallback height; their ground heights are found afterwards a few
// milliseconds at a time, so a city of hundreds of docks never holds the main
// thread for the length of hundreds of depth renders. Each case drives the
// real rendering part (and the real lifecycle where it tears down) over a
// scene whose height reads are counted and timed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
import { createTerrainSurface } from '../../services/terrainSurface.js';
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
/** Milliseconds one fake depth render holds the thread. */
const SAMPLE_COST_MS = 1;
/** The ground under a point, as the fake scene and globe both report it. */
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
function busyWait(ms) {
const until = performance.now() + ms;
while (performance.now() < until);
}
/** `count` stations on a line through Boston. */
function stations(count, prefix = 's') {
return {
data: {
stations: Array.from({ length: count }, (_, i) => ({
station_id: `${prefix}${i}`,
name: `Dock ${i}`,
lat: 42.3 + i * 1e-4,
lon: -71.1 + i * 1e-4,
capacity: 10,
})),
},
};
}
/**
* The rendering part over a scene that counts its height reads.
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
* `shared`: the application's terrain surface is supplied;
* `unloaded`: latitudes whose terrain tile is not drawn.
*/
function makeLayer({
terrainGlobe = false,
shared = false,
unloaded = () => false,
} = {}) {
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
// sampleHeight calls in this turn of the event loop, and the most in any
// turn so far. Two numbers, not a list: a slow run has a great many turns.
let thisTurn = 0;
let mostInATurn = 0;
let ticking = true;
const tick = () => {
if (!ticking) return;
thisTurn = 0;
setImmediate(tick);
};
setImmediate(tick);
const degrees = (carto) => [
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
];
const viewer = {
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: {
sampleHeightSupported: true,
sampleHeight(carto) {
calls.sampleHeight += 1;
thisTurn += 1;
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
busyWait(SAMPLE_COST_MS);
return groundAt(...degrees(carto));
},
globe: {
show: terrainGlobe,
tilesLoaded: true,
getHeight(carto) {
calls.getHeight += 1;
const [lat, lon] = degrees(carto);
return unloaded(lat) ? undefined : groundAt(lat, lon);
},
},
primitives: { add() {}, remove() {} },
},
};
const surface = createTerrainSurface();
const services = {
render: {
governorRequestRender() {
calls.render += 1;
},
},
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
if (shared) {
services.ground = {
terrainSurface: {
...surface,
height(...args) {
calls.sharedHeight += 1;
return surface.height(...args);
},
},
};
}
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source: {} };
parts.model = createModel(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.ingestion = { abortAllInFlight() {} };
// Leaves the points alone, so a test of disable() sees what disable() stops.
parts.viewport = {
onCameraChanged() {},
deactivateAllCities() {},
runProximityCheck: async () => {},
};
parts.lifecycle = createLifecycle(context);
parts.lifecycle.methods.init(viewer);
state._pointCollection = {
show: true,
add: (options) => ({ ...options }),
remove() {},
};
const heightOf = (key) =>
Cesium.Cartographic.fromCartesian(
state._stationRenderMap.get(key).point.position,
).height;
return {
state,
parts,
viewer,
calls,
rendering: parts.rendering,
lifecycle: parts.lifecycle.methods,
heightOf,
add(cityId, count) {
const info = parts.model.parseStationInformation(stations(count, cityId));
parts.rendering.ensureCityPoints(cityId, info);
return info;
},
/** Wait until every queued station has its height, or fail. */
async refined() {
const deadline = performance.now() + 20_000;
while (parts.rendering.pendingHeightCount() > 0) {
assert.ok(performance.now() < deadline, 'refinement finished');
await new Promise((resolve) => setTimeout(resolve, 5));
}
},
/** The most sampleHeight calls made without yielding to the loop. */
longestBurst() {
return mostInATurn;
},
done() {
ticking = false;
parts.rendering.stopHeightRefinement();
},
};
}
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('a city of stations is drawn at once without sampling heights', (t) => {
const layer = makeLayer();
t.after(layer.done);
const started = performance.now();
layer.add('boston', 300);
const spent = performance.now() - started;
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
assert.ok(
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
'drawn at the fallback height',
);
assert.equal(layer.rendering.pendingHeightCount(), 300);
});
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
const layer = makeLayer();
t.after(layer.done);
const info = layer.add('boston', 120);
const rendersBefore = layer.calls.render;
await layer.refined();
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
assert.ok(
layer.longestBurst() <= 10,
`longest burst was ${layer.longestBurst()} samples`,
);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(
Math.abs(actual - expected) < 1e-3,
`${station.stationId}: ${actual} vs ${expected}`,
);
}
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
});
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
const layer = makeLayer({ terrainGlobe: true, shared: true });
t.after(layer.done);
const info = layer.add('boston', 200);
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
assert.equal(layer.calls.getHeight, 200);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
}
});
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
const layer = makeLayer({ terrainGlobe: true });
t.after(layer.done);
layer.add('boston', 50);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.getHeight, 50);
assert.ok(
Math.abs(
layer.heightOf('boston:boston7') -
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
// Stations 10 and up lie on a tile the globe is not drawing.
const layer = makeLayer({
terrainGlobe: true,
shared: true,
unloaded: (lat) => lat > 42.30095,
});
t.after(layer.done);
layer.add('boston', 20);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 10);
assert.ok(
Math.abs(
layer.heightOf('boston:boston15') -
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('removing a city stops its refinement and leaves the other running', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 40);
layer.add('toronto', 40);
layer.rendering.removeCityPoints('boston');
assert.equal(layer.rendering.pendingHeightCount(), 40);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
assert.ok(layer.heightOf('toronto:toronto3') > 50);
layer.add('boston', 40);
layer.rendering.removeCityPoints('boston');
layer.rendering.removeCityPoints('toronto');
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
});
test('disabling the layer stops refinement and leaves no timer', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.lifecycle.enable(layer.viewer);
layer.add('boston', 200);
await pause(30);
const partWay = layer.calls.sampleHeight;
assert.ok(partWay < 200, 'still refining');
layer.lifecycle.disable(layer.viewer);
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
});
test('a station selected before its height arrives is re-selected at it', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 3);
const reselected = [];
layer.state._selectedKey = 'boston:boston1';
// The selection part's select path; here it records the key.
layer.parts.selection._selectStation = (key) => reselected.push(key);
await layer.refined();
assert.deepEqual(reselected, ['boston:boston1']);
});
+3
View File
@@ -44,6 +44,7 @@ export function createLifecycle({
layerState._inFlightInfo = new Map(); layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map(); layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map(); layerState._stationRenderMap = new Map();
parts.rendering?.stopHeightRefinement?.();
layerState._clickHandler = null; layerState._clickHandler = null;
layerState._selectedKey = null; layerState._selectedKey = null;
layerState._selectedEntity = null; layerState._selectedEntity = null;
@@ -134,6 +135,7 @@ export function createLifecycle({
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities(); parts.viewport.deactivateAllCities();
parts.rendering?.stopHeightRefinement?.();
layerState._cityRuntime.clear(); layerState._cityRuntime.clear();
layerState._pointCollection.show = false; layerState._pointCollection.show = false;
layerState._count = 0; layerState._count = 0;
@@ -165,6 +167,7 @@ export function createLifecycle({
layerState._cameraChangedAttached = false; layerState._cameraChangedAttached = false;
} }
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.rendering?.stopHeightRefinement?.();
if (layerState._pointCollection) { if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection); viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null; layerState._pointCollection = null;
+11
View File
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
export const POINT_HEIGHT_OFFSET_M = 2.0; export const POINT_HEIGHT_OFFSET_M = 2.0;
/**
* Milliseconds one step may spend finding station ground heights. A height
* the terrain globe cannot give is a depth render of a millisecond or more.
*/
export const HEIGHT_REFINE_BUDGET_MS = 4;
/** Milliseconds between those steps, about a frame. */
export const HEIGHT_REFINE_INTERVAL_MS = 16;
/** Hard cap on total rendered station points across all cities. */ /** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000; export const MAX_TOTAL_POINTS = 8000;
+106 -12
View File
@@ -11,7 +11,13 @@ import {
POINT_HEIGHT_OFFSET_M, POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS, MAX_TOTAL_POINTS,
COLOR_OUTLINE, COLOR_OUTLINE,
HEIGHT_REFINE_BUDGET_MS,
HEIGHT_REFINE_INTERVAL_MS,
} from './policy.js'; } from './policy.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../../services/terrainSurface.js';
export function createRendering({ export function createRendering({
state: layerState, state: layerState,
@@ -20,6 +26,14 @@ export function createRendering({
source, source,
}) { }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
// The terrain globe's loaded surface: the application's shared reader, or
// the layer's own when it is built without one.
const terrainSurface =
services.ground?.terrainSurface || createTerrainSurface();
/** Stations drawn at the fallback height, waiting for their ground height. */
let heightQueue = [];
let heightTimer = null;
const heightPoint = new Cesium.Cartographic();
/** /**
* Resolve the effective dock capacity for a station. * Resolve the effective dock capacity for a station.
@@ -87,8 +101,10 @@ export function createRendering({
} }
/** /**
* Create a Cartesian3 position for a station, terrain-clamped when possible. * Create a Cartesian3 position for a station at the fallback height. Its
* Falls back to a fixed small height offset if terrain sampling is unsupported. * ground height is found afterwards (refineHeights): a city's stations are
* drawn at once, and finding hundreds of heights in the same task held the
* main thread for most of a second.
* @param {Object} station - Station info object with lat/lon. * @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid. * @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
*/ */
@@ -97,18 +113,78 @@ export function createRendering({
const lon = Number(station?.lon); const lon = Number(station?.lon);
const lat = Number(station?.lat); const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
}
let height = POINT_HEIGHT_OFFSET_M; /**
// Sample terrain height so points sit on ground rather than at ellipsoid level * The ground height under a station, or undefined when the scene cannot say.
if (layerState._viewer?.scene?.sampleHeightSupported) { * A terrain globe answers from its loaded surface, which is cheap. Where it
const carto = Cesium.Cartographic.fromDegrees(lon, lat); * cannot (the globe is hidden under photorealistic tiles, or draws no tile
const sampled = layerState._viewer.scene.sampleHeight(carto); * at the point) the scene renders the depth there, at a millisecond or more
if (Number.isFinite(sampled)) { * a point.
height = sampled + POINT_HEIGHT_OFFSET_M; */
}
function sampleGroundHeight(lat, lon) {
const viewer = layerState._viewer;
const scene = viewer?.scene;
if (!scene) return undefined;
// A station is a marker: its one read is not left for a later window.
// The step's own time budget bounds what a pass spends.
const terrain = terrainSurface.height(
viewer,
lat,
lon,
TERRAIN_NEED.MARKER,
);
if (Number.isFinite(terrain)) return terrain;
if (!scene.sampleHeightSupported) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
const sampled = scene.sampleHeight(heightPoint);
return Number.isFinite(sampled) ? sampled : undefined;
}
/**
* Move queued stations onto the ground for a few milliseconds, then leave
* the rest to the next step. A station the scene has no height for keeps
* the fallback height, as it did when heights were read while drawing.
*/
function refineHeights() {
heightTimer = null;
const started = performance.now();
let moved = false;
while (
heightQueue.length > 0 &&
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
) {
const { record, lat, lon } = heightQueue.pop();
const ground = sampleGroundHeight(lat, lon);
if (ground === undefined) continue;
record.point.position = Cesium.Cartesian3.fromDegrees(
lon,
lat,
ground + POINT_HEIGHT_OFFSET_M,
);
moved = true;
// The highlight and card of a selected station copy its position.
if (layerState._selectedKey === record.key)
parts.selection._selectStation(record.key);
} }
if (moved) governorRequestRender('bikeshare-heights');
scheduleHeightRefinement();
}
return Cesium.Cartesian3.fromDegrees(lon, lat, height); function scheduleHeightRefinement() {
if (heightTimer !== null || heightQueue.length === 0) return;
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
}
/** Drop every queued station and the pending step. */
function stopHeightRefinement() {
heightQueue = [];
clearTimeout(heightTimer);
heightTimer = null;
} }
/** /**
@@ -178,7 +254,7 @@ export function createRendering({
id: key, id: key,
}); });
layerState._stationRenderMap.set(key, { const record = {
key, key,
cityId, cityId,
stationId: station.stationId, stationId: station.stationId,
@@ -191,12 +267,19 @@ export function createRendering({
isInstalled: station.isInstalled, isInstalled: station.isInstalled,
isRenting: station.isRenting, isRenting: station.isRenting,
isReturning: station.isReturning, isReturning: station.isReturning,
};
layerState._stationRenderMap.set(key, record);
heightQueue.push({
record,
lat: Number(station.lat),
lon: Number(station.lon),
}); });
runtime.stationKeys.add(key); runtime.stationKeys.add(key);
} }
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
scheduleHeightRefinement();
} }
/** /**
@@ -224,6 +307,12 @@ export function createRendering({
layerState._stationRenderMap.delete(key); layerState._stationRenderMap.delete(key);
} }
// Its stations no longer wait for a height.
heightQueue = heightQueue.filter(
({ record }) => layerState._stationRenderMap.get(record.key) === record,
);
if (heightQueue.length === 0) stopHeightRefinement();
layerState._cityRuntime.delete(cityId); layerState._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
} }
@@ -280,5 +369,10 @@ export function createRendering({
ensureCityPoints, ensureCityPoints,
removeCityPoints, removeCityPoints,
applyStatusToPoints, applyStatusToPoints,
stopHeightRefinement,
/** Stations still waiting for their ground height. */
pendingHeightCount: () => heightQueue.length,
/** Whether a refinement step is waiting on a timer. */
heightRefinementScheduled: () => heightTimer !== null,
}; };
} }
+23 -10
View File
@@ -1,5 +1,9 @@
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js'; import {
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js'; aircraftCallsignRule,
aircraftModelByCallsign,
aircraftModelSpec,
fleetModelSet,
} from '../../data/fleetModels.js';
/** /**
* What an aircraft draws with before the fleet model set has loaded: no * What an aircraft draws with before the fleet model set has loaded: no
@@ -17,7 +21,6 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
bellyM: 0, bellyM: 0,
elevationM: 0, elevationM: 0,
tinted: false, tinted: false,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: Object.freeze([0, 0, 0]), visualCenterNative: Object.freeze([0, 0, 0]),
trailAnchorNative: Object.freeze([0, 0, 0]), trailAnchorNative: Object.freeze([0, 0, 0]),
key: 'unloaded', key: 'unloaded',
@@ -25,12 +28,14 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
/** /**
* The fleet model, life-size scale and measured geometry an aircraft draws * The fleet model, life-size scale and measured geometry an aircraft draws
* with, chosen by its ICAO type and emitter category (`fleetModels.js`). * with, chosen by its ICAO type and emitter category, and by its callsign
* for the types that pick by callsign (`fleetModels.js`).
* Shared by the Flights, Military Flights and Local ADS-B layers so all three * Shared by the Flights, Military Flights and Local ADS-B layers so all three
* draw the same model for the same aircraft. `bellyM` is the model's scaled * draw the same model for the same aircraft. `bellyM` is the model's scaled
* clearance: how far a grounded aircraft's origin stands above the ground. * clearance: how far a grounded aircraft's origin stands above the ground.
* @param {{typeCode?: string|null, type?: string|null, * @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}|null|undefined} aircraft * category?: string|number|null,
* callsign?: string|null}|null|undefined} aircraft
* @param {object|null} [set] The loaded fleet model set. * @param {object|null} [set] The loaded fleet model set.
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before * @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
* the set has loaded. * the set has loaded.
@@ -40,17 +45,17 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
// Layers ask on every tick; each type and category is resolved once per set. // Layers ask on every tick; each type and category is resolved once per set.
let cache = specCaches.get(set); let cache = specCaches.get(set);
if (!cache) specCaches.set(set, (cache = new Map())); if (!cache) specCaches.set(set, (cache = new Map()));
const key = aircraftModelSpecCacheKey(aircraft); const key = aircraftModelSpecCacheKey(aircraft, set);
let layerSpec = cache.get(key); let layerSpec = cache.get(key);
if (!layerSpec) { if (!layerSpec) {
const spec = aircraftModelSpec(set, { const spec = aircraftModelSpec(set, {
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null, typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
category: aircraft?.category ?? null, category: aircraft?.category ?? null,
callsign: aircraft?.callsign ?? null,
}); });
layerSpec = Object.freeze({ layerSpec = Object.freeze({
...spec, ...spec,
bellyM: spec.clearanceM, bellyM: spec.clearanceM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
}); });
cache.set(key, layerSpec); cache.set(key, layerSpec);
} }
@@ -61,10 +66,18 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
const specCaches = new WeakMap(); const specCaches = new WeakMap();
/** /**
* Cache key of an aircraft's model spec: what selects its model. * Cache key of an aircraft's model spec: what selects its model. For a type
* that picks its model by callsign (an airport vehicle) the key adds the
* rule its callsign matches, or `type` for none, so such a type caches at
* most one entry per rule and one for its own model.
* @param {object|null|undefined} aircraft * @param {object|null|undefined} aircraft
* @param {object|null} [set] The loaded fleet model set.
* @returns {string} * @returns {string}
*/ */
export function aircraftModelSpecCacheKey(aircraft) { export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) {
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`; const type = aircraft?.typeCode ?? aircraft?.type ?? '';
const key = `${type}|${aircraft?.category ?? ''}`;
if (!set || !aircraftModelByCallsign(set.types, type)) return key;
const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign);
return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`;
} }
-4
View File
@@ -85,10 +85,6 @@ export const MODEL_ALL_KEEP_M = 450000;
export const MODEL_HEADING_OFFSET_DEG = 180; export const MODEL_HEADING_OFFSET_DEG = 180;
// every fleet model is nose -X: opposite Cesium heading 0 // every fleet model is nose -X: opposite Cesium heading 0
// Owner launch-polish direction: models should read as clean light silhouettes,
// with only a weak diffuse contribution from the models' own colours.
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
+62 -3
View File
@@ -5,6 +5,11 @@ import {
} from '../../data/renderAltitude.js'; } from '../../data/renderAltitude.js';
import { classifyAircraft } from '../../data/aircraftClass.js'; import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
aircraftCallsignRule,
aircraftModelSpec,
fleetModelSet,
} from '../../data/fleetModels.js';
import { import {
approxDistanceKm, approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM, GROUND_FLOOR_CLAMP_RADIUS_KM,
@@ -21,6 +26,7 @@ export class FlightRecords {
cachedGroundFloor, cachedGroundFloor,
floorAltitudeM, floorAltitudeM,
now = () => Date.now(), now = () => Date.now(),
fleetModels = fleetModelSet,
}) { }) {
this.services = { this.services = {
geoidHeight, geoidHeight,
@@ -29,6 +35,8 @@ export class FlightRecords {
}; };
/** The time records are judged at: the layer's clock. */ /** The time records are judged at: the layer's clock. */
this.now = now; this.now = now;
/** The loaded fleet model set, or null before it loads. */
this.fleetModels = fleetModels;
this.data = new Map(); this.data = new Map();
// Facts about an airframe, not a moment, kept across a reset. // Facts about an airframe, not a moment, kept across a reset.
this.airframes = new Map(); this.airframes = new Map();
@@ -92,7 +100,6 @@ export class FlightRecords {
verticalRateMps: vertical_rate, verticalRateMps: vertical_rate,
} = observation; } = observation;
const onGround = on_ground === true;
this.missingPolls.delete(icao24); this.missingPolls.delete(icao24);
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for // Sticky merge: OpenSky intermittently drops callsign/velocity/track for
// aircraft it still positions — hold last-known-good instead of // aircraft it still positions — hold last-known-good instead of
@@ -102,6 +109,27 @@ export class FlightRecords {
// The airframe's facts: this record's, or those kept across a reset. // The airframe's facts: this record's, or those kept across a reset.
const airframe = prevMeta ?? this.airframes.get(icao24); const airframe = prevMeta ?? this.airframes.get(icao24);
this.airframes.delete(icao24); this.airframes.delete(icao24);
// A ground vehicle or ground station (a feed's ZVEH or GRND: a model
// built standing, catalog category `other`) takes the feed's own type,
// which no airframe lookup knows; every other aircraft keeps waiting for
// its looked-up type. With no altitude at all it stands on the ground,
// where it is, instead of at the airborne default.
const groundModel = (typeCode) =>
drawnAsGroundModel(
this.fleetModels(),
typeCode,
category,
stickyText(callsign, prevMeta?.callsign),
);
const feedType = observation.typeCode || null;
const typeCode =
airframe?.typeCode ??
(feedType && groundModel(feedType) ? feedType : null);
const onGround =
on_ground === true ||
(!Number.isFinite(baro_alt) &&
!Number.isFinite(geo_alt) &&
groundModel(typeCode));
// Grounded planes with no baro reading sit at 0 m, not the 10 km // Grounded planes with no baro reading sit at 0 m, not the 10 km
// airborne default (a parked plane must never float). // airborne default (a parked plane must never float).
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
@@ -319,8 +347,9 @@ export class FlightRecords {
prevMeta?.lastContactEpochMs, prevMeta?.lastContactEpochMs,
null, null,
), ),
// adsbdb enrichment — written by the enrichment callbacks, carried across polls: // adsbdb enrichment — written by the enrichment callbacks, carried
typeCode: airframe?.typeCode ?? null, // across polls; a ground vehicle's or station's from the feed:
typeCode,
typeName: airframe?.typeName ?? null, typeName: airframe?.typeName ?? null,
registration: airframe?.registration ?? null, registration: airframe?.registration ?? null,
airline: routeHeld ? (airframe.airline ?? null) : null, airline: routeHeld ? (airframe.airline ?? null) : null,
@@ -408,3 +437,33 @@ export class FlightRecords {
this.geoidNCache.delete(id); this.geoidNCache.delete(id);
} }
} }
/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */
const groundModelCaches = new WeakMap();
/**
* Whether an aircraft of this type, emitter category and callsign is drawn
* with a model built standing on the ground (catalog category `other`: an
* airport vehicle, a ground station's mast). False before the set loads.
* Answered once per type, category and callsign rule matched.
* @param {object|null} set The loaded fleet model set.
* @param {string|null} typeCode
* @param {string|number|null} category
* @param {string|null} callsign
* @returns {boolean}
*/
function drawnAsGroundModel(set, typeCode, category, callsign) {
if (!set || !typeCode) return false;
let cache = groundModelCaches.get(set);
if (!cache) groundModelCaches.set(set, (cache = new Map()));
const rule = aircraftCallsignRule(set.types, typeCode, callsign);
const key = `${typeCode}|${category ?? ''}|${rule}`;
let ground = cache.get(key);
if (ground === undefined) {
ground =
aircraftModelSpec(set, { typeCode, category, callsign }).category ===
'other';
cache.set(key, ground);
}
return ground;
}
+60 -1
View File
@@ -1,13 +1,28 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlightRecords } from './records.js'; import { FlightRecords } from './records.js';
function records() { const FLEET = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../public/models/fleet',
);
const fleetSet = {
catalog: JSON.parse(
fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'),
),
types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')),
};
function records(fleetModels = () => null) {
return new FlightRecords({ return new FlightRecords({
geoidHeight: () => 40, geoidHeight: () => 40,
cachedGroundFloor: () => null, cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude, floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0, approxDistanceKm: () => 0,
fleetModels,
}); });
} }
const view = () => ({ const view = () => ({
@@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged
store.forget('abc123'); store.forget('abc123');
assert.equal(store.fixRepeated.has('abc123'), false); assert.equal(store.fixRepeated.has('abc123'), false);
}); });
test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => {
const store = records(() => fleetSet);
const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null };
const vehicle = store.receive(
observation({
id: 'v1',
typeCode: 'ZVEH',
callsign: 'FIRE 2',
...noAltitude,
}),
view(),
).meta;
assert.equal(vehicle.onGround, true);
assert.equal(vehicle.typeCode, 'ZVEH');
assert.equal(vehicle.altitude, 0);
const mast = store.receive(
observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }),
view(),
).meta;
assert.equal(mast.onGround, true);
assert.equal(mast.typeCode, 'GRND');
// An airliner with no altitude keeps the airborne default, and its feed
// type still waits for the airframe lookup.
const plane = store.receive(
observation({ id: 'p1', typeCode: 'A320', ...noAltitude }),
view(),
).meta;
assert.equal(plane.onGround, false);
assert.equal(plane.altitude, 10000);
assert.equal(plane.typeCode, null);
// A vehicle reporting an altitude is placed by it.
const reported = store.receive(
observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }),
view(),
).meta;
assert.equal(reported.onGround, false);
// Before the model set loads nothing is grounded by its model.
const unloaded = records().receive(
observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }),
view(),
).meta;
assert.equal(unloaded.onGround, false);
});
+9 -11
View File
@@ -45,7 +45,10 @@ import {
STALE_ALPHA, STALE_ALPHA,
displayDelayMs, displayDelayMs,
} from './policy.js'; } from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import {
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
aircraftModelBlend,
} from '../../data/aircraftModelLighting.js';
export function createRendering({ export function createRendering({
flightState, flightState,
@@ -192,11 +195,12 @@ export function createRendering({
* @returns {void} * @returns {void}
*/ */
/** Model tint, mirroring the billboard color rules. */ /** Model colour: the mesh's own (white), tinted cyan while tracked and red
* while broadcasting an emergency. */
function _modelColor(icao24) { function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
return _fleetBillboardColor(icao24); return _isEmergency(icao24) ? EMERGENCY_TINT : Cesium.Color.WHITE;
} }
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -526,10 +530,7 @@ export function createRendering({
minimumPixelSize: MODEL_MIN_PX, minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale, scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX, ...aircraftModelBlend(flightState._irBoost),
// Launch presentation keeps the code-side tint dominant for every approved
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
customShader: flightState._irBoost customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER ? flightState._IR_UNLIT_SHADER
: undefined, : undefined,
@@ -748,10 +749,7 @@ export function createRendering({
minimumPixelSize: TRACKED_MODEL_MIN_PX, minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale, scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
colorBlendMode: Cesium.ColorBlendMode.MIX, ...aircraftModelBlend(flightState._irBoost),
// The tracked aircraft uses the same dominant light tint as the fleet;
// IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
customShader: flightState._irBoost customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER ? flightState._IR_UNLIT_SHADER
: undefined, : undefined,
-1
View File
@@ -700,7 +700,6 @@ export function createLocalAdsbLayer({
if (viewer.scene?.primitives) { if (viewer.scene?.primitives) {
models = createLocalAdsbModels({ models = createLocalAdsbModels({
viewer, viewer,
color,
resolveAsset, resolveAsset,
groundSnap: services.groundSnap?.createGroundSnap?.() || null, groundSnap: services.groundSnap?.createGroundSnap?.() || null,
...(loadModel ? { loadModel } : {}), ...(loadModel ? { loadModel } : {}),
+4 -7
View File
@@ -503,7 +503,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o
assert.equal(queries.length, 2, 'each key is asked for once'); assert.equal(queries.length, 2, 'each key is asked for once');
}); });
test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mesh colours', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 }; const clock = { now: 100_000 };
const loads = []; const loads = [];
@@ -548,13 +548,10 @@ test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t
assert.equal(model.scale, spec.scale, 'drawn life size'); assert.equal(model.scale, spec.scale, 'drawn life size');
assert.equal(model.id, 'local-adsb:a0b702'); assert.equal(model.id, 'local-adsb:a0b702');
assert.ok( assert.ok(
Cesium.Color.equals( Cesium.Color.equals(model.color, Cesium.Color.WHITE),
model.color, 'the mesh draws its own colours',
Cesium.Color.fromCssColorString('#ff4fd8'),
),
); );
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.HIGHLIGHT);
assert.equal(model.colorBlendAmount, 0.94);
assert.equal(model.show, true); assert.equal(model.show, true);
const heli = sources[0].entities.getById('local-adsb:a0b702'); const heli = sources[0].entities.getById('local-adsb:a0b702');
assert.equal( assert.equal(
+9 -8
View File
@@ -16,12 +16,16 @@ import {
MODEL_PROX_ADD_M, MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M, MODEL_PROX_KEEP_M,
} from '../flights/policy.js'; } from '../flights/policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import {
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
aircraftModelBlend,
} from '../../data/aircraftModelLighting.js';
const specOf = (marker) => const specOf = (marker) =>
aircraftLayerModelSpec({ aircraftLayerModelSpec({
typeCode: marker?.typeCode, typeCode: marker?.typeCode,
category: marker?.category, category: marker?.category,
callsign: marker?.record?.callsign,
}); });
/** /**
@@ -30,15 +34,14 @@ const specOf = (marker) =>
* Mirrors the public Flights fleet: models only below the altitude ceiling, * Mirrors the public Flights fleet: models only below the altitude ceiling,
* the same proximity/all caps and add/keep radii, the same visible-first * the same proximity/all caps and add/keep radii, the same visible-first
* eligibility (`selectModelEligible`), the same fleet model per type, life * eligibility (`selectModelEligible`), the same fleet model per type, life
* size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here * size (`aircraftLayerModelSpec`) and the same colours: the mesh's own
* magenta, as Flights tints military amber). Each model owns its matrix. A * (`aircraftModelBlend`). Each model owns its matrix. A
* billboard stays the visual until its model is loaded AND placed; a grounded * billboard stays the visual until its model is loaded AND placed; a grounded
* aircraft's model rides the one-shot ground snap plus the class belly * aircraft's model rides the one-shot ground snap plus the class belly
* offset, and without a resolved ground it stays a billboard. Every fleet * offset, and without a resolved ground it stays a billboard. Every fleet
* model loads once, up front, when the layer starts (`loadFleetModelSet`). * model loads once, up front, when the layer starts (`loadFleetModelSet`).
* @param {object} options * @param {object} options
* @param {object} options.viewer Cesium viewer. * @param {object} options.viewer Cesium viewer.
* @param {Cesium.Color} options.color Model tint.
* @param {(url: string) => string} options.resolveAsset * @param {(url: string) => string} options.resolveAsset
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance. * @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
* @param {(options: object) => Promise<object>} [options.loadModel] * @param {(options: object) => Promise<object>} [options.loadModel]
@@ -46,7 +49,6 @@ const specOf = (marker) =>
*/ */
export function createLocalAdsbModels({ export function createLocalAdsbModels({
viewer, viewer,
color,
resolveAsset, resolveAsset,
groundSnap = null, groundSnap = null,
loadModel = (options) => Cesium.Model.fromGltfAsync(options), loadModel = (options) => Cesium.Model.fromGltfAsync(options),
@@ -103,9 +105,8 @@ export function createLocalAdsbModels({
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX, minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale, scale: spec.scale,
color, color: Cesium.Color.WHITE,
colorBlendMode: Cesium.ColorBlendMode.MIX, ...aircraftModelBlend(false),
colorBlendAmount: spec.blendAmount,
id, id,
}, },
{ resolveAsset, load: loadModel }, { resolveAsset, load: loadModel },
-4
View File
@@ -107,10 +107,6 @@ export const MODEL_ALL_KEEP_M = 450000;
export const MODEL_HEADING_OFFSET_DEG = 180; export const MODEL_HEADING_OFFSET_DEG = 180;
// every fleet model is nose -X: opposite Cesium heading 0 // every fleet model is nose -X: opposite Cesium heading 0
// Preserve the layer's amber identity while reducing the models' own colours
// to a weak diffuse hint, matching civilian launch presentation.
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
+12 -11
View File
@@ -34,7 +34,6 @@ import {
MODEL_PROX_KEEP_M, MODEL_PROX_KEEP_M,
IR_RELOAD_BATCH, IR_RELOAD_BATCH,
MODEL_MIN_PX, MODEL_MIN_PX,
MODEL_COLOR_BLEND_AMOUNT,
TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX, TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS, FLEET_DR_INTERVAL_MS,
@@ -43,7 +42,10 @@ import {
COURSE_MAX_DPS, COURSE_MAX_DPS,
STALE_ALPHA, STALE_ALPHA,
} from './policy.js'; } from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import {
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
aircraftModelBlend,
} from '../../data/aircraftModelLighting.js';
export function createRendering({ export function createRendering({
flightState, flightState,
@@ -81,7 +83,6 @@ export function createRendering({
const base = aircraftLayerModelSpec(meta); const base = aircraftLayerModelSpec(meta);
spec = Object.freeze({ spec = Object.freeze({
...base, ...base,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
}); });
// Not cached before the model set loads: the answer changes once it has. // Not cached before the model set loads: the answer changes once it has.
@@ -152,11 +153,15 @@ export function createRendering({
const _iconKind = (icao24, klass) => const _iconKind = (icao24, klass) =>
tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ /** Model colour: the mesh's own (white), tinted amber while tracked and red
* while broadcasting an emergency. */
function _modelColor(icao24) { function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
return _fleetBillboardColor(icao24); const info = flightState.records.data.get(icao24);
return isEmergency(info?.emergency)
? EMERGENCY_ICON_COLOR
: Cesium.Color.WHITE;
} }
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency, /** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
@@ -443,9 +448,7 @@ export function createRendering({
minimumPixelSize: MODEL_MIN_PX, minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale, scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX, ...aircraftModelBlend(flightState._irBoost),
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
customShader: flightState._irBoost customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER ? flightState._IR_UNLIT_SHADER
: undefined, : undefined,
@@ -662,9 +665,7 @@ export function createRendering({
minimumPixelSize: TRACKED_MODEL_MIN_PX, minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale, scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
colorBlendMode: Cesium.ColorBlendMode.MIX, ...aircraftModelBlend(flightState._irBoost),
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
customShader: flightState._irBoost customShader: flightState._irBoost
? flightState._IR_UNLIT_SHADER ? flightState._IR_UNLIT_SHADER
: undefined, : undefined,
+340
View File
@@ -0,0 +1,340 @@
import { pointAtS } from '../../data/railPaths.js';
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
import { displayMotion } from '../transit/movement.js';
/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */
const lines = new WeakMap();
/**
* A line as a snapshot hands it out: a frozen copy of the layer's path, its
* number arrays frozen arrays, built once per line. `pointAtS` and
* `projectOnPath` read it as they read the path, and nothing written to it
* reaches the layer.
*/
function lineOf(path) {
let line = lines.get(path);
if (!line) {
const copy = {};
for (const [name, value] of Object.entries(path))
copy[name] = ArrayBuffer.isView(value)
? Object.freeze(Array.from(value))
: value;
line = Object.freeze(copy);
lines.set(path, line);
}
return line;
}
const NO_STOPS = Object.freeze([]);
/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */
const stopLists = new WeakMap();
/** Whether `copy` still holds each of `list`'s stops, field by field. */
function sameStops(copy, list) {
if (copy.length !== list.length) return false;
for (let i = 0; i < list.length; i++) {
const stop = list[i];
const kept = copy[i];
if (
!stop ||
typeof stop !== 'object' ||
!kept ||
typeof kept !== 'object'
) {
if (!Object.is(stop, kept)) return false;
continue;
}
const names = Object.keys(stop);
if (names.length !== Object.keys(kept).length) return false;
for (const name of names)
if (!Object.is(stop[name], kept[name])) return false;
}
return true;
}
/**
* A trip's stops as a snapshot hands them out: a frozen array of frozen
* copies, kept per list the stops provider returns while that list still
* holds the same stops, so a provider that updates its list in place is
* copied again.
*/
function stopsOf(list) {
if (!Array.isArray(list)) return NO_STOPS;
let stops = stopLists.get(list);
if (!stops || !sameStops(stops, list)) {
stops = Object.freeze(
list.map((stop) =>
stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop,
),
);
stopLists.set(list, stops);
}
return stops;
}
/**
* `previous` when it holds each of `next`'s values, else a frozen copy of
* `next`: a part of a snapshot that did not change is the one read before.
* `next` is a draft, filled again on every read.
*/
function reuse(previous, next) {
if (!previous) return Object.freeze({ ...next });
for (const name in next)
if (!Object.is(previous[name], next[name]))
return Object.freeze({ ...next });
return previous;
}
/**
* Read access to one train, and a hold on it for a first-person view.
*
* `getSelectedTrain()` and `getCockpitSubject()` describe the selected train
* and the held one as a frozen snapshot, read again on each call: where it is
* along its line, how fast it moves and how that is known, and its stops. A
* call while nothing in it changed returns the snapshot it returned before,
* and a part that did not change (its feed, its line, its stops) is the same
* object in the next one, so a view can read it on every frame. `path.line`
* is a frozen copy of the train's line for `pointAtS` and `projectOnPath`.
* `setCockpitSubject(key)` holds a train: its marker, 3D model, card and
* selected line are hidden, it stays selected (so its stops stay fresh and its feed stays
* polled at any distance) and clicks and Escape no longer change the
* selection. A held train keeps moving, hidden, and keeps the selection when
* its category is turned off, and is the copy of its train drawn though
* another feed's copy would outrank it (dedupe.js). Released, it is a train
* like any other: one in a category turned off is deselected and stops, and
* a better copy takes over at the next poll. The hold ends with
* `setCockpitSubject(null)`, and on its own when the train is removed or the
* layer is turned off. A train removed while held leaves its feed polled
* until the next `setCockpitSubject` call, so a view that waits for it holds
* it again if the feed lists it again.
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
* A snapshot's `selectedAtMs` is when the user last selected the selected
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
* re-selection. It is null for any other train, after a deselection, and for
* a train selected only by a hold: a hold is not a user's selection.
*
* @param {object} context The layer's state, parts, services and timetable.
* @returns {{methods: object}}
*/
export function createRailCockpitSubject({
state,
parts,
services,
timetable,
}) {
const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M)
? services.ground.GROUND_FLOOR_LIFT_M
: 0;
const finiteOrNull = (value) =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const scratch = {};
/** Each train's last snapshot, returned again while nothing in it changed. */
const views = new WeakMap();
/** The drafts trainView fills on every read (reuse). */
const draft = {
view: {},
feed: {},
position: {},
path: {},
motion: {},
provenance: {},
};
/** The catalog's public entry for a feed, else the registry's. */
function feedOf(feedId, previous) {
const listed = state._catalog?.find((feed) => feed.id === feedId);
const feed = listed ?? getRegisteredRailFeed(feedId);
if (!feed) return null;
const next = draft.feed;
next.id = feed.id;
next.name = feed.name ?? null;
next.operator = feed.operator ?? null;
next.region = feed.region ?? null;
next.positions = listed?.positions ?? feed.positions ?? 'gps';
next.tripUpdates =
typeof listed?.tripUpdates === 'boolean'
? listed.tripUpdates
: typeof feed.tripUpdatesUrl === 'string';
next.attribution = feed.attribution ?? null;
next.license = feed.license ?? null;
next.licenseUrl = feed.licenseUrl ?? null;
return reuse(previous, next);
}
/** The route's short (else long) name in the feed's timetable, or null. */
function routeNameOf(entry) {
const routeId = entry.record?.routeId;
if (!routeId) return null;
const schedule = timetable?.scheduleFor?.(entry.feedId);
const route = schedule?.routes?.[routeId];
const name = route?.short || route?.long;
return typeof name === 'string' && name ? name : null;
}
/**
* Which way the train runs along its shape: as its binding learnt it from
* successive reports, else from its drawn course against the line's
* bearing there; 0 while neither tells.
*/
function directionOf(entry) {
const binding = entry.path;
if (binding.direction === 1 || binding.direction === -1)
return binding.direction;
const course = finiteOrNull(entry.courseDeg);
const point = pointAtS(binding.path, binding.drawS, scratch);
if (course === null || !Number.isFinite(point?.bearingDeg)) return 0;
const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180);
return turn > 90 ? -1 : 1;
}
/** The train as a frozen snapshot (see the module comment), or null. */
function trainView(entry) {
if (!entry?.record) return null;
const previous = views.get(entry);
const record = entry.record;
const sample = entry.sample ?? null;
const drawn = entry.drawnHeight ?? null;
const binding = entry.path ?? null;
const reported = finiteOrNull(record.speedMps);
const derived = finiteOrNull(sample?.segmentSpeedMps);
const trip = state._selectedKey === entry.key ? state._trip : null;
const status = state._feedStatus.get(entry.feedId);
const position = draft.position;
position.latitude =
finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat;
position.longitude =
finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon;
position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null;
position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0;
position.atMs = finiteOrNull(drawn?.at);
let path = null;
if (binding && binding.path) {
path = draft.path;
path.shapeIndex = binding.shapeIndex;
path.s = binding.drawS;
path.direction = directionOf(entry);
path.lengthM = binding.path.length;
path.via = binding.via;
path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null;
path.line = lineOf(binding.path);
}
const motion = draft.motion;
motion.reportedSpeedMps = reported;
motion.derivedSpeedMps = derived;
motion.speedSource =
reported !== null ? 'reported' : derived !== null ? 'derived' : null;
motion.courseDeg = finiteOrNull(entry.courseDeg);
motion.phase = sample?.phase ?? null;
motion.word = displayMotion(entry, Date.now()).word;
motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs);
motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs);
motion.feedStale = status?.stale === true;
const provenance = draft.provenance;
provenance.positionSource = record.positionSource ?? 'gps';
provenance.timestampSource = record.timestampSource ?? null;
const view = draft.view;
view.key = entry.key;
view.feedId = entry.feedId;
view.selectedAtMs =
state._selectedKey === entry.key ? state._selectedAtMs : null;
view.id = String(record.id ?? '');
view.tripId = record.tripId || null;
view.routeId = record.routeId || null;
view.routeName = routeNameOf(entry);
view.label = record.label || null;
view.trainNumber = record.trainNumber || null;
view.headsign = trip?.headsign || null;
view.category = entry.category ?? null;
view.mode = entry.railMode ?? null;
view.feed = feedOf(entry.feedId, previous?.feed);
view.position = reuse(previous?.position, position);
view.path = path && reuse(previous?.path, path);
view.motion = reuse(previous?.motion, motion);
view.provenance = reuse(previous?.provenance, provenance);
view.stops = stopsOf(trip?.stops);
view.match = trip?.match ?? null;
const snapshot = reuse(previous, view);
views.set(entry, snapshot);
return snapshot;
}
/** A train's marker and playback again, after a hold on it began or ended. */
function replay(entry) {
parts.rendering.paint(entry);
parts.rendering.schedulePlayback(entry);
parts.rendering.syncRenderHold();
}
const methods = {
/** The selected train (see createRailCockpitSubject), or null. */
getSelectedTrain() {
return trainView(state._trains.get(state._selectedKey));
},
/** The held train, or null when none is held. */
getCockpitSubject() {
return trainView(state._trains.get(state._cockpitSubjectKey));
},
/**
* Hold the train `key` for a first-person view, or release the hold with
* null. A train it holds is selected, if it was not already.
* @param {string|null} key
* @returns {boolean} False for a key the layer does not list. A listed
* train is held even when its category is off or another feed's copy
* is drawn: the held copy is then the one drawn. Releasing returns true.
*/
setCockpitSubject(key) {
const previous = state._trains.get(state._cockpitSubjectKey);
if (key === null || key === undefined) {
state._cockpitSubjectKey = null;
state._cockpitLostFeedId = null;
if (previous) {
// Its category turned off meanwhile: deselected, as the toggle
// does to any other train (controls.js).
if (
previous.key === state._selectedKey &&
state._categories[previous.category] === false
)
parts.selection.clearSelection();
replay(previous);
}
parts.selection.refreshSelectedCard(true);
parts.routes.applyHold?.();
return true;
}
const entry = state._trains.get(key);
if (!entry) return false;
if (state._selectedKey !== key) {
parts.selection.selectTrain(key);
// A hold is not a selection the user made (selectedAtMs).
state._selectedAtMs = null;
}
state._cockpitSubjectKey = key;
state._cockpitLostFeedId = null;
// Another feed's copy drew it: the held copy is drawn now (dedupe.js).
if (entry.duplicate === true) parts.ingestion.duplicates?.refresh();
if (previous && previous !== entry) replay(previous);
replay(entry);
parts.selection.refreshSelectedCard(true);
parts.routes.applyHold?.();
return true;
},
/**
* Select the train `key` and hand it the camera, as a click on it does.
* @param {string} key
* @returns {boolean} False for a train the layer does not show: gone, in
* a category turned off, or a copy another feed draws (dedupe.js).
*/
focusTrain(key) {
const entry = state._trains.get(key);
if (!entry || !parts.rendering.categoryShown(entry)) return false;
parts.selection.selectAndFocusTrain(key);
return true;
},
};
return { methods, trainView };
}
+504
View File
@@ -0,0 +1,504 @@
// The Rail layer's read API for a first-person view: the selected or held
// train as a snapshot (where it is along its line and which way it runs, its
// speed and how that is known, its feed's credit, its stops), and a hold that
// hides its marker and card, keeps it selected and ends when it goes.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
RAIL_POLL_MS,
STOP_REFRESH_MS,
railTrainFollowable,
railTrainKey,
} from './policy.js';
import {
BOSTON,
railLayerHarness as harness,
railTrain as train,
} from '../../testSupport/railLayerHarness.mjs';
import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { publicRailCatalog } from '../../data/railFeeds.js';
import { pointAtS } from '../../data/railPaths.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
const M_PER_DEG = 111_320;
const LAT = 42.36;
const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180);
const LON0 = -71.08;
/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */
function eastLine(lengthM) {
const out = [];
for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX);
return out;
}
const PACK = Object.freeze({
version: 1,
feedId: 'mbta-rail',
builtAt: '2026-09-09T00:00:00.000Z',
shapes: [eastLine(6000)],
dist: [[]],
shapeSources: ['operator'],
trips: { 'trip-T1': 0, 'trip-T2': 0 },
routes: { R1: [0] },
});
/** Two trains on the line, T1 running east and T2 west, a step a poll. */
async function onTheLine(t, options = {}) {
const app = harness(t, {
paths: { 'mbta-rail': PACK },
at: BOSTON,
...options,
});
let step = 0;
app.feeds.set('mbta-rail', () => [
train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }),
train('T2', LAT, LON0 + (5000 - step * 150) / KX),
]);
await app.enable();
for (let i = 0; i < 4; i += 1) {
step += 1;
app.run(RAIL_POLL_MS);
await app.poll();
}
return app;
}
const T1 = railTrainKey('mbta-rail', 'T1');
const T2 = railTrainKey('mbta-rail', 'T2');
test('the selected train reads as a snapshot of its place, motion and sources', async (t) => {
const app = await onTheLine(t);
assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected');
app.parts().selection.selectTrain(T1);
const view = app.layer.getSelectedTrain();
assert.ok(Object.isFrozen(view));
assert.equal(view.key, T1);
assert.equal(view.feedId, 'mbta-rail');
assert.equal(view.id, 'T1');
assert.equal(view.tripId, 'trip-T1');
assert.equal(view.routeId, 'R1');
assert.equal(view.category, 'regional');
assert.equal(view.mode, 'rail');
const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail');
assert.equal(view.feed.attribution, listed.attribution);
assert.equal(view.feed.license, listed.license);
assert.equal(view.feed.licenseUrl, listed.licenseUrl);
assert.equal(view.feed.positions, 'gps');
assert.equal(view.path.shapeIndex, 0);
assert.equal(view.path.builtAt, PACK.builtAt);
assert.equal(view.path.via, 'trip');
assert.ok(Math.abs(view.path.lengthM - 6000) < 1);
assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`);
assert.equal(view.path.direction, 1, 'runs along the shape');
assert.equal(typeof view.path.line.length, 'number');
assert.equal(view.motion.reportedSpeedMps, 15);
assert.equal(view.motion.speedSource, 'reported');
assert.equal(view.provenance.positionSource, 'gps');
assert.equal(view.provenance.timestampSource, 'vehicle');
const drawn = app.entry(T1).drawnHeight;
assert.equal(view.position.railHeightM, drawn.h - 1.5);
assert.ok(Number.isFinite(view.position.latitude));
assert.ok(Array.isArray(view.stops));
app.parts().selection.selectTrain(T2);
const west = app.layer.getSelectedTrain();
assert.equal(west.path.direction, -1, 'runs against the shape');
assert.equal(west.motion.reportedSpeedMps, null);
assert.equal(
west.motion.speedSource,
west.motion.derivedSpeedMps === null ? null : 'derived',
);
});
/** A trip's stops as a stops provider lists them. */
const stopList = (...names) =>
names.map((name, i) => ({
stopId: name,
name,
lat: LAT,
lon: LON0 + (i * 2000) / KX,
state: 'upcoming',
}));
test('a snapshot read again while nothing changed is the one read before', async (t) => {
let list = stopList('Alpha', 'Bravo', 'Charlie');
const app = await onTheLine(t, {
stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) },
});
app.parts().selection.selectTrain(T1);
const first = app.layer.getSelectedTrain();
assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot');
// Its stops are frozen copies of the provider's.
assert.equal(first.stops.length, 3);
assert.ok(Object.isFrozen(first.stops));
assert.ok(Object.isFrozen(first.stops[0]));
assert.notEqual(first.stops[0], list[0]);
assert.equal(first.stops[0].name, 'Alpha');
// It moves on: a new snapshot at its new place, what did not change kept.
app.run(RAIL_POLL_MS);
await app.poll();
app.run(RAIL_POLL_MS);
const moved = app.layer.getSelectedTrain();
assert.notEqual(moved, first, 'a new snapshot');
assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path));
assert.ok(moved.path.s > first.path.s, 'at its new place');
assert.equal(moved.stops, first.stops, 'the same stops');
assert.equal(moved.feed, first.feed);
assert.equal(moved.provenance, first.provenance);
assert.equal(moved.path.line, first.path.line);
assert.equal(app.layer.getSelectedTrain(), moved);
// The provider lists other stops: new ones.
list = stopList('Delta', 'Echo');
app.run(STOP_REFRESH_MS);
const later = app.layer.getSelectedTrain();
assert.notEqual(later.stops, first.stops);
assert.deepEqual(
later.stops.map((stop) => stop.name),
['Delta', 'Echo'],
);
// A provider that updates its list in place: the stops are copied again.
list[0].state = 'passed';
app.run(STOP_REFRESH_MS);
const updated = app.layer.getSelectedTrain();
assert.notEqual(updated.stops, later.stops);
assert.equal(updated.stops[0].state, 'passed');
assert.equal(app.layer.getSelectedTrain().stops, updated.stops);
});
test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => {
const app = await onTheLine(t);
app.parts().selection.selectTrain(T1);
const { line, s } = app.layer.getSelectedTrain().path;
const own = app.entry(T1).path.path;
assert.notEqual(line, own, 'not the layer’s own');
assert.ok(Object.isFrozen(line));
for (const name of ['lat', 'lon', 'x', 'y', 's', 'box'])
assert.ok(Object.isFrozen(line[name]), name);
const lat = own.lat[1];
assert.throws(() => {
line.lat[1] = 0;
}, TypeError);
assert.equal(own.lat[1], lat, 'the layer’s line is as it was');
assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same');
// Copied once per line: T2 runs on the same one.
app.parts().selection.selectTrain(T2);
assert.equal(app.layer.getSelectedTrain().path.line, line);
});
test('a held train hides its marker and card and keeps the selection', async (t) => {
const app = await onTheLine(t);
assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false);
assert.equal(app.layer.getCockpitSubject(), null);
assert.equal(app.layer.setCockpitSubject(T1), true);
assert.equal(app.state()._selectedKey, T1, 'holding selects it');
assert.equal(app.layer.getCockpitSubject().key, T1);
app.run(1000);
assert.equal(app.entry(T1).marker.show, false);
assert.equal(app.entry(T2).marker.show, true);
// From inside the train its selected line would run under the eye.
assert.equal(app.state()._route.primitive.show, false, 'no line');
assert.deepEqual(
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
[],
'no card',
);
// Escape is the view's while it holds the train.
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, T1);
// It still moves.
const before = app.layer.getCockpitSubject().path.s;
app.run(RAIL_POLL_MS);
await app.poll();
app.run(RAIL_POLL_MS);
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.layer.getCockpitSubject(), null);
app.run(1000);
assert.equal(app.entry(T1).marker.show, true);
assert.equal(app.state()._route.primitive.show, true, 'the line is back');
assert.equal(
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
1,
'the card is back',
);
});
test('a held train draws no 3D model either; let go, its model draws again', async (t) => {
const models = await fleetModelFakes(t);
const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 });
// A model's load answers off the frame; the next frames place it.
const frames = async () => {
app.run(500);
await app.settle();
app.run(500);
};
await frames();
assert.equal(models.drawn(T1).length, 1, 'near the camera: its model');
assert.equal(app.entry(T1).modelOwnsVisual, true);
assert.equal(app.entry(T1).marker.show, false, 'its icon hands over');
assert.equal(app.layer.setCockpitSubject(T1), true);
app.run(250);
// The view's eye is inside the train.
assert.equal(models.drawn(T1).length, 0, 'no model');
assert.equal(app.entry(T1).modelOwnsVisual, false);
assert.equal(app.entry(T1).marker.show, false, 'and no icon');
assert.equal(models.drawn(T2).length, 1, 'another train keeps its model');
await frames();
assert.equal(models.drawn(T1).length, 0, 'not on a later frame either');
assert.equal(app.entry(T1).marker.show, false);
assert.equal(app.layer.setCockpitSubject(null), true);
await frames();
assert.equal(models.drawn(T1).length, 1, 'let go: its model again');
assert.equal(app.entry(T1).modelOwnsVisual, true);
assert.equal(app.entry(T1).marker.show, false);
});
const MADRID = { lat: 40.4168, lon: -3.7038 };
test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => {
const app = harness(t, { at: MADRID });
// One Alvia in two feeds: a station-event copy, held, and a poll later a
// GPS copy that would outrank it.
let step = 0;
let gpsCopy = false;
const lat = (offset) => MADRID.lat + offset + step * 0.003;
app.feeds.set('renfe-cercanias', () => [
train('A1', lat(0), MADRID.lon, {
label: 'ALVIA 4073',
positionSource: 'event',
}),
]);
app.feeds.set('renfe-larga-distancia', () =>
gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [],
);
await app.enable();
const a = railTrainKey('renfe-cercanias', 'A1');
const b = railTrainKey('renfe-larga-distancia', 'B1');
for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) {
step += 1;
app.run(RAIL_POLL_MS);
await app.poll();
}
assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving');
assert.equal(app.layer.setCockpitSubject(a), true);
gpsCopy = true;
step += 1;
app.run(RAIL_POLL_MS);
await app.poll();
// The view knows the train by its key, and the copies stand apart: the
// held copy stays drawn and selected, the other is the one hidden.
assert.equal(app.layer.getCockpitSubject()?.key, a);
assert.equal(app.state()._selectedKey, a);
assert.equal(app.entry(a).duplicate, false);
assert.equal(app.entry(b).duplicate, true);
assert.equal(app.entry(a).marker.show, false);
assert.equal(app.entry(b).marker.show, false, 'no other marker on it');
assert.deepEqual(
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
[],
'no card',
);
// It moves between polls, frame by frame.
assert.ok(app.state()._moving.has(app.entry(a)), 'still playing');
const before = app.layer.getCockpitSubject().position.latitude;
app.run(2000);
assert.ok(
app.layer.getCockpitSubject().position.latitude > before,
'still moving',
);
// Let go: the better copy takes over at the next poll, with the selection.
app.layer.setCockpitSubject(null);
step += 1;
app.run(RAIL_POLL_MS);
await app.poll();
assert.equal(app.entry(a).duplicate, true);
assert.equal(app.entry(b).duplicate, false);
assert.equal(app.state()._selectedKey, b);
assert.equal(app.entry(b).marker.show, true);
assert.equal(
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
1,
'its card',
);
// The copy hidden is not focused: the selection stays on the one drawn.
assert.equal(app.layer.focusTrain(a), false);
assert.equal(app.state()._selectedKey, b);
// A hold of the copy hidden draws it at once, in place of the other.
assert.equal(app.layer.setCockpitSubject(a), true);
assert.equal(app.state()._selectedKey, a);
assert.equal(app.entry(a).duplicate, false);
assert.equal(app.entry(b).duplicate, true);
assert.equal(app.entry(b).marker.show, false);
assert.ok(app.state()._moving.has(app.entry(a)), 'playing');
});
test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
app.run(1000);
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing');
app.layer.setParams({ regional: false });
assert.equal(app.state()._selectedKey, T1, 'still selected');
assert.equal(app.layer.getCockpitSubject()?.key, T1);
assert.equal(app.entry(T1).marker.show, false);
assert.equal(app.entry(T2).marker.show, false, 'its category is hidden');
assert.equal(app.state()._route.primitive.show, false, 'no line');
assert.deepEqual(
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
[],
'no card',
);
// It moves between polls, frame by frame.
assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing');
const before = app.layer.getCockpitSubject().path.s;
app.run(2000);
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
assert.equal(app.entry(T1).marker.show, false, 'and still hidden');
// Let go: a train of a hidden category, deselected and stopped as the
// toggle would have left it.
app.layer.setCockpitSubject(null);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._moving.has(app.entry(T1)), false);
assert.equal(app.entry(T1).marker.show, false);
const parked = app.entry(T1).path.drawS;
app.run(2000);
assert.equal(app.entry(T1).path.drawS, parked, 'stopped');
// Held again, hidden, it plays at once; let go, it stops again.
assert.equal(app.layer.setCockpitSubject(T1), true);
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once');
app.layer.setCockpitSubject(null);
assert.equal(app.state()._moving.has(app.entry(T1)), false);
// Shown again, it is drawn and moves as any other.
app.layer.setParams({ regional: true });
assert.equal(app.entry(T1).marker.show, true);
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again');
});
test('a ride let go with restore does not focus a train its category hides', async (t) => {
const app = await onTheLine(t);
// The documented provider: hold on entry; on exit, release, then focus
// the train again when asked to restore.
assert.equal(app.layer.setCockpitSubject(T1), true);
app.layer.setParams({ regional: false });
app.layer.setCockpitSubject(null);
assert.equal(app.layer.focusTrain(T1), false, 'not drawn');
assert.equal(app.state()._selectedKey, null, 'not selected');
assert.equal(app.parts().follow.stats(), null, 'not followed');
assert.equal(app.viewer.trackedEntity, undefined);
assert.equal(app.focusRequests.length, 0, 'not flown to');
assert.equal(app.entry(T1).marker.show, false);
// Shown again, it focuses as before.
app.layer.setParams({ regional: true });
assert.equal(app.layer.focusTrain(T1), true);
assert.equal(app.state()._selectedKey, T1);
assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it');
});
test('a held train that goes takes the hold with it; focusTrain selects', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]);
for (let i = 0; i < 3; i += 1) {
app.run(RAIL_POLL_MS);
await app.poll();
}
assert.equal(app.entry(T1), undefined, 'the train is gone');
assert.equal(app.layer.getCockpitSubject(), null);
assert.equal(app.state()._cockpitSubjectKey, null);
assert.equal(app.layer.focusTrain(T1), false);
assert.equal(app.layer.focusTrain(T2), true);
assert.equal(app.state()._selectedKey, T2);
});
test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
// From the cab the camera looks along the ground, here past the feed's range.
await app.moveCamera({ lat: 40.71, lon: -74.0 });
assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train');
const others = () => [train('T2', LAT, LON0 + 4000 / KX)];
app.feeds.set('mbta-rail', others);
for (let i = 0; i < 3; i += 1) {
app.run(RAIL_POLL_MS);
await app.poll();
}
await app.recheck();
assert.equal(app.entry(T1), undefined, 'the train is gone');
assert.equal(app.layer.getCockpitSubject(), null);
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled');
await app.moveCamera({ lat: 40.72, lon: -74.0 });
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera');
// Listed again: the view holds it again.
app.feeds.set('mbta-rail', () => [
train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }),
...others(),
]);
app.run(RAIL_POLL_MS);
await app.poll();
assert.equal(app.layer.setCockpitSubject(T1), true);
assert.equal(app.state()._cockpitLostFeedId, null);
assert.equal(app.state()._selectedKey, T1);
// Let go, and the feed is the viewport's again: out of range, released.
app.parts().ingestion.removeTrain(T1);
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
app.layer.setCockpitSubject(null);
await app.moveCamera({ lat: 40.71, lon: -74.0 });
assert.equal(app.state()._activeFeeds.has('mbta-rail'), false);
assert.equal(app.state()._trains.size, 0);
});
test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => {
const app = await onTheLine(t);
assert.equal(app.layer.focusTrain(T1), true);
const first = app.layer.getSelectedTrain().selectedAtMs;
assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it');
// Selected again: a later stamp.
app.run(1000);
app.layer.focusTrain(T1);
assert.ok(app.layer.getSelectedTrain().selectedAtMs > first);
// A hold of another train is not the user's selection.
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().key, T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
app.layer.setCockpitSubject(null);
// Nor after a deselection (Escape), then a hold.
app.layer.focusTrain(T1);
globalThis.document.dispatchEvent(
Object.assign(new Event('keydown'), { key: 'Escape' }),
);
assert.equal(app.state()._selectedKey, null);
assert.equal(app.state()._selectedAtMs, null, 'deselection clears it');
app.layer.setCockpitSubject(T2);
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
// Releasing twice is harmless: the selection stays.
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.state()._selectedKey, T2);
assert.equal(app.layer.getCockpitSubject(), null);
});
test('turning the layer off ends the hold', async (t) => {
const app = await onTheLine(t);
app.layer.setCockpitSubject(T1);
app.parts().ingestion.removeTrain(T1);
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
app.layer.disable(app.viewer);
assert.equal(app.state()._cockpitSubjectKey, null);
assert.equal(app.state()._cockpitLostFeedId, null);
assert.equal(app.layer.getCockpitSubject(), null);
});
+7 -1
View File
@@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) {
return true; return true;
state._categories = next; state._categories = next;
const selected = state._trains.get(state._selectedKey); const selected = state._trains.get(state._selectedKey);
if (selected && next[selected.category] === false) // A held train keeps the selection until its view lets it go
// (cockpitSubject.js).
if (
selected &&
next[selected.category] === false &&
selected.key !== state._cockpitSubjectKey
)
parts.selection.clearSelection(); parts.selection.clearSelection();
parts.rendering.applyCategoryVisibility(); parts.rendering.applyCategoryVisibility();
state._revision += 1; state._revision += 1;
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more