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
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
59 changed files with 2514 additions and 116 deletions

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
AISSTREAM_API_KEY=
# Optional AISStream subscription filters.
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
# default worldwide subscription — a narrow filter above can be legitimately
@@ -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).
+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).
+34 -1
View File
@@ -5960,6 +5960,39 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
settings and screen position rather than observed brightness. Selected-trail
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
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
@@ -7430,7 +7463,7 @@ pins and share links already supply complete boxes.
30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. 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). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
+1
View File
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `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)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
+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
every model is drawn life size. Ground objects, balloons and obstacles in
the `other` category are drawn at the size they are built.
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
port or starboard hand, safe water, isolated danger, special mark), a red
can when its type names no mark, at its built size and with no heading
(`vesselModelSpec`, `aidToNavigationModel`).
- An aircraft model's origin is its axis; it stands that model's scaled
clearance above the ground. A ship's origin is its waterline, and a transit
or rail vehicle's the road or rail top (a monorail or gondola is raised by
+14 -1
View File
@@ -11,7 +11,8 @@
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/sources/infrastructureData.js",
"src/services/terrainSurface.js"
],
"external": ["cesium"]
},
@@ -251,6 +252,7 @@
"src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js",
"src/data/vesselTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/acartia.js",
@@ -1514,6 +1516,7 @@
"exports": ["./layers/flights"],
"modules": [
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
@@ -1593,6 +1596,7 @@
"src/layers/flights/modelSpec.js",
"src/layers/flights/policy.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
@@ -2110,6 +2114,7 @@
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js",
"src/services/terrainSurface.js",
"src/data/inputOwnership.js",
"src/data/cameraSensitivity.js",
"src/i18n/index.js",
@@ -3061,6 +3066,7 @@
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
@@ -4241,6 +4247,7 @@
"src/scopeMaskPolicy.js",
"src/scopeMask.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/data/pointerOwnerNames.js",
@@ -4589,6 +4596,7 @@
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
@@ -4817,6 +4825,7 @@
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetTrailAnchors.js"
],
"external": []
@@ -5207,6 +5216,7 @@
"exports": ["./layers/transit/standalone"],
"modules": [
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/aircraftClass.js",
"src/i18n/marker.js",
"src/data/fleetModelInstances.js",
@@ -5263,6 +5273,7 @@
"src/renderGovernor.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
"src/services/terrainHeights.js",
@@ -5299,6 +5310,7 @@
"modules": [
"src/i18n/marker.js",
"src/data/fleetModels.js",
"src/data/vesselTypes.js",
"src/data/fleetModelInstances.js",
"src/data/aircraftClass.js",
"src/data/aircraftModelLighting.js",
@@ -5314,6 +5326,7 @@
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js",
"src/layers/transit/cockpitSubject.js",
"src/layers/transit/ingestion.js",
+2
View File
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
'ExtendedClassBPositionReport',
'ShipStaticData',
'StaticDataReport',
// Buoys, beacons and other aids to navigation (AIS message 21).
'AidsToNavigationReport',
];
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
+41 -6
View File
@@ -1,4 +1,8 @@
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
import {
aidToNavigationTypeName,
isAidToNavigation,
} from '../../../src/data/vesselTypes.js';
export const AISSTREAM_CACHE_MAX = 50000;
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
const staticData = _aisStreamStatic.get(mmsi) || {};
const aidReport = messageType === 'AidsToNavigationReport';
_aisStreamVessels.set(mmsi, {
lat,
lon,
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
name:
(aidReport && aidNameFromAis(message)) ||
vesselNameFromAis(metadata, message, staticData) ||
`MMSI ${mmsi}`,
mmsi,
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
type: vesselTypeFromAis(message, staticData),
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
type: aidReport
? aidToNavigationTypeName(message.Type)
: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination),
length: staticData.length ?? null,
beam: staticData.beam ?? null,
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
// Use the AIS message's own report time, not server ingest wall-clock —
// trail spacing and dead reckoning depend on true fix epochs.
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
// A virtual aid is broadcast where nothing physical stands. Only such a
// row carries the field, so ship rows stay their size.
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
_updatedAt: Date.now(),
});
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
);
}
/**
* An aid's whole name: an aid-to-navigation report carries the first 20
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
*/
function aidNameFromAis(message) {
const name = stringValue(message.Name);
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
}
function vesselTypeFromAis(message, staticData = {}) {
return stringValue(
message.Type ??
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
);
}
/**
* The current rows, newest first, with every vessel ahead of every aid to
* navigation: a capped request never gives up a ship for a buoy.
*/
export function aisStreamRows(maxRows) {
const cutoff = Date.now() - AISSTREAM_STALE_MS;
const rows = [];
const vessels = [];
const aids = [];
for (const row of _aisStreamVessels.values()) {
if (row._updatedAt >= cutoff) rows.push(row);
if (row._updatedAt >= cutoff)
(isAidToNavigation(row) ? aids : vessels).push(row);
}
rows.sort((a, b) => b._updatedAt - a._updatedAt);
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
vessels.sort(newestFirst);
if (vessels.length < maxRows) aids.sort(newestFirst);
return vessels
.concat(aids)
.slice(0, maxRows)
.map(({ _updatedAt, ...row }) => row);
}
function pruneAisStreamCache() {
+5 -2
View File
@@ -175,7 +175,7 @@ export function createApplicationCatalog({
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationBikeshare({ surface, source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
@@ -205,7 +205,10 @@ export function createApplicationCatalog({
}),
createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
...createInfrastructureLayers({
...localGeoJsonServices,
terrainSurface: surface.groundFloor.terrainSurface,
}),
createApplicationCables({ source: sources.cables }),
createApplicationRail({
surface,
+9 -2
View File
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) {
export function createApplicationBikeshare({ source, surface }) {
return createBikeshareLayer({
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 },
},
});
}
+54
View File
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
_setVesselStateForTest({ enabled: false });
}
});
test('an aid to navigation card shows its kind, never a speed or heading', () => {
const aid = makeRecord({
name: 'SF APPROACH N',
mmsi: '993672001',
type: 'Cardinal Mark N',
speed: 0,
course: 135,
heading: 135,
});
const card = buildVesselCard(aid);
assert.deepEqual(card.details, ['CARDINAL MARK N']);
assert.equal(card.accent, '217, 210, 192');
const selected = buildSelectedVesselCard(aid);
assert.equal(selected.details[0], 'CARDINAL MARK N');
assert.equal(selected.accent, '217, 210, 192');
// Known only by its MMSI, an aid is named as one.
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
});
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, {
type: 'Port hand Mark',
speed: 0,
heading: 135,
course: 135,
});
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
harness.record.virtual = true;
aisLiveVesselsLayer.selectById(harness.record.mmsi);
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
} finally {
harness.cleanup();
}
});
test('a virtual aid to navigation card says it is virtual', () => {
const aid = makeRecord({
mmsi: '993672001',
type: 'Isolated danger',
virtual: true,
});
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
assert.equal(
buildSelectedVesselCard(aid).details[0],
'ISOLATED DANGER · VIRTUAL',
);
});
+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.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);
});
+69 -5
View File
@@ -11,6 +11,9 @@
* vehicle's road or rail top), and is drawn life size: a type's published
* length over the model's built length is its scale.
*
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
* the set's buoy of its IALA mark, by the mark its type names.
*
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
* fetches the JSON and every GLB once, up front, so no model is fetched
* mid-session.
@@ -18,6 +21,7 @@
import { classifyAircraft } from './aircraftClass.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import { isAidToNavigation } from './vesselTypes.js';
/** Where the model set is served, relative to the application base. */
export const FLEET_MODEL_BASE = '/models/fleet/';
@@ -84,6 +88,44 @@ const TEXT_SHIP_TYPES = [
[/pleasure|yacht/i, 37],
];
/**
* The buoy an aid to navigation is drawn as, by the mark its type names
* ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
* that matches. Beacons, lights, RACONs and reference points with no mark of
* their own are the special mark.
*/
const AID_MODELS = [
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
[/isolated danger/i, 'buoy_isolated_danger'],
[/safe water/i, 'buoy_safe_water'],
[/port hand/i, 'buoy_lateral_port'],
[/starboard hand/i, 'buoy_lateral_starboard'],
[/special mark|light vessel|lanby/i, 'buoy_special'],
[
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
'buoy_special',
],
];
/** The buoy an aid to navigation whose type names no mark is drawn as. */
export const DEFAULT_AID_MODEL = 'can_red';
/**
* The buoy model of an aid to navigation's type, else undefined when the
* type names no mark.
* @param {string|null} [type]
* @returns {string|undefined}
*/
export function aidToNavigationModel(type) {
const text = String(type ?? '').trim();
for (const [pattern, model] of AID_MODELS)
if (pattern.test(text)) return model;
return undefined;
}
/**
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
* emitter category `category` and callsign `callsign`: the first of the
@@ -397,13 +439,33 @@ export function aircraftModelSpec(
/**
* The model a vessel is drawn with, from its AIS ship type (a code or a text
* family), reported length and beam (metres) and name.
* family), reported length and beam (metres) and name. An aid to navigation
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
* light, or its MMSI is an aid's) is the buoy of its mark
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
* @param {{catalog: object, types: object}} set
* @param {{type?: string|number|null, length?: number|null,
* beam?: number|null, name?: string|null}} vessel
* @returns {object} {@link fleetModelSpec}
* @param {{type?: string|number|null, mmsi?: string|number|null,
* virtual?: boolean, length?: number|null, beam?: number|null,
* name?: string|null}} vessel
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
* null for a virtual aid
*/
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
export function vesselModelSpec(
set,
{ type, mmsi, virtual, length, beam, name } = {},
) {
if (virtual === true) return null;
if (isAidToNavigation({ type, mmsi })) {
const model = aidToNavigationModel(type);
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
if (set.catalog[known])
return Object.freeze({
...fleetModelSpec(set, known, null),
fixed: true,
});
}
// A beam counts only beside a reported length: a vessel drawn at its type's
// typical length keeps the model's proportions.
const reported = hullLength(length ?? undefined);
@@ -460,6 +522,8 @@ export function namedFleetModels(types) {
transit.default[0],
...Object.keys(transit.elevation),
...Object.values(CLASS_MODELS),
...AID_MODELS.map(([, model]) => model),
DEFAULT_AID_MODEL,
]);
}
+63
View File
@@ -4,7 +4,9 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_AID_MODEL,
FLEET_MODEL_BASE,
aidToNavigationModel,
aircraftModelSpec,
aisShipTypeCode,
emitterCategory,
@@ -381,6 +383,67 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
assert.equal(unknown.scale, 1);
});
test('aids to navigation are the buoy of the mark their type names', () => {
const cases = [
['Cardinal Mark N', 'buoy_cardinal_north'],
['Beacon, Cardinal E', 'buoy_cardinal_east'],
['Cardinal Mark S', 'buoy_cardinal_south'],
['Beacon, Cardinal W', 'buoy_cardinal_west'],
['Port Hand Mark', 'buoy_lateral_port'],
['Beacon, Port hand', 'buoy_lateral_port'],
['Starboard Hand Mark', 'buoy_lateral_starboard'],
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
['Safe Water', 'buoy_safe_water'],
['Isolated danger', 'buoy_isolated_danger'],
['Beacon, Special Mark', 'buoy_special'],
['Special Mark', 'buoy_special'],
['Light Vessel / LANBY / Rigs', 'buoy_special'],
['RACON', 'buoy_special'],
['Light, with Sectors', 'buoy_special'],
['Reference point', 'buoy_special'],
['Navigation aid', 'buoy_special'],
];
for (const [type, model] of cases) {
assert.equal(aidToNavigationModel(type), model, type);
assert.ok(catalog[model], model);
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
assert.equal(spec.model, model, type);
assert.equal(spec.category, 'buoys', type);
assert.equal(spec.fixed, true, type);
// A buoy is drawn as built, whatever length the report gives.
assert.equal(spec.scale, 1, type);
}
assert.equal(
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
1,
);
// An aid of no stated kind, known only by its MMSI, is the default can.
assert.ok(catalog[DEFAULT_AID_MODEL]);
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
assert.equal(unnamed.fixed, true);
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
// A virtual aid is broadcast where nothing stands: it keeps its disc.
assert.equal(
vesselModelSpec(set, {
type: 'Cardinal Mark N',
mmsi: '993672001',
virtual: true,
}),
null,
);
// Vessels, including those that work on buoys, keep their hulls.
for (const vessel of [
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
{ type: '70', mmsi: '366999002' },
{ type: 'Tug', mmsi: '' },
]) {
const spec = vesselModelSpec(set, vessel);
assert.notEqual(spec.category, 'buoys', vessel.type);
assert.equal(spec.fixed, undefined, vessel.type);
}
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity',
+1
View File
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
'terrainSurface.js',
]);
assert.deepEqual(
entry.imports,
+185 -5
View File
@@ -23,9 +23,10 @@ import {
} from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
const createLocalGeoJsonLayer = (options, services = {}) =>
createLayer(options, {
...localGeoJsonServices,
...services,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
});
@@ -58,10 +59,15 @@ class MockLayerEvent {
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
* (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({
sampleHeightSupported = false,
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
points = null,
services = {},
} = {}) {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
globalThis.fetch = async () => ({
ok: true,
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',
id: 'real-dam',
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
setInputAction() {},
destroy() {},
}),
});
}, services);
try {
await layer.enable(viewer);
} finally {
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
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();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
terrain = 110;
clock.advance(500);
clock.advance(2_100);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
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');
});
/** 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,
shouldRecomputeInfraLod,
} from './localGeojsonLod.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../services/terrainSurface.js';
const DEFAULT_LABEL_MAX = 900;
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.
*/
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. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
governorRequestRender,
showOsmCredit,
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 _enabled = false;
let _clickHandler = null;
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
clearTimeout(_groundRetryTimer);
_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({
sourceId: id,
host: overlayHost,
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
const source = _dataSource;
_dataSource = null;
_stemRecords = [];
_ground.queued = [];
_stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id);
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
// Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const context = registerEntityContext(feature, {
id: `${id}:${recordId}`,
layerId: id,
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
stemPositionBuffers,
stemPositionBufferIndex: 0,
groundHeight,
// Degrees, as the terrain surface takes them.
latDeg,
lonDeg,
groundSampled: false,
groundSampleQueued: false,
lastGroundSampleMs: 0,
priority,
entry: labels
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return;
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;
const cameraPos = viewer.camera.positionWC;
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
_lastGroundSampleCapability = canSampleGround;
let groundRetryPending = 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. ---
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
const terrainFloorChanged = refreshLocalTerrainFloor(
viewer,
record,
_ground,
);
if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now);
updateLocalStemGeometry(viewer, record, now, _ground);
} else if (
canSampleGround &&
!record.groundSampled &&
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
);
if (
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)
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
// the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range, in-view records only,
// 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 (
canSampleGround &&
!record.groundSampled &&
!record.groundSampleQueued &&
!groundRetryPending &&
Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record)
groundSampleInView(viewer, record, _ground)
) {
groundRetryPending = true;
}
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
// so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer);
requestQueuedGroundFrame();
const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort(
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
}
function refreshLocalTerrainFloor(viewer, record) {
function refreshLocalTerrainFloor(viewer, record, ground) {
const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
return false;
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
// 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
// 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 (
!Number.isFinite(height) ||
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
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
* 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 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 =
Number.isFinite(terrain) &&
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 (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;
const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false;
let sampled;
const startedAt = performance.now();
try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch {
return false; // tiles not ready; retry on a later bounded update
} finally {
ground.sampleMs += performance.now() - startedAt;
}
if (
!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
// 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
// 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
? globe.getHeight?.(record.carto)
: undefined;
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
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)
? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
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
// minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1);
+9
View File
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
/**
* An aid to navigation's disc and card accent: a bone white apart from every
* ship type's colour.
*/
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
css: '#d9d2c0',
accent: '217, 210, 192',
});
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
+107
View File
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
/**
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
* floating marks by their cardinal quadrant, lateral side, safe water,
* isolated danger or special mark, and the lights, RACONs and reference
* points around them. Code 0 names no kind.
*/
const AID_TYPE_NAMES = Object.freeze([
'Navigation aid',
'Reference point',
'RACON',
'Fixed structure off shore',
'Emergency wreck marking buoy',
'Light, without sectors',
'Light, with sectors',
'Leading light front',
'Leading light rear',
'Beacon, Cardinal N',
'Beacon, Cardinal E',
'Beacon, Cardinal S',
'Beacon, Cardinal W',
'Beacon, Port hand',
'Beacon, Starboard hand',
'Beacon, Preferred channel port hand',
'Beacon, Preferred channel starboard hand',
'Beacon, Isolated danger',
'Beacon, Safe water',
'Beacon, Special mark',
'Cardinal Mark N',
'Cardinal Mark E',
'Cardinal Mark S',
'Cardinal Mark W',
'Port hand Mark',
'Starboard hand Mark',
'Preferred Channel Port hand',
'Preferred Channel Starboard hand',
'Isolated danger',
'Safe Water',
'Special Mark',
'Light Vessel / LANBY / Rigs',
]);
/**
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
* @param {string|number|null|undefined} code
* @returns {string}
*/
export function aidToNavigationTypeName(code) {
const text = String(code ?? '').trim();
const index = /^\d+$/.test(text) ? Number(text) : -1;
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
}
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
const AID_TYPE_NAME_SET = new Set(
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
);
/**
* Words a vessel type uses for an aid to navigation. A vessel that works on
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
* not a mark on the water.
*/
const AID_TYPE_TEXT =
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
const AID_MMSI = /^99\d{7}$/;
/**
* Whether each type text seen names an aid. Vessel layers ask per vessel on
* their draw passes, and a feed uses few distinct type texts, so each is
* matched once; the memo is bounded against a feed of free-form types.
*/
const AID_TYPE_MEMO = new Map();
const AID_TYPE_MEMO_MAX = 1024;
function typeNamesAid(text) {
let aid = AID_TYPE_MEMO.get(text);
if (aid !== undefined) return aid;
aid =
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
AID_TYPE_MEMO.set(text, aid);
return aid;
}
/**
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
* RACON or light) rather than a vessel: its type is one of the kinds an aid
* report names or uses a word for one, or its MMSI is an aid-to-navigation
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
* @param {{type?: string|number|null, mmsi?: string|number|null,
* virtual?: boolean}} vessel
* @returns {boolean}
*/
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
if (virtual === true) return true;
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
if (text && typeNamesAid(text)) return true;
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
}
+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));
});
+2
View File
@@ -776,6 +776,8 @@
"TANKER": "ناقلة",
"OTHER": "أخرى",
"VESSEL": "سفينة",
"AID TO NAVIGATION": "مساعدة ملاحية",
"VIRTUAL": "افتراضي",
"POS: LIVE": "الموضع: مباشر",
"POS: {time}Z": "الموضع: {time}Z",
"AIS: --": "AIS: --",
+2
View File
@@ -416,6 +416,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "SCHIFF",
"AID TO NAVIGATION": "SEEZEICHEN",
"VIRTUAL": "VIRTUELL",
"POS: LIVE": "POS: LIVE",
"POS: {time}Z": "POS: {time}Z",
"awaiting first AIS position…": "warten auf erste AIS-Position…",
+2
View File
@@ -650,6 +650,8 @@
"TANKER": "PETROLERO",
"OTHER": "OTRO",
"VESSEL": "BUQUE",
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
"VIRTUAL": "VIRTUAL",
"POS: LIVE": "POS: EN VIVO",
"POS: {time}Z": "POS: {time}Z",
"AIS: --": "AIS: --",
+2
View File
@@ -1291,6 +1291,8 @@
"AIS: {name}": "AIS : {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
"VESSEL": "NAVIRE",
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
"VIRTUAL": "VIRTUEL",
"{speed}KT": "{speed} ND",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS : DIRECT",
+2
View File
@@ -1149,6 +1149,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "אונייה",
"AID TO NAVIGATION": "עזר ניווט",
"VIRTUAL": "וירטואלי",
"{speed}KT": "{speed} קשר",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS: חי",
+2
View File
@@ -1291,6 +1291,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
"VESSEL": "NAVE",
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
"VIRTUAL": "VIRTUALE",
"{speed}KT": "{speed}KT",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS: LIVE",
+2
View File
@@ -1253,6 +1253,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶",
"AID TO NAVIGATION": "航路標識",
"VIRTUAL": "仮想",
"{speed}KT": "{speed} kt",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS: ライブ",
+2
View File
@@ -1289,6 +1289,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "EMBARCAÇÃO",
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
"VIRTUAL": "VIRTUAL",
"{speed}KT": "{speed} KT",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS: AO VIVO",
+2
View File
@@ -394,6 +394,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
"VESSEL": "СУДНО",
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
"AIS: --": "AIS: --",
"awaiting first AIS position…": "ожидание первой позиции AIS…",
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
+2
View File
@@ -382,6 +382,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "FARTYG",
"AID TO NAVIGATION": "SJÖMÄRKE",
"VIRTUAL": "VIRTUELL",
"POS: LIVE": "POS: LIVE",
"POS: {time}Z": "POS: {time}Z",
"awaiting first AIS position…": "väntar på första AIS-positionen …",
+2
View File
@@ -1309,6 +1309,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
"VESSEL": "СУДНО",
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
"VIRTUAL": "ВІРТУАЛЬНИЙ",
"{speed}KT": "{speed} ВУЗ",
"{heading}DEG": "{heading}°",
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
+2
View File
@@ -1229,6 +1229,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶",
"AID TO NAVIGATION": "助航标志",
"VIRTUAL": "虚拟",
"{speed}KT": "{speed} kn",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS:实时",
+2
View File
@@ -1224,6 +1224,8 @@
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"VESSEL": "船舶",
"AID TO NAVIGATION": "助航標誌",
"VIRTUAL": "虛擬",
"{speed}KT": "{speed} kn",
"{heading}DEG": "{heading}°",
"POS: LIVE": "POS:實時",
+4 -2
View File
@@ -411,7 +411,8 @@
"MMSI: {id}",
"NORAD: {id}",
"Regional",
"{datasets} · {tiles} · {packs}"
"{datasets} · {tiles} · {packs}",
"VIRTUAL"
],
"fr": [
"1 min/s",
@@ -1067,7 +1068,8 @@
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}",
"Regional"
"Regional",
"VIRTUAL"
],
"ru": [
"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._inFlightStatus = new Map();
layerState._stationRenderMap = new Map();
parts.rendering?.stopHeightRefinement?.();
layerState._clickHandler = null;
layerState._selectedKey = null;
layerState._selectedEntity = null;
@@ -134,6 +135,7 @@ export function createLifecycle({
parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities();
parts.rendering?.stopHeightRefinement?.();
layerState._cityRuntime.clear();
layerState._pointCollection.show = false;
layerState._count = 0;
@@ -165,6 +167,7 @@ export function createLifecycle({
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
parts.rendering?.stopHeightRefinement?.();
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
+11
View File
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
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. */
export const MAX_TOTAL_POINTS = 8000;
+106 -12
View File
@@ -11,7 +11,13 @@ import {
POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS,
COLOR_OUTLINE,
HEIGHT_REFINE_BUDGET_MS,
HEIGHT_REFINE_INTERVAL_MS,
} from './policy.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../../services/terrainSurface.js';
export function createRendering({
state: layerState,
@@ -20,6 +26,14 @@ export function createRendering({
source,
}) {
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.
@@ -87,8 +101,10 @@ export function createRendering({
}
/**
* Create a Cartesian3 position for a station, terrain-clamped when possible.
* Falls back to a fixed small height offset if terrain sampling is unsupported.
* Create a Cartesian3 position for a station at the fallback height. Its
* 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.
* @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 lat = Number(station?.lat);
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
if (layerState._viewer?.scene?.sampleHeightSupported) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const sampled = layerState._viewer.scene.sampleHeight(carto);
if (Number.isFinite(sampled)) {
height = sampled + POINT_HEIGHT_OFFSET_M;
}
/**
* The ground height under a station, or undefined when the scene cannot say.
* A terrain globe answers from its loaded surface, which is cheap. Where it
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
* at the point) the scene renders the depth there, at a millisecond or more
* a point.
*/
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,
});
layerState._stationRenderMap.set(key, {
const record = {
key,
cityId,
stationId: station.stationId,
@@ -191,12 +267,19 @@ export function createRendering({
isInstalled: station.isInstalled,
isRenting: station.isRenting,
isReturning: station.isReturning,
};
layerState._stationRenderMap.set(key, record);
heightQueue.push({
record,
lat: Number(station.lat),
lon: Number(station.lon),
});
runtime.stationKeys.add(key);
}
layerState._count = layerState._stationRenderMap.size;
scheduleHeightRefinement();
}
/**
@@ -224,6 +307,12 @@ export function createRendering({
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._count = layerState._stationRenderMap.size;
}
@@ -280,5 +369,10 @@ export function createRendering({
ensureCityPoints,
removeCityPoints,
applyStatusToPoints,
stopHeightRefinement,
/** Stations still waiting for their ground height. */
pendingHeightCount: () => heightQueue.length,
/** Whether a refinement step is waiting on a timer. */
heightRefinementScheduled: () => heightTimer !== null,
};
}
+25 -3
View File
@@ -1,6 +1,10 @@
import * as Cesium from 'cesium';
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
import {
COLD_CELL_FLUSH_MS,
COLD_CELL_RETRY_MS,
@@ -44,6 +48,11 @@ const CATEGORY_TINTS = Object.fromEntries(
);
const SPRITE_PX = 64;
/**
* Terrain reads per degree for a train's height: about 3.7 m apart. At 11 m
* a train on fine tiles stood up to a couple of metres off the surface.
*/
const TERRAIN_GRID = 3e4;
/** Billboard tints; the setter copies, so sharing one instance is safe. */
const FULL_TINT = Cesium.Color.WHITE;
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
@@ -117,8 +126,11 @@ export function createRendering({ state, services, parts }) {
neighborFloorM = null,
coarseFloorCoord = null,
} = services.ground;
// 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();
const sprites = new Map();
const terrainPoint = new Cesium.Cartographic();
const scratchPosition = new Cesium.Cartesian3();
/** Scratch for the on-line position; place() runs every frame. */
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
@@ -395,8 +407,18 @@ export function createRendering({ state, services, parts }) {
function floorHeight(lat, lon, entry = null, stop = false) {
const globe = state._viewer?.scene?.globe;
if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
// A moving train is asked about every frame, at a new point each
// time. On a ~4 m grid it asks the terrain a few times a second
// instead, and a standing train not at all; the drawn height is
// smoothed along the track anyway (displayHeightFor). Read as a
// marker, never rationed: a train that starts on a fallback floor only
// creeps to the terrain at a railway's grade.
const rendered = terrainSurface.height(
state._viewer,
Math.round(lat * TERRAIN_GRID) / TERRAIN_GRID,
Math.round(lon * TERRAIN_GRID) / TERRAIN_GRID,
TERRAIN_NEED.MARKER,
);
if (Number.isFinite(rendered))
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
}
+50 -16
View File
@@ -7,6 +7,10 @@ import {
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
withinViewBounds,
} from './policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
/** Surface preparation off the render path, shared by markers and history. */
export function createHeight({ state, services, parts }) {
@@ -47,35 +51,44 @@ export function createHeight({ state, services, parts }) {
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
}
// Loaded-terrain heights come from the application's shared reader, which
// keeps them per point and rations the reads (services/terrainSurface.js).
// A layer built without one keeps its own.
const sharedSurface = services.ground.terrainSurface;
const terrainSurface = sharedSurface || createTerrainSurface();
/**
* The floor under a vehicle, and the best fallback if its own cell is cold.
* @param {number} lat
* @param {number} lon
* @param {?object} [entry] The vehicle, for a fixture floor.
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
* @returns {{cell: string, height: number|null, prior: number|null}}
*/
const terrainPoint = new Cesium.Cartographic();
function requestHeight(lat, lon, entry = null) {
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
const coarse = coarseFloorCoord(lat, lon);
const cell = `${coarse.lat},${coarse.lon}`;
const fixtureFloor =
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
if (Number.isFinite(fixtureFloor))
return { cell, height: fixtureFloor, prior: null };
// On a terrain globe, use the loaded surface under the marker. This is
// synchronous cache work and does not launch a terrain request.
// On a terrain globe, use the loaded surface under the marker. This reads
// tiles already loaded and does not launch a terrain request.
const globe = state._viewer?.scene?.globe;
if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
const flat =
if (
state._viewer?.terrainProvider instanceof
Cesium.EllipsoidTerrainProvider;
if (Number.isFinite(rendered) || flat)
return {
cell,
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
prior: null,
};
Cesium.EllipsoidTerrainProvider
) {
// Nothing is asked of the terrain while the globe is flat: show the
// reader the flat provider, so the heights kept from the terrain
// before it do not come back with the terrain after it.
terrainSurface.observe(state._viewer);
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
}
const rendered = terrainSurface.height(state._viewer, lat, lon, need);
if (Number.isFinite(rendered))
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
}
const floor = cachedGroundFloor(lat, lon);
if (Number.isFinite(floor)) {
@@ -107,6 +120,7 @@ export function createHeight({ state, services, parts }) {
function rereadFloors() {
state._floorTimer = null;
if (!state._enabled) return;
const waitingBefore = terrainSurface.waitingCount();
const onGround = nearGround(),
candidates = [];
const entries = [...state._vehicles.values()].filter(
@@ -120,6 +134,13 @@ export function createHeight({ state, services, parts }) {
Number(b.key === state._selectedKey) -
Number(a.key === state._selectedKey),
);
// Every vehicle's own position before any trail: the first trails of a
// pass must not spend the budget the last markers are waiting on.
for (const entry of entries) {
const newest = entry.fixes.at(-1);
if (newest)
requestHeight(newest.lat, newest.lon, entry, TERRAIN_NEED.MARKER);
}
for (const entry of entries) {
const cells = new Map();
const collect = (point, answer) => {
@@ -141,7 +162,12 @@ export function createHeight({ state, services, parts }) {
};
parts.trails.prepareEntry(entry, collect);
const newest = entry.fixes.at(-1);
const answer = requestHeight(newest.lat, newest.lon, entry);
const answer = requestHeight(
newest.lat,
newest.lon,
entry,
TERRAIN_NEED.MARKER,
);
entry.heightCell = answer.cell;
// The selected vehicle's path also offers its history, newest segment
// first; the vehicle's own cell is read where its newest fix is.
@@ -227,7 +253,11 @@ export function createHeight({ state, services, parts }) {
for (const entry of entries) parts.trails.prepareEntry(entry);
parts.rendering.requestVisibility();
governorRequestRender('transit-floor');
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS)
// A pass that ran out of terrain budget before reaching every point
// comes back for the rest. Each pass reads its budget, and points stop
// falling due once the terrain settles, so this ends.
const waiting = terrainSurface.waitingCount() - waitingBefore;
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0)
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
}
@@ -258,6 +288,8 @@ export function createHeight({ state, services, parts }) {
}
state._floorAttempts = 0;
state._heightDirty.clear();
// The shared reader's heights belong to every layer still showing.
if (!sharedSurface) terrainSurface.clear();
}
return {
@@ -267,5 +299,7 @@ export function createHeight({ state, services, parts }) {
anchorFloors,
rereadFloors,
clear,
/** Terrain read counters since the layer was created, for checks. */
terrainStats: () => terrainSurface.stats(),
};
}
+389
View File
@@ -0,0 +1,389 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createHeight } from './height.js';
import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js';
import {
TERRAIN_HEIGHT_CACHE_MAX,
TERRAIN_HEIGHT_FRESH_MS,
TERRAIN_NEED,
TERRAIN_READ_BUDGET_MS,
TERRAIN_READ_WINDOW_MS,
TERRAIN_REFRESH_BUDGET_MS,
TERRAIN_SETTLE_MS,
} from '../../services/terrainSurface.js';
const LIFT = 1.5;
/**
* A height part over a terrain globe whose every read costs `costMs` of a
* clock the test owns. `surface(lat, lon)` is the terrain; `reads` lists the
* points actually read.
*/
function terrain(t, { costMs = 0, surface = () => 100, provider = {} } = {}) {
const clock = { now: 1_000_000 };
t.mock.method(performance, 'now', () => clock.now);
const reads = [];
const world = { surface, costMs };
const globe = {
show: true,
getHeight(point) {
const lat = Cesium.Math.toDegrees(point.latitude);
const lon = Cesium.Math.toDegrees(point.longitude);
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
clock.now += world.costMs;
return world.surface(lat, lon);
},
};
const state = {
_enabled: true,
_selectedKey: null,
_vehicles: new Map(),
_visible: new Set(),
_floorCursor: 0,
_floorAttempts: 0,
_floorTimer: null,
_heightDirty: new Set(),
_viewer: {
camera: { positionCartographic: { height: 500 } },
scene: { globe },
terrainProvider: provider,
},
};
const parts = {
trails: { prepareEntry() {} },
rendering: { requestVisibility() {} },
};
const height = createHeight({
state,
services: {
render: { governorRequestRender() {} },
ground: {
coarseFloorCoord: (lat, lon) => ({
lat: Number(lat.toFixed(3)),
lon: Number(lon.toFixed(3)),
}),
cachedGroundFloor: () => null,
neighborFloorM: () => null,
warmGroundFloor() {},
GROUND_FLOOR_LIFT_M: LIFT,
},
},
parts,
});
t.after(() => height.clear());
const nextWindow = () => {
clock.now += TERRAIN_READ_WINDOW_MS;
};
return { height, state, parts, globe, world, clock, reads, nextWindow };
}
test('a point is read from the terrain once and reused until its reuse time passes', (t) => {
const { height, reads, clock, world } = terrain(t);
const first = height.requestHeight(42.36, -71.06);
assert.deepEqual(first, { cell: '42.36,-71.06', height: 100 + LIFT, prior: null });
for (let i = 0; i < 500; i++) height.requestHeight(42.36, -71.06);
assert.equal(reads.length, 1, '501 requests, one terrain read');
// A point a metre away is its own point; the same cell does not stand in
// for it while the budget allows a read.
world.surface = (lat) => (lat > 42.36 ? 140 : 100);
assert.equal(height.requestHeight(42.36002, -71.06).height, 140 + LIFT);
assert.equal(reads.length, 2);
// The surface under the first point changed (the globe drew a finer tile).
world.surface = () => 120;
clock.now += TERRAIN_HEIGHT_FRESH_MS - 1;
assert.equal(height.requestHeight(42.36, -71.06).height, 100 + LIFT);
clock.now += 1;
assert.equal(height.requestHeight(42.36, -71.06).height, 120 + LIFT);
assert.equal(reads.length, 3);
assert.deepEqual(height.terrainStats(), {
reads: 3,
reused: 501,
deferred: 0,
borrowed: 0,
held: 2,
});
});
test('terrain that is not loaded yet is not remembered as an answer', (t) => {
const { height, reads, world } = terrain(t, { surface: () => undefined });
assert.deepEqual(height.requestHeight(42.36, -71.06), {
cell: '42.36,-71.06',
height: null,
prior: null,
});
assert.equal(height.requestHeight(42.36, -71.06).height, null);
assert.equal(reads.length, 2, 'each request asks again');
world.surface = () => 12;
assert.equal(height.requestHeight(42.36, -71.06).height, 12 + LIFT);
assert.equal(height.terrainStats().held, 1);
});
test('a tile that leaves the globe keeps the height it gave', (t) => {
const { height, clock, world } = terrain(t, { surface: () => 30 });
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
world.surface = () => undefined;
clock.now += TERRAIN_HEIGHT_FRESH_MS * 20;
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
});
test('new points stop being read once a window has spent its budget', (t) => {
const costMs = 5;
const { height, reads, nextWindow } = terrain(t, {
costMs,
surface: (lat, lon) => Math.round(lon * -1000),
});
const affordable = Math.ceil(TERRAIN_READ_BUDGET_MS / costMs);
// One point per floor cell (0.001° apart), more than the window affords.
const answers = [];
for (let i = 0; i < affordable + 3; i++)
answers.push(height.requestHeight(42.36, -71.06 - i * 0.001).height);
assert.equal(reads.length, affordable);
assert.deepEqual(
answers.slice(affordable),
[null, null, null],
'a point in a cell never read waits',
);
assert.ok(reads.length * costMs < TERRAIN_READ_BUDGET_MS + costMs, 'the overrun is one read at most');
// A new point in a cell that was read borrows that cell's height.
const borrowed = height.requestHeight(42.36004, -71.06);
assert.equal(borrowed.height, 71060 + LIFT);
assert.equal(reads.length, affordable, 'borrowing reads nothing');
assert.equal(height.terrainStats().borrowed, 1);
// The next window reads the points that waited, and the borrower its own.
nextWindow();
for (let i = affordable; i < affordable + 3; i++)
assert.equal(
height.requestHeight(42.36, -71.06 - i * 0.001).height,
Math.round((-71.06 - i * 0.001) * -1000) + LIFT,
);
assert.equal(reads.length, affordable + 3);
});
test("a vehicle's own position is read even when the window is spent", (t) => {
const { height, reads } = terrain(t, { costMs: TERRAIN_READ_BUDGET_MS });
height.requestHeight(42.36, -71.06);
assert.equal(height.requestHeight(42.37, -71.07).height, null, 'a trail point waits');
assert.equal(height.requestHeight(42.38, -71.08, null, TERRAIN_NEED.MARKER).height, 100 + LIFT);
assert.equal(reads.length, 2);
});
test('undrawn trail history leaves most of the budget to the paths markers are drawn along', (t) => {
const costMs = 1;
const { height, reads } = terrain(t, { costMs });
// New points, one per floor cell: history first, as a pass offers them.
let lon = -71;
const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height;
const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.BACKGROUND));
const read = history.filter((h) => h !== null).length;
assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget');
const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH));
assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest');
assert.equal(ask(TERRAIN_NEED.BACKGROUND), null);
assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read');
assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1);
});
test('held points keep their last height while re-reads are over budget, and the longest-held are re-read first so none starves', (t) => {
const costMs = TERRAIN_REFRESH_BUDGET_MS / 2;
const { height, reads, clock, world, nextWindow } = terrain(t, { surface: () => 50 });
const points = Array.from({ length: 40 }, (_, i) => [42.36, -71.06 - i * 0.001]);
for (const [lat, lon] of points) height.requestHeight(lat, lon);
assert.equal(reads.length, 40);
world.costMs = costMs;
world.surface = () => 80;
clock.now += TERRAIN_HEIGHT_FRESH_MS;
reads.length = 0;
const pass = () => points.map(([lat, lon]) => height.requestHeight(lat, lon).height);
const first = pass();
const perWindow = Math.ceil(TERRAIN_REFRESH_BUDGET_MS / costMs);
assert.equal(reads.length, perWindow, 'one window re-reads its budget and no more');
assert.deepEqual(first.slice(0, perWindow), Array(perWindow).fill(80 + LIFT));
assert.deepEqual(first.slice(perWindow), Array(40 - perWindow).fill(50 + LIFT), 'the rest keep their last height');
// Pass after pass, a window apart: the budget moves down the list instead
// of returning to the first points, whose reuse time runs out again long
// before the last ones have had a turn (40 points, ${perWindow} a window).
const turns = new Map();
for (const point of reads) turns.set(point, 1);
const cycle = Math.ceil(40 / perWindow);
for (let passes = 1; passes < cycle; passes++) {
nextWindow();
reads.length = 0;
pass();
for (const point of reads) turns.set(point, (turns.get(point) || 0) + 1);
}
assert.equal(turns.size, 40, 'every point was re-read in one cycle');
assert.ok([...turns.values()].every((count) => count === 1), 'and none twice before the others once');
assert.ok(cycle * TERRAIN_READ_WINDOW_MS > TERRAIN_HEIGHT_FRESH_MS, 'the cycle is longer than the reuse time, so the first points were stale again');
assert.deepEqual(pass().slice(-1), [80 + LIFT]);
});
test('once the camera and the tiles have settled a height is kept until either changes', (t) => {
const { height, state, globe, reads, clock, world } = terrain(t, { surface: () => 10 });
const read = () => height.requestHeight(42.36, -71.06).height;
assert.equal(read(), 10 + LIFT);
// Still settling: the point falls due and is read again.
world.surface = () => 20;
clock.now += TERRAIN_SETTLE_MS;
assert.equal(read(), 20 + LIFT);
assert.equal(reads.length, 2);
// Read after the settle time: kept, however long the view stays put.
world.surface = () => 99;
for (let i = 0; i < 50; i++) {
clock.now += 60_000;
assert.equal(read(), 20 + LIFT);
}
assert.equal(reads.length, 2);
// The camera moves: the globe may draw the point from a finer tile.
state._viewer.camera.positionCartographic = { longitude: 0, latitude: 0, height: 250 };
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(read(), 99 + LIFT);
assert.equal(reads.length, 3);
// Settled again, then a tile starts loading with the camera still.
world.surface = () => 101;
clock.now += TERRAIN_SETTLE_MS;
assert.equal(read(), 101 + LIFT);
clock.now += 60_000;
assert.equal(read(), 101 + LIFT);
assert.equal(reads.length, 4);
world.surface = () => 105;
globe.tilesLoaded = false;
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(read(), 105 + LIFT);
assert.equal(reads.length, 5);
});
test('another terrain provider or globe is read afresh, however settled the old heights were', (t) => {
const { height, state, globe, world, clock, reads } = terrain(t);
const viewer = state._viewer;
const at = () => height.requestHeight(42.36, -71.06).height;
// Read once the camera and tiles have long settled: kept indefinitely.
height.requestHeight(10, 10);
clock.now += TERRAIN_SETTLE_MS * 2;
reads.length = 0;
assert.equal(at(), 100 + LIFT);
clock.now += TERRAIN_SETTLE_MS * 10;
world.surface = () => 250;
assert.equal(at(), 100 + LIFT, 'same terrain: the kept height stands');
assert.equal(reads.length, 1);
viewer.terrainProvider = {};
assert.equal(at(), 250 + LIFT, 'a new provider is a new surface');
assert.equal(reads.length, 2);
world.surface = () => 400;
viewer.scene.globe = { show: true, getHeight: globe.getHeight };
assert.equal(at(), 400 + LIFT, 'and so is a new globe');
assert.equal(reads.length, 3);
// Terrain → flat → the same terrain provider: nothing asks the terrain
// while the globe is flat, yet the old heights must not come back.
const real = viewer.terrainProvider;
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
assert.equal(at(), LIFT);
world.surface = () => 30;
viewer.terrainProvider = real;
assert.equal(at(), 30 + LIFT);
assert.equal(reads.length, 4);
});
test('a flat globe answers without reading terrain', (t) => {
const { height, reads } = terrain(t, {
provider: new Cesium.EllipsoidTerrainProvider(),
});
assert.deepEqual(height.requestHeight(42.36, -71.06), {
cell: '42.36,-71.06',
height: LIFT,
prior: null,
});
assert.equal(reads.length, 0);
});
test('a hidden globe reads no terrain and clear() forgets what was read', (t) => {
const { height, reads, globe } = terrain(t);
height.requestHeight(42.36, -71.06);
height.clear();
assert.equal(height.terrainStats().held, 0);
height.requestHeight(42.36, -71.06);
assert.equal(reads.length, 2, 'a cleared layer reads again');
globe.show = false;
assert.equal(height.requestHeight(42.5, -71.5).height, null);
assert.equal(reads.length, 2);
});
test('the points kept stay bounded', (t) => {
const { height } = terrain(t);
const total = TERRAIN_HEIGHT_CACHE_MAX + 5_000;
for (let i = 0; i < total; i++)
height.requestHeight(10 + Math.floor(i / 1000) * 0.001, 20 + (i % 1000) * 0.0001);
const { held, reads } = height.terrainStats();
assert.equal(reads, total);
assert.ok(held <= TERRAIN_HEIGHT_CACHE_MAX, `${held} held`);
assert.ok(held >= TERRAIN_HEIGHT_CACHE_MAX * 0.7, `${held} held`);
});
test('a fleet rereading every trail fix reads each point once, not once per request', (t) => {
const { height, state, parts, reads } = terrain(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
// 600 vehicles with 40 fixes each, 25 m apart along a street.
const fleet = Array.from({ length: 600 }, (_, v) => ({
key: `bus-${v}`,
fixes: Array.from({ length: 40 }, (_, i) => ({
lat: 42 + v * 0.002,
lon: -71 + i * 0.0003,
})),
}));
for (const entry of fleet) {
state._vehicles.set(entry.key, entry);
state._visible.add(entry);
}
let requests = 0;
parts.trails.prepareEntry = (entry, collect) => {
for (const fix of entry.fixes) {
requests++;
const answer = height.requestHeight(fix.lat, fix.lon, entry);
if (collect) collect(fix, answer);
}
};
height.anchorFloors();
for (let i = 0; i < FLOOR_REREAD_ATTEMPTS; i++) t.mock.timers.tick(FLOOR_REREAD_MS);
assert.equal(requests, 600 * 40 * 2 * (FLOOR_REREAD_ATTEMPTS + 1));
assert.equal(new Set(reads).size, 600 * 40);
assert.equal(reads.length, 600 * 40, `${requests} requests, ${reads.length} terrain reads`);
assert.ok(fleet.every((entry) => entry.heightM === 100 + LIFT && entry.heightResolved));
});
test('a reread cycle outlasts its attempts while trail points still wait for a read, then stops', (t) => {
const costMs = 4;
const { height, state, parts, reads, clock } = terrain(t, { costMs });
t.mock.timers.enable({ apis: ['setTimeout'] });
const entry = {
key: 'bus',
// One fix per floor cell, so nothing can be borrowed.
fixes: Array.from({ length: 60 }, (_, i) => ({ lat: 42, lon: -71 + i * 0.001 })),
};
state._vehicles.set(entry.key, entry);
state._visible.add(entry);
parts.trails.prepareEntry = (e) => {
for (const fix of e.fixes) height.requestHeight(fix.lat, fix.lon, e);
};
height.anchorFloors();
let passes = 1;
while (state._floorTimer && passes < 200) {
clock.now += FLOOR_REREAD_MS;
t.mock.timers.tick(FLOOR_REREAD_MS);
passes++;
}
assert.equal(new Set(reads).size, 60, 'every fix was read in the end');
assert.ok(passes > FLOOR_REREAD_ATTEMPTS + 1, `${passes} passes: the cycle kept going for the unread fixes`);
assert.ok(passes < 120, `${passes} passes: and stopped once none waited`);
assert.equal(state._floorTimer, null);
});
+13 -2
View File
@@ -13,6 +13,7 @@ import {
transitVehicleKey,
vehicleReportTimeMs,
} from './policy.js';
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
import {
transitModeFor,
transitModeResolved,
@@ -274,7 +275,12 @@ export function createIngestion({ state, services, parts, source }) {
// DEM one, or a change of map regime, moves a parked vehicle too. A
// cell still cold takes the lowest neighbour as a prior, as before.
const priorHeightM = entry.heightM;
const height = parts.height.requestHeight(record.lat, record.lon);
const height = parts.height.requestHeight(
record.lat,
record.lon,
null,
TERRAIN_NEED.MARKER,
);
if (height.height !== null) {
entry.heightCell = height.cell;
entry.heightM = height.height;
@@ -311,7 +317,12 @@ export function createIngestion({ state, services, parts, source }) {
}
continue;
}
const height = parts.height.requestHeight(record.lat, record.lon);
const height = parts.height.requestHeight(
record.lat,
record.lon,
null,
TERRAIN_NEED.MARKER,
);
// Keep the local prior separate from resolved historical floors.
// A neighbouring cell is not a guaranteed lower bound.
const prior = height.height === null ? height.prior : height.height;
-1
View File
@@ -77,7 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300;
export const FLOOR_REREAD_MS = 900;
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
export const FLOOR_REREAD_ATTEMPTS = 6;
// --- Rendering ---
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
export const MARKER_PIXEL_SIZE = 20;
+20 -8
View File
@@ -8,6 +8,7 @@ import {
} from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js';
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
@@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) {
b = {};
let vertices = 0,
signature = '';
const floor = (point) => {
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
const floor = (point, need = TERRAIN_NEED.PATH) => {
const answer = parts.height.requestHeight(
point.lat,
point.lon,
entry,
need,
);
if (collect) collect(point, answer);
return answer.height;
};
@@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) {
lon: a.lon + ((b.lon - a.lon) * i) / n,
});
}
for (let i = 0; i < entry.track.count; i++) {
readFix(entry.track, i, a);
const h = floor(a);
if (h !== null) correctHeight(entry.track, a.seq, h);
}
const order = [];
const activeIndex = Math.max(
0,
Math.min(
@@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) {
entry.sample.fromSeq - entry.track.baseSeq,
),
);
for (let i = 0; i < entry.track.count; i++) {
readFix(entry.track, i, a);
// Fixes behind an unselected vehicle are kept but not drawn.
const h = floor(
a,
entry === selected || i >= activeIndex
? TERRAIN_NEED.PATH
: TERRAIN_NEED.BACKGROUND,
);
if (h !== null) correctHeight(entry.track, a.seq, h);
}
const order = [];
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
if (entry === selected)
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
+55 -20
View File
@@ -1,8 +1,10 @@
import {
AID_TO_NAVIGATION_STYLE,
accentForVesselType,
displayVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { isAidToNavigation } from '../../data/vesselTypes.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
@@ -16,14 +18,21 @@ export function createCards({
function updateSelectedVesselHud(record) {
// Pinned vessels missing from recent refreshes get a stale marker
const stale = (record.missedRefreshes || 0) > 0;
// An aid to navigation is a fixed mark: it has no speed or heading to show.
const aid = isAidToNavigation(record);
services.readouts?.presentSelectedVessel({
text: [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
aid
? aidKind(
trimHudValue(record.type || t('AID TO NAVIGATION'), 24),
record,
)
: t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n'),
});
@@ -43,20 +52,22 @@ export function createCards({
/**
* Card model for an ambient (decluttered-in) vessel — name title plus one
* compact type/speed/heading detail line, anchored at the record's current
* rendered position (height-datum caveat: no datum work here). Pure —
* exported for unit tests.
* rendered position (height-datum caveat: no datum work here). An aid to
* navigation's line is its kind alone. Pure — exported for unit tests.
* @param {Object} record - Vessel record.
* @returns {Object} vesselLabels entry.
*/
function buildVesselCard(record) {
const parts = [];
const aid = isAidToNavigation(record);
const type = vesselTypeShort(record);
if (type) parts.push(type);
if (record.speed !== null && record.speed !== undefined)
if (record.virtual === true) parts.push(t('VIRTUAL'));
if (!aid && record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
if (Number.isFinite(direction))
if (!aid && Number.isFinite(direction))
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
@@ -65,7 +76,7 @@ export function createCards({
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 10,
accent: accentForVesselType(record.type),
accent: vesselAccent(record, aid),
title: trimHudValue(displayVesselName(record), 26),
details: parts.length ? [parts.join(' · ')] : [],
selected: false,
@@ -76,22 +87,26 @@ export function createCards({
/**
* Card model for the click-selected vessel — the full-detail card, drawn last
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
* for unit tests.
* vessels carry a STALE marker (mirrors the HUD readout). An aid to
* navigation shows its kind with no speed or heading. Pure — exported for
* unit tests.
* @param {Object} record - Selected vessel record.
* @returns {Object} vesselLabels entry.
*/
function buildSelectedVesselCard(record) {
const aid = isAidToNavigation(record);
const direction = record.heading ?? record.course;
const details = [
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
aid
? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record)
: [
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
@@ -106,7 +121,7 @@ export function createCards({
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 12,
accent: accentForVesselType(record.type),
accent: vesselAccent(record, aid),
title: trimHudValue(displayVesselName(record), 32),
details,
selected: true,
@@ -114,6 +129,20 @@ export function createCards({
};
}
/** An aid's kind, marked virtual when nothing physical stands there. */
function aidKind(kind, record) {
return record.virtual === true ? `${kind} · ${t('VIRTUAL')}` : kind;
}
/** A card's accent: an aid's disc colour, else its ship type's. */
function vesselAccent(record, aid) {
return aid
? AID_TO_NAVIGATION_STYLE.accent
: accentForVesselType(record.type);
}
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
function vesselOverlayEntryId(record) {
@@ -129,9 +158,15 @@ export function createCards({
* Uppercased AIS type for the card (empty string when unknown). Free feed
* text is bounded to the card width; a translated type token is a short
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
* An aid's kind is the whole line, so it keeps the room speed and heading
* would take ("CARDINAL MARK N", not "CARDINAL MARK ").
*/
function vesselTypeShort(record) {
if (isAidToNavigation(record)) {
const kind = String(record.type || '').trim();
return kind ? trimHudValue(kind.toUpperCase(), 26) : '';
}
const feedType = normalizeVesselType(record.type);
const shown = displayVesselType(record.type);
return shown === feedType
+1
View File
@@ -218,6 +218,7 @@ export function createIngestion({
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
course: record.courseDeg,
heading: record.headingDeg,
virtual: record.virtual === true,
last_position_epoch:
record.observedAtMs == null ? null : record.observedAtMs / 1000,
last_position_UTC:
+47
View File
@@ -515,3 +515,50 @@ test('AIS feed rows stay keyed by their MMSI', async () => {
['235000001', '235000001'],
);
});
test('an aid to navigation is drawn as a disc, a vessel as its chevron', async () => {
const aid = {
...observation('993672001'),
mmsi: '993672001',
type: 'Port Hand Mark',
speedMps: 0,
};
const ship = { ...observation('366999002'), type: 'Cargo' };
const { layer, primitives } = setup({
async getSnapshot() {
return snapshot([aid, ship]);
},
});
await layer.update();
const svgOf = (id) => {
const primitive = [...primitives].find((p) => p.id?.id === id);
assert.ok(primitive, id);
return atob(primitive.image.split(',')[1]);
};
assert.match(svgOf('993672001'), /<circle/);
// The source's virtual flag reaches the layer's record.
const recordOf = (id) => [...primitives].find((p) => p.id?.id === id).id;
assert.equal(recordOf('993672001').virtual, false);
assert.equal(recordOf('366999002').virtual, false);
assert.doesNotMatch(svgOf('993672001'), /<path/);
assert.match(svgOf('366999002'), /<path/);
});
test('a virtual aid to navigation keeps its disc and its virtual flag', async () => {
const aid = {
...observation('993672009'),
mmsi: '993672009',
type: 'Isolated danger',
virtual: true,
};
const { layer, primitives } = setup({
async getSnapshot() {
return snapshot([aid]);
},
});
await layer.update();
const primitive = [...primitives].find((p) => p.id?.id === '993672009');
assert.ok(primitive);
assert.equal(primitive.id.virtual, true);
assert.match(atob(primitive.image.split(',')[1]), /<circle/);
});
+3
View File
@@ -31,6 +31,8 @@ export function normalizeVessel(row) {
speed: finiteNumber(row.speed),
course: finiteNumber(row.course),
heading: finiteNumber(row.heading),
// A virtual aid to navigation: a mark with nothing physical there.
virtual: row.virtual === true,
lastPositionUtc: String(row.last_position_UTC || ''),
lastPositionEpoch: finiteNumber(row.last_position_epoch),
missedRefreshes: 0,
@@ -160,6 +162,7 @@ export class VesselRecords {
record.speed = next.speed;
record.course = next.course;
record.heading = next.heading;
record.virtual = next.virtual;
record.lastPositionUtc = next.lastPositionUtc;
record.lastPositionEpoch = next.lastPositionEpoch;
record.missedRefreshes = 0;
+35 -11
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AID_TO_NAVIGATION_STYLE,
vesselTypeCss,
vesselOverlayCohortLimit,
VESSEL_OVERLAY_SOURCE_ID,
@@ -28,6 +29,7 @@ import {
vesselModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
import { isAidToNavigation } from '../../data/vesselTypes.js';
/** How often vessels move on as the world clock plays (wall ms). */
const FOLLOW_POSITION_MS = 250;
@@ -182,12 +184,14 @@ export function createRendering({
* Best available real-world direction of travel for a vessel, degrees
* clockwise from north (true heading preferred, course-over-ground fallback).
* Screen rotation is computed from this by the shared projected-rotation
* pass in updateVisibility — never directly from the compass value.
* pass in updateVisibility — never directly from the compass value. An aid
* to navigation is fixed, so it has none.
* @param {Object} record - Vessel record.
* @returns {number} Course in degrees (0 when unknown).
*/
function vesselCourseDeg(record) {
if (isAidToNavigation(record)) return 0;
const direction = record.heading ?? record.course;
return Number.isFinite(direction) ? direction : 0;
}
@@ -195,6 +199,7 @@ export function createRendering({
/**
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
* The shape points north (up) so billboard rotation maps directly to heading.
* An aid to navigation, which has no heading, is a ringed disc instead.
* One icon is generated per color+variant and reused across all billboards.
* @param {Object} record - Vessel record (drives per-type tint).
* @param {boolean} selected - True for the white/brighter selected variant.
@@ -202,16 +207,25 @@ export function createRendering({
*/
function shipIcon(record, selected) {
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
const aid = isAidToNavigation(record);
const cssColor = selected
? '#ffffff'
: aid
? AID_TO_NAVIGATION_STYLE.css
: vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
if (vesselState.shipIconCache.has(key))
return vesselState.shipIconCache.get(key);
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
const strokeWidth = selected ? 1.1 : 0.7;
const shape = aid
? `<circle r="8" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth * 2}"/>
<circle r="3" fill="${stroke}"/>`
: `<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g transform="translate(16,16)">
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
${shape}
</g>
</svg>`;
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
@@ -260,18 +274,24 @@ export function createRendering({
);
}
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
/**
* The model item for `record`, its spec rebuilt only when its type or size
* changes; null for a virtual aid to navigation, which keeps its disc: no
* buoy stands there (vesselModelSpec).
*/
function modelItem(record, set) {
if (record.virtual === true) return null;
let item = modelItems.get(record.id);
if (!item || item.record !== record)
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
const specKey = `${record.type}|${record.mmsi}|${record.length}|${record.beam}|${record.name}`;
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = vesselModelSpec(set, record);
}
item.position = getVisual(record).waterline;
item.headingDeg = vesselCourseDeg(record);
// An aid to navigation is a fixed mark: it does not turn to a heading.
item.headingDeg = item.spec?.fixed ? 0 : vesselCourseDeg(record);
item.show = record.id !== state.cockpitSubjectId;
return item;
}
@@ -294,7 +314,8 @@ export function createRendering({
limitSq
)
continue;
into.set(record.id, modelItem(record, set));
const item = modelItem(record, set);
if (item) into.set(record.id, item);
}
}
@@ -330,8 +351,10 @@ export function createRendering({
gatherModelItems(set, next);
} else {
for (const [id, item] of modelItems)
if (state.records.byId.get(id) === item.record)
next.set(id, modelItem(item.record, set));
if (state.records.byId.get(id) === item.record) {
const kept = modelItem(item.record, set);
if (kept) next.set(id, kept);
}
}
for (const [id, item] of modelItems)
if (next.get(id) !== item) handBackVisual(item.record, occluder);
@@ -465,7 +488,8 @@ export function createRendering({
visible &&
record.id !== state.cockpitSubjectId &&
!visual.modelOwnsVisual;
if (visible && doRotations && scene) {
// An aid's disc is never turned (vesselCourseDeg).
if (visible && doRotations && scene && !isAidToNavigation(record)) {
const rot = screenProjectedRotation(
scene,
visual.position,
+3
View File
@@ -1,3 +1,4 @@
import { createTerrainSurface } from './terrainSurface.js';
/** Construct an instance-owned groundFloor service with explicit dependencies. */
export function createGroundFloor({ terrain, signal }) {
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
@@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) {
{ once: true },
);
return {
// The terrain globe's loaded surface, read once per point for every layer.
terrainSurface: createTerrainSurface(),
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
+259
View File
@@ -0,0 +1,259 @@
import * as Cesium from 'cesium';
/**
* The height of the terrain globe's loaded surface at a point, kept per point
* and read within a time budget.
*
* `globe.getHeight` casts a ray against the mesh of the tile drawn under the
* point: geometry work, not a table lookup, and dearer the finer the tile. A
* layer that asks for every vehicle, trail fix or site on every pass asks the
* same few thousand points over and over, so the answers are kept and the
* reads are rationed. One instance serves every layer of an application, so
* the layers share one budget instead of each spending its own.
*/
/**
* While the camera moves or tiles load, a kept height is reused this long
* before the point is read again.
*/
export const TERRAIN_HEIGHT_FRESH_MS = 2_000;
/**
* A height read once the camera has been still and the globe's tiles loaded
* for this long is kept until either changes: the globe draws a point from
* another tile only after one of them does.
*/
export const TERRAIN_SETTLE_MS = 5_000;
/** Read budgets are counted over windows of this length. */
export const TERRAIN_READ_WINDOW_MS = 200;
/**
* Milliseconds of terrain reads in one window. Past it a point never read
* takes the last height read in its ~111 m floor cell, or waits for a later
* request; a MARKER point is read regardless.
*/
export const TERRAIN_READ_BUDGET_MS = 16;
/**
* The share of that budget re-reads of kept points may use, the longest-kept
* first, leaving the rest for points never read.
*/
export const TERRAIN_REFRESH_BUDGET_MS = 12;
/**
* How much a height matters to what is drawn, lowest first. BACKGROUND is a
* point nobody is looking at (the undrawn trail history of a vehicle) and may
* use a quarter of each budget; PATH is drawn geometry; MARKER is a thing's
* own position, read even when the window's budget is spent, since a marker
* without a height is not drawn.
*/
export const TERRAIN_NEED = Object.freeze({
BACKGROUND: 0,
PATH: 1,
MARKER: 2,
});
/** Points kept; the oldest quarter is dropped past it. */
export const TERRAIN_HEIGHT_CACHE_MAX = 60_000;
/** A number unique to a point on a `scale`-per-degree grid. */
function gridKey(lat, lon, scale) {
return (
(Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) +
Math.round(lon * scale) +
180 * scale
);
}
/**
* @returns {{
* height: (viewer: object, lat: number, lon: number, need?: number) => number|undefined,
* observe: (viewer: object) => void,
* waitingCount: () => number,
* stats: () => {reads: number, reused: number, deferred: number, borrowed: number, held: number},
* clear: () => void,
* }}
*/
export function createTerrainSurface() {
const point = new Cesium.Cartographic();
/** Point key → `{ h, at }`: the height read there and when. */
const heights = new Map();
/** Floor-cell key → the last height read anywhere in that cell. */
const cells = new Map();
/** The globe and terrain provider the kept heights were read from. */
let globeRead = null;
let providerRead = null;
let windowAt = -Infinity;
let readMs = 0;
let refreshMs = 0;
// Per need (background, path): the oldest read among the points this window
// left for later, and the bound the next window re-reads up to.
const deferredAt = [Infinity, Infinity];
const refreshUpTo = [Infinity, Infinity];
let waiting = 0;
/** When the camera last moved or the globe was last loading tiles. */
let changedAt = -Infinity;
let cameraLon = NaN,
cameraLat = NaN,
cameraHeight = NaN;
const counters = { reads: 0, reused: 0, deferred: 0, borrowed: 0 };
function clear() {
heights.clear();
cells.clear();
globeRead = providerRead = null;
windowAt = -Infinity;
readMs = refreshMs = 0;
deferredAt.fill(Infinity);
refreshUpTo.fill(Infinity);
changedAt = -Infinity;
cameraLon = cameraLat = cameraHeight = NaN;
}
/**
* The globe redraws a point from another tile only after the camera moves
* or a tile arrives: note when either was last seen.
*/
function noteChange(viewer, globe, now) {
const camera = viewer.camera?.positionCartographic;
if (
globe.tilesLoaded === false ||
camera?.longitude !== cameraLon ||
camera?.latitude !== cameraLat ||
camera?.height !== cameraHeight
) {
changedAt = now;
cameraLon = camera?.longitude;
cameraLat = camera?.latitude;
cameraHeight = camera?.height;
}
}
/** The viewer's terrain globe, the one whose heights are held, or null. */
function terrainGlobe(viewer) {
const globe = viewer?.scene?.globe;
if (globe?.show !== true || typeof globe.getHeight !== 'function')
return null;
// A height belongs to the terrain it was read from: another globe or
// another terrain provider is another surface, however settled the camera.
const provider = viewer.terrainProvider;
if (globe !== globeRead || provider !== providerRead) {
clear();
globeRead = globe;
providerRead = provider;
}
return globe;
}
/**
* Look at the camera and the globe's tile loading without asking for a
* height. The surface only sees a change when it is asked something, so a
* caller that asks nothing while tiles load (it waits for them) calls this
* on its own walk; otherwise the load would pass unseen and the heights
* kept from before it would never be read again.
* @param {object} viewer
*/
function observe(viewer) {
const globe = terrainGlobe(viewer);
if (globe) noteChange(viewer, globe, performance.now());
}
/**
* The loaded terrain's height at a point, in metres above the ellipsoid, or
* undefined when it is not known yet or the viewer has no terrain globe
* showing. A point read within the reuse time answers from memory, and so
* does one read after the camera and the globe's tiles had settled: the
* surface under it cannot have changed since. Past the window's budget a
* kept point keeps its last height and a new point borrows its floor
* cell's. Requests tend to come in the same order every pass, so re-reads
* go to the longest-kept points first: otherwise the first few would be
* re-read forever and the rest never.
* @param {object} viewer The Cesium viewer whose globe is read.
* @param {number} lat Degrees.
* @param {number} lon Degrees.
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
*/
function height(viewer, lat, lon, need = TERRAIN_NEED.PATH) {
const globe = terrainGlobe(viewer);
if (!globe) return undefined;
const now = performance.now();
if (now - windowAt >= TERRAIN_READ_WINDOW_MS) {
for (let i = 0; i < deferredAt.length; i++) {
refreshUpTo[i] = deferredAt[i];
deferredAt[i] = Infinity;
}
windowAt = now;
readMs = refreshMs = 0;
noteChange(viewer, globe, now);
}
const key = gridKey(lat, lon, 1e5);
const held = heights.get(key);
if (
held &&
(now - held.at < TERRAIN_HEIGHT_FRESH_MS ||
held.at >= changedAt + TERRAIN_SETTLE_MS)
) {
counters.reused++;
return held.h;
}
const cellKey = gridKey(lat, lon, 1e3);
const marker = need === TERRAIN_NEED.MARKER;
// Background may use a quarter of each budget; drawn geometry the rest.
const share = need === TERRAIN_NEED.BACKGROUND ? 0.25 : 1;
const affordable =
marker && !held
? true
: readMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) &&
(!held ||
marker ||
(refreshMs < TERRAIN_REFRESH_BUDGET_MS * share &&
held.at <= refreshUpTo[need]));
if (!affordable) {
counters.deferred++;
if (held) {
if (!marker && held.at < deferredAt[need]) deferredAt[need] = held.at;
waiting++;
return held.h;
}
const borrowed = cells.get(cellKey);
if (borrowed !== undefined) {
counters.borrowed++;
return borrowed;
}
waiting++;
return undefined;
}
Cesium.Cartographic.fromDegrees(lon, lat, 0, point);
const rendered = globe.getHeight(point);
const spent = performance.now() - now;
readMs += spent;
if (held) refreshMs += spent;
counters.reads++;
// A tile that left the globe keeps the height it gave.
if (!Number.isFinite(rendered)) return held?.h;
if (held) {
held.h = rendered;
held.at = now;
} else {
if (heights.size >= TERRAIN_HEIGHT_CACHE_MAX) {
let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2;
for (const old of heights.keys()) {
heights.delete(old);
if (--drop <= 0) break;
}
cells.clear();
}
heights.set(key, { h: rendered, at: now });
}
cells.set(cellKey, rendered);
return rendered;
}
return {
height,
observe,
/**
* Requests so far that were left unread or due a re-read. A caller that
* reads it before and after its own pass learns whether to come back.
*/
waitingCount: () => waiting,
/** Read counters since creation, for checks. */
stats: () => ({ ...counters, held: heights.size }),
clear,
};
}
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
TERRAIN_NEED,
TERRAIN_READ_BUDGET_MS,
TERRAIN_READ_WINDOW_MS,
TERRAIN_SETTLE_MS,
createTerrainSurface,
} from './terrainSurface.js';
/** A viewer over a terrain globe whose every read costs `costMs` of a clock the test owns. */
function world(t, { costMs = 0, surface = () => 100 } = {}) {
const clock = { now: 5_000_000 };
t.mock.method(performance, 'now', () => clock.now);
const reads = [];
const model = { surface, costMs };
const globe = () => ({
show: true,
getHeight(point) {
const lat = Cesium.Math.toDegrees(point.latitude);
const lon = Cesium.Math.toDegrees(point.longitude);
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
clock.now += model.costMs;
return model.surface(lat, lon);
},
});
const viewer = {
scene: { globe: globe() },
camera: {
positionCartographic: { longitude: 0, latitude: 0, height: 900 },
},
};
return { viewer, clock, reads, model, globe };
}
test('a viewer without a terrain globe showing has no terrain height', (t) => {
const { viewer, reads } = world(t);
const surface = createTerrainSurface();
viewer.scene.globe.show = false;
assert.equal(surface.height(viewer, 42, -71), undefined);
assert.equal(surface.height({ scene: {} }, 42, -71), undefined);
assert.equal(surface.height(null, 42, -71), undefined);
assert.equal(reads.length, 0);
});
test('every caller of one surface shares its heights and its budget', (t) => {
const costMs = 2;
const { viewer, reads, clock } = world(t, { costMs });
const surface = createTerrainSurface();
// Two layers asking about the same street: the second reads nothing.
const transit = () => surface.height(viewer, 42.36, -71.06);
const rail = () => surface.height(viewer, 42.36, -71.06);
assert.equal(transit(), 100);
assert.equal(rail(), 100);
assert.equal(reads.length, 1);
// And what one spends in a window the other cannot spend again.
const affordable = TERRAIN_READ_BUDGET_MS / costMs;
let lon = -72;
const fresh = () => surface.height(viewer, 42, (lon -= 0.001));
for (let i = 0; i < affordable - 1; i++) assert.equal(fresh(), 100);
const before = surface.waitingCount();
assert.equal(fresh(), undefined, 'the window is spent, whoever asks');
assert.equal(surface.waitingCount(), before + 1);
assert.equal(
surface.height(viewer, 43, -73, TERRAIN_NEED.MARKER),
100,
'a marker is still read',
);
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(fresh(), 100);
assert.deepEqual(surface.stats(), {
reads: affordable + 2,
reused: 1,
deferred: 1,
borrowed: 0,
held: affordable + 2,
});
});
test("another globe's surface is read afresh", (t) => {
const { viewer, reads, clock, model, globe } = world(t, {
surface: () => 10,
});
const surface = createTerrainSurface();
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
clock.now += TERRAIN_SETTLE_MS;
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
clock.now += 60_000;
model.surface = () => 250;
assert.equal(surface.height(viewer, 42.36, -71.06), 10, 'settled: kept');
// The map source changed and the viewer has a new globe.
viewer.scene.globe = globe();
assert.equal(surface.height(viewer, 42.36, -71.06), 250);
assert.equal(reads.length, 3);
assert.equal(surface.stats().held, 1);
});
test('another terrain provider is read afresh, however settled the old heights were', (t) => {
const { viewer, reads, model, clock } = world(t);
const surface = createTerrainSurface();
viewer.terrainProvider = {};
const at = () => surface.height(viewer, 42.36, -71.06);
// Read once the camera and tiles have long settled: kept indefinitely.
surface.height(viewer, 10, 10);
clock.now += TERRAIN_SETTLE_MS * 2;
reads.length = 0;
assert.equal(at(), 100);
clock.now += TERRAIN_SETTLE_MS * 10;
model.surface = () => 250;
assert.equal(at(), 100, 'same terrain: the kept height stands');
assert.equal(reads.length, 1);
const real = (viewer.terrainProvider = {});
assert.equal(at(), 250, 'a new provider is a new surface');
assert.equal(reads.length, 2);
// Terrain → flat → the same provider. A caller answers a flat globe
// itself and asks nothing, so it shows the flat provider with observe().
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
surface.observe(viewer);
model.surface = () => 30;
viewer.terrainProvider = real;
assert.equal(at(), 30);
assert.equal(reads.length, 3);
});
test('observe() lets a caller that asks nothing while tiles load report the load', (t) => {
const { viewer, reads, clock, model } = world(t, { surface: () => 10 });
const surface = createTerrainSurface();
const read = () => surface.height(viewer, 42.36, -71.06);
read();
clock.now += TERRAIN_SETTLE_MS;
read();
clock.now += 60_000;
assert.equal(reads.length, 2, 'settled and kept');
// Finer tiles load and finish while nobody asks for a height.
model.surface = () => 14;
viewer.scene.globe.tilesLoaded = false;
clock.now += 1_000;
surface.observe(viewer);
viewer.scene.globe.tilesLoaded = true;
clock.now += 3_000;
assert.equal(
read(),
14,
'the height kept from before the load is read again',
);
assert.equal(reads.length, 3);
surface.observe(null);
surface.observe({ scene: {} });
});
+3 -1
View File
@@ -12,7 +12,9 @@
* snapshots. A vessel record's `id` is whatever its source gives, such as a
* provider id; its `mmsi` is an optional attribute ('' or absent when
* unknown), never its key, since vessels can share an MMSI. A source that
* only has MMSIs uses the MMSI as the id.
* only has MMSIs uses the MMSI as the id. A vessel record's `virtual` is true
* for a virtual aid to navigation, a mark broadcast where nothing physical
* stands (AIS message 21's virtual flag), else false; any source may set it.
*
* A source may also declare a timeline (see readSnapshotTimeline): it can
* answer a snapshot at a given time, so the layer follows the world clock.
+12
View File
@@ -221,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
assert.equal(row.altitudeDatum, 'sea-surface');
assert.equal(row.observedAtMs, now);
assert.equal('extra' in row, false);
// Only a source's own true marks a virtual aid to navigation.
assert.equal(row.virtual, false);
const base = { mmsi: '993672001', lat: 30, lon: -97 };
assert.equal(
normalizeVesselObservation({ ...base, virtual: true }).virtual,
true,
);
for (const virtual of ['true', 1, false, null])
assert.equal(
normalizeVesselObservation({ ...base, virtual }).virtual,
false,
);
assert.equal(
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
null,
+2
View File
@@ -55,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
epoch(row.last_position_epoch, 1000) ??
epoch(Date.parse(row.last_position_UTC)),
altitudeDatum: 'sea-surface',
// A virtual aid to navigation: a mark broadcast with nothing there.
virtual: row.virtual === true,
};
}
+8 -2
View File
@@ -21,7 +21,10 @@ import {
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
import {
isAidToNavigation,
normalizeVesselType,
} from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
@@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({
title: 'Vessels in an area',
description:
'Ships currently reported by AIS inside an area, nearest the center ' +
'first, optionally filtered by vessel type. group_by counts every matched ' +
'first, optionally filtered by vessel type. Buoys, beacons and other ' +
'aids to navigation are not ships and are left out. group_by counts every matched ' +
'ship by type or destination, and stats summarizes their speed; both ' +
'cover all matches, not just the rows returned.',
inputSchema: {
@@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({
const rows = snapshot.records
.filter(
(record) =>
// A buoy or beacon in the feed is not a ship to count.
!isAidToNavigation(record) &&
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted ||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
+41
View File
@@ -467,3 +467,44 @@ test('vessels in an area can be counted by destination with a speed summary', as
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
});
});
test('vessels in an area leave out buoys and beacons', async () => {
const withAids = {
...vessels(),
async getSnapshot() {
return {
records: [
...records,
vessel('993672001', 37.8, -122.42, {
name: 'SF APPROACH N',
type: 'Cardinal Mark N',
speedMps: 0,
headingDeg: 135,
}),
vessel('993672002', 37.79, -122.43, { type: '' }),
],
source: 'AISStream',
observedAtMs: Date.UTC(2026, 0, 1),
freshness: 'current',
};
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: withAids },
});
const all = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.equal(all.data.total, 2);
assert.equal(all.summary.split(' in ')[0], '2 vessels');
assert.deepEqual(all.data.rows.map((row) => row.mmsi).sort(), [
'366999712',
'538001234',
]);
assert.equal(all.data.breakdown.unknown_count, 0);
assert.ok(
!all.data.breakdown.groups.some((group) => /mark/i.test(group.value)),
);
});