mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
15
Commits
No files matched your search
+1
-1
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
|||||||
AISSTREAM_API_KEY=
|
AISSTREAM_API_KEY=
|
||||||
# Optional AISStream subscription filters.
|
# Optional AISStream subscription filters.
|
||||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
|
||||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||||
# default worldwide subscription — a narrow filter above can be legitimately
|
# default worldwide subscription — a narrow filter above can be legitimately
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Fix the page freezing for seconds at a time with Transit on over a terrain
|
||||||
|
globe, most visibly while zooming over a large fleet. Every reread asked the
|
||||||
|
terrain for the height of every trail fix of every vehicle in view; heights
|
||||||
|
are now kept per point, read again only while the camera moves or tiles
|
||||||
|
load, and read within a time budget that puts vehicle positions first.
|
||||||
|
Switching the terrain provider or globe starts the kept heights afresh
|
||||||
|
(mazy1998, review by kvnloo, #1688).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Read terrain heights through one shared, cached and budgeted reader
|
||||||
|
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
|
||||||
|
data layers. Opening Data Centers no longer blocks the page for over two
|
||||||
|
seconds, opening Bike Share for most of a second, and Rail asks the terrain
|
||||||
|
about a twentieth as often (mazy1998, #1689).
|
||||||
@@ -0,0 +1,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
@@ -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
|
settings and screen position rather than observed brightness. Selected-trail
|
||||||
diagnostics report every condition even when selection fails.
|
diagnostics report every condition even when selection fails.
|
||||||
|
|
||||||
|
Heights of the terrain globe's loaded surface come from one shared reader,
|
||||||
|
`src/services/terrainSurface.js`, which the ground service hands to every layer
|
||||||
|
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
|
||||||
|
drawn tile's mesh, so the reader keeps each answer per point, about a metre
|
||||||
|
apart. While the camera moves or tiles load a kept height is read again after
|
||||||
|
2 s; a height read once both have been still for 5 s is kept until either
|
||||||
|
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
|
||||||
|
of it for re-reads, longest-kept first. A request ranks as a marker (always
|
||||||
|
read), drawn geometry, or background (a quarter of each budget). Past the
|
||||||
|
budget a kept point keeps its last height and a new point takes the last
|
||||||
|
height read in its ~111 m floor cell or waits.
|
||||||
|
|
||||||
|
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
|
||||||
|
the path a marker is drawn along next, the undrawn trail history of
|
||||||
|
unselected vehicles as background, and its reread cycle continues until no
|
||||||
|
point waits. Before this, every reread cast a ray for every trail fix of
|
||||||
|
every vehicle in view, which blocked the page for up to 19 s at a time over
|
||||||
|
a 650-vehicle fleet while zooming.
|
||||||
|
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
|
||||||
|
so a moving train reads a few times a second and a standing one not at all.
|
||||||
|
- Local data layers (Data Centers, Dams) read terrain floors through it, and
|
||||||
|
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
|
||||||
|
frame per layer and continue on the next frames.
|
||||||
|
- Bike Share draws a city's stations at once at the fallback height and finds
|
||||||
|
their ground heights afterwards in 4 ms steps, from the terrain reader on a
|
||||||
|
terrain globe and from depth samples where the globe is hidden.
|
||||||
|
|
||||||
|
Verified in the browser on a keyless terrain globe only: Boston for Transit,
|
||||||
|
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
|
||||||
|
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
|
||||||
|
and any city steeper than Boston. Street Traffic keeps its own height cache
|
||||||
|
and ALPR cameras their own sample budget.
|
||||||
|
|
||||||
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
||||||
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
||||||
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
||||||
@@ -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
|
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||||
can retain a bounded retry delay of up to 30 seconds.
|
can retain a bounded retry delay of up to 30 seconds.
|
||||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 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.
|
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
||||||
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
|
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
|
||||||
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
|
|||||||
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
|
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
|
||||||
|
|
||||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ How the layers use them:
|
|||||||
type's published length (a vessel to its reported AIS length and beam), so
|
type's published length (a vessel to its reported AIS length and beam), so
|
||||||
every model is drawn life size. Ground objects, balloons and obstacles in
|
every model is drawn life size. Ground objects, balloons and obstacles in
|
||||||
the `other` category are drawn at the size they are built.
|
the `other` category are drawn at the size they are built.
|
||||||
|
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
|
||||||
|
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
|
||||||
|
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
|
||||||
|
port or starboard hand, safe water, isolated danger, special mark), a red
|
||||||
|
can when its type names no mark, at its built size and with no heading
|
||||||
|
(`vesselModelSpec`, `aidToNavigationModel`).
|
||||||
- An aircraft model's origin is its axis; it stands that model's scaled
|
- An aircraft model's origin is its axis; it stands that model's scaled
|
||||||
clearance above the ground. A ship's origin is its waterline, and a transit
|
clearance above the ground. A ship's origin is its waterline, and a transit
|
||||||
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"src/data/localGeojsonLod.js",
|
"src/data/localGeojsonLod.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/infrastructureOverlayEntry.js",
|
"src/data/infrastructureOverlayEntry.js",
|
||||||
"src/sources/infrastructureData.js"
|
"src/sources/infrastructureData.js",
|
||||||
|
"src/services/terrainSurface.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -251,6 +252,7 @@
|
|||||||
"src/data/adsbLolFallback.js",
|
"src/data/adsbLolFallback.js",
|
||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js",
|
"src/data/aisWatchdog.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"server/providers/acartia.js",
|
"server/providers/acartia.js",
|
||||||
@@ -1514,6 +1516,7 @@
|
|||||||
"exports": ["./layers/flights"],
|
"exports": ["./layers/flights"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -1593,6 +1596,7 @@
|
|||||||
"src/layers/flights/modelSpec.js",
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/flights/policy.js",
|
"src/layers/flights/policy.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -2110,6 +2114,7 @@
|
|||||||
"src/layers/bikeshare/state.js",
|
"src/layers/bikeshare/state.js",
|
||||||
"src/layers/bikeshare/testing.js",
|
"src/layers/bikeshare/testing.js",
|
||||||
"src/layers/bikeshare/viewport.js",
|
"src/layers/bikeshare/viewport.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
@@ -3061,6 +3066,7 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -4241,6 +4247,7 @@
|
|||||||
"src/scopeMaskPolicy.js",
|
"src/scopeMaskPolicy.js",
|
||||||
"src/scopeMask.js",
|
"src/scopeMask.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
"src/data/pointerOwnerNames.js",
|
"src/data/pointerOwnerNames.js",
|
||||||
@@ -4589,6 +4596,7 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -4817,6 +4825,7 @@
|
|||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftEmergency.js",
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js"
|
"src/data/fleetTrailAnchors.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
@@ -5207,6 +5216,7 @@
|
|||||||
"exports": ["./layers/transit/standalone"],
|
"exports": ["./layers/transit/standalone"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
@@ -5263,6 +5273,7 @@
|
|||||||
"src/renderGovernor.js",
|
"src/renderGovernor.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
@@ -5299,6 +5310,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftModelLighting.js",
|
"src/data/aircraftModelLighting.js",
|
||||||
@@ -5314,6 +5326,7 @@
|
|||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
"src/layers/transit/cockpitSubject.js",
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
|||||||
'ExtendedClassBPositionReport',
|
'ExtendedClassBPositionReport',
|
||||||
'ShipStaticData',
|
'ShipStaticData',
|
||||||
'StaticDataReport',
|
'StaticDataReport',
|
||||||
|
// Buoys, beacons and other aids to navigation (AIS message 21).
|
||||||
|
'AidsToNavigationReport',
|
||||||
];
|
];
|
||||||
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
||||||
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
|
import {
|
||||||
|
aidToNavigationTypeName,
|
||||||
|
isAidToNavigation,
|
||||||
|
} from '../../../src/data/vesselTypes.js';
|
||||||
export const AISSTREAM_CACHE_MAX = 50000;
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const aidReport = messageType === 'AidsToNavigationReport';
|
||||||
_aisStreamVessels.set(mmsi, {
|
_aisStreamVessels.set(mmsi, {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
name:
|
||||||
|
(aidReport && aidNameFromAis(message)) ||
|
||||||
|
vesselNameFromAis(metadata, message, staticData) ||
|
||||||
|
`MMSI ${mmsi}`,
|
||||||
mmsi,
|
mmsi,
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
type: vesselTypeFromAis(message, staticData),
|
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
||||||
|
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
|
||||||
|
type: aidReport
|
||||||
|
? aidToNavigationTypeName(message.Type)
|
||||||
|
: vesselTypeFromAis(message, staticData),
|
||||||
destination: stringValue(message.Destination ?? staticData.destination),
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
length: staticData.length ?? null,
|
length: staticData.length ?? null,
|
||||||
beam: staticData.beam ?? null,
|
beam: staticData.beam ?? null,
|
||||||
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
// Use the AIS message's own report time, not server ingest wall-clock —
|
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||||
// trail spacing and dead reckoning depend on true fix epochs.
|
// trail spacing and dead reckoning depend on true fix epochs.
|
||||||
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
|
// A virtual aid is broadcast where nothing physical stands. Only such a
|
||||||
|
// row carries the field, so ship rows stay their size.
|
||||||
|
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
|
||||||
_updatedAt: Date.now(),
|
_updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid's whole name: an aid-to-navigation report carries the first 20
|
||||||
|
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
|
||||||
|
*/
|
||||||
|
function aidNameFromAis(message) {
|
||||||
|
const name = stringValue(message.Name);
|
||||||
|
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function vesselTypeFromAis(message, staticData = {}) {
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
return stringValue(
|
return stringValue(
|
||||||
message.Type ??
|
message.Type ??
|
||||||
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current rows, newest first, with every vessel ahead of every aid to
|
||||||
|
* navigation: a capped request never gives up a ship for a buoy.
|
||||||
|
*/
|
||||||
export function aisStreamRows(maxRows) {
|
export function aisStreamRows(maxRows) {
|
||||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
const rows = [];
|
const vessels = [];
|
||||||
|
const aids = [];
|
||||||
for (const row of _aisStreamVessels.values()) {
|
for (const row of _aisStreamVessels.values()) {
|
||||||
if (row._updatedAt >= cutoff) rows.push(row);
|
if (row._updatedAt >= cutoff)
|
||||||
|
(isAidToNavigation(row) ? aids : vessels).push(row);
|
||||||
}
|
}
|
||||||
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
||||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
vessels.sort(newestFirst);
|
||||||
|
if (vessels.length < maxRows) aids.sort(newestFirst);
|
||||||
|
return vessels
|
||||||
|
.concat(aids)
|
||||||
|
.slice(0, maxRows)
|
||||||
|
.map(({ _updatedAt, ...row }) => row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisStreamCache() {
|
function pruneAisStreamCache() {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function createApplicationCatalog({
|
|||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
createApplicationBikeshare({ surface, source: sources.bikeshare }),
|
||||||
createApplicationDirections({ source: sources.directions }),
|
createApplicationDirections({ source: sources.directions }),
|
||||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||||
createApplicationOsmPlaces(),
|
createApplicationOsmPlaces(),
|
||||||
@@ -205,7 +205,10 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
createApplicationCyclones({ feed: sources.cyclones }),
|
createApplicationCyclones({ feed: sources.cyclones }),
|
||||||
...createOsmOverlayLayers(),
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers({
|
||||||
|
...localGeoJsonServices,
|
||||||
|
terrainSurface: surface.groundFloor.terrainSurface,
|
||||||
|
}),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
createApplicationRail({
|
createApplicationRail({
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
|
|||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationBikeshare({ source }) {
|
export function createApplicationBikeshare({ source, surface }) {
|
||||||
return createBikeshareLayer({
|
return createBikeshareLayer({
|
||||||
source,
|
source,
|
||||||
services: { render, sprites, picking, overlays },
|
services: {
|
||||||
|
render,
|
||||||
|
sprites,
|
||||||
|
picking,
|
||||||
|
overlays,
|
||||||
|
// Station heights come from the terrain surface every layer shares.
|
||||||
|
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
|||||||
_setVesselStateForTest({ enabled: false });
|
_setVesselStateForTest({ enabled: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation card shows its kind, never a speed or heading', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
name: 'SF APPROACH N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
speed: 0,
|
||||||
|
course: 135,
|
||||||
|
heading: 135,
|
||||||
|
});
|
||||||
|
const card = buildVesselCard(aid);
|
||||||
|
assert.deepEqual(card.details, ['CARDINAL MARK N']);
|
||||||
|
assert.equal(card.accent, '217, 210, 192');
|
||||||
|
const selected = buildSelectedVesselCard(aid);
|
||||||
|
assert.equal(selected.details[0], 'CARDINAL MARK N');
|
||||||
|
assert.equal(selected.accent, '217, 210, 192');
|
||||||
|
// Known only by its MMSI, an aid is named as one.
|
||||||
|
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
|
||||||
|
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
|
||||||
|
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
|
||||||
|
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||||
|
try {
|
||||||
|
Object.assign(harness.record, {
|
||||||
|
type: 'Port hand Mark',
|
||||||
|
speed: 0,
|
||||||
|
heading: 135,
|
||||||
|
course: 135,
|
||||||
|
});
|
||||||
|
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
|
||||||
|
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
|
||||||
|
harness.record.virtual = true;
|
||||||
|
aisLiveVesselsLayer.selectById(harness.record.mmsi);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation card says it is virtual', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Isolated danger',
|
||||||
|
virtual: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
|
||||||
|
assert.equal(
|
||||||
|
buildSelectedVesselCard(aid).details[0],
|
||||||
|
'ISOLATED DANGER · VIRTUAL',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -316,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
|
|||||||
assert.equal(row.length, 12);
|
assert.equal(row.length, 12);
|
||||||
assert.equal(row.beam, 4);
|
assert.equal(row.beam, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
|
||||||
|
assert.equal(
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672001,
|
||||||
|
Type: 20,
|
||||||
|
Name: 'SF APPROACH N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672001',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = rowFor('993672001');
|
||||||
|
assert.equal(row.type, 'Cardinal Mark N');
|
||||||
|
assert.equal(row.name, 'SF APPROACH N');
|
||||||
|
assert.equal(row.speed, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an aid's name runs on into its name extension", () => {
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672002,
|
||||||
|
Type: 13,
|
||||||
|
Name: 'SAN FRANCISCO MAIN S',
|
||||||
|
NameExtension: 'HIP CHANNEL 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672002',
|
||||||
|
ShipName: 'SAN FRANCISCO MAIN S',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
|
||||||
|
// The aid reports last, so it is the newest row.
|
||||||
|
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672003',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = aisStreamRows(50000);
|
||||||
|
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
|
||||||
|
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
|
||||||
|
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
|
||||||
|
const vesselCount =
|
||||||
|
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
|
||||||
|
assert.ok(
|
||||||
|
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
|
||||||
|
const aidReport = (mmsi, VirtualAtoN) => ({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: mmsi,
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', true));
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672005', false));
|
||||||
|
assert.equal(rowFor('993672004').virtual, true);
|
||||||
|
assert.equal('virtual' in rowFor('993672005'), false);
|
||||||
|
// A later real report of the same aid drops the flag.
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', false));
|
||||||
|
assert.equal('virtual' in rowFor('993672004'), false);
|
||||||
|
});
|
||||||
+69
-5
@@ -11,6 +11,9 @@
|
|||||||
* vehicle's road or rail top), and is drawn life size: a type's published
|
* vehicle's road or rail top), and is drawn life size: a type's published
|
||||||
* length over the model's built length is its scale.
|
* length over the model's built length is its scale.
|
||||||
*
|
*
|
||||||
|
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
|
||||||
|
* the set's buoy of its IALA mark, by the mark its type names.
|
||||||
|
*
|
||||||
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
||||||
* fetches the JSON and every GLB once, up front, so no model is fetched
|
* fetches the JSON and every GLB once, up front, so no model is fetched
|
||||||
* mid-session.
|
* mid-session.
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
|
|
||||||
import { classifyAircraft } from './aircraftClass.js';
|
import { classifyAircraft } from './aircraftClass.js';
|
||||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||||
|
import { isAidToNavigation } from './vesselTypes.js';
|
||||||
|
|
||||||
/** Where the model set is served, relative to the application base. */
|
/** Where the model set is served, relative to the application base. */
|
||||||
export const FLEET_MODEL_BASE = '/models/fleet/';
|
export const FLEET_MODEL_BASE = '/models/fleet/';
|
||||||
@@ -84,6 +88,44 @@ const TEXT_SHIP_TYPES = [
|
|||||||
[/pleasure|yacht/i, 37],
|
[/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
|
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
||||||
* emitter category `category` and callsign `callsign`: the first of the
|
* 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
|
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
||||||
* family), reported length and beam (metres) and name.
|
* family), reported length and beam (metres) and name. An aid to navigation
|
||||||
|
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
|
||||||
|
* light, or its MMSI is an aid's) is the buoy of its mark
|
||||||
|
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
|
||||||
|
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
|
||||||
|
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
|
||||||
* @param {{catalog: object, types: object}} set
|
* @param {{catalog: object, types: object}} set
|
||||||
* @param {{type?: string|number|null, length?: number|null,
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
* beam?: number|null, name?: string|null}} vessel
|
* virtual?: boolean, length?: number|null, beam?: number|null,
|
||||||
* @returns {object} {@link fleetModelSpec}
|
* name?: string|null}} vessel
|
||||||
|
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
|
||||||
|
* null for a virtual aid
|
||||||
*/
|
*/
|
||||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
export function vesselModelSpec(
|
||||||
|
set,
|
||||||
|
{ type, mmsi, virtual, length, beam, name } = {},
|
||||||
|
) {
|
||||||
|
if (virtual === true) return null;
|
||||||
|
if (isAidToNavigation({ type, mmsi })) {
|
||||||
|
const model = aidToNavigationModel(type);
|
||||||
|
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
|
||||||
|
if (set.catalog[known])
|
||||||
|
return Object.freeze({
|
||||||
|
...fleetModelSpec(set, known, null),
|
||||||
|
fixed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||||
// typical length keeps the model's proportions.
|
// typical length keeps the model's proportions.
|
||||||
const reported = hullLength(length ?? undefined);
|
const reported = hullLength(length ?? undefined);
|
||||||
@@ -460,6 +522,8 @@ export function namedFleetModels(types) {
|
|||||||
transit.default[0],
|
transit.default[0],
|
||||||
...Object.keys(transit.elevation),
|
...Object.keys(transit.elevation),
|
||||||
...Object.values(CLASS_MODELS),
|
...Object.values(CLASS_MODELS),
|
||||||
|
...AID_MODELS.map(([, model]) => model),
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
FLEET_MODEL_BASE,
|
FLEET_MODEL_BASE,
|
||||||
|
aidToNavigationModel,
|
||||||
aircraftModelSpec,
|
aircraftModelSpec,
|
||||||
aisShipTypeCode,
|
aisShipTypeCode,
|
||||||
emitterCategory,
|
emitterCategory,
|
||||||
@@ -381,6 +383,67 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
|||||||
assert.equal(unknown.scale, 1);
|
assert.equal(unknown.scale, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aids to navigation are the buoy of the mark their type names', () => {
|
||||||
|
const cases = [
|
||||||
|
['Cardinal Mark N', 'buoy_cardinal_north'],
|
||||||
|
['Beacon, Cardinal E', 'buoy_cardinal_east'],
|
||||||
|
['Cardinal Mark S', 'buoy_cardinal_south'],
|
||||||
|
['Beacon, Cardinal W', 'buoy_cardinal_west'],
|
||||||
|
['Port Hand Mark', 'buoy_lateral_port'],
|
||||||
|
['Beacon, Port hand', 'buoy_lateral_port'],
|
||||||
|
['Starboard Hand Mark', 'buoy_lateral_starboard'],
|
||||||
|
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
|
||||||
|
['Safe Water', 'buoy_safe_water'],
|
||||||
|
['Isolated danger', 'buoy_isolated_danger'],
|
||||||
|
['Beacon, Special Mark', 'buoy_special'],
|
||||||
|
['Special Mark', 'buoy_special'],
|
||||||
|
['Light Vessel / LANBY / Rigs', 'buoy_special'],
|
||||||
|
['RACON', 'buoy_special'],
|
||||||
|
['Light, with Sectors', 'buoy_special'],
|
||||||
|
['Reference point', 'buoy_special'],
|
||||||
|
['Navigation aid', 'buoy_special'],
|
||||||
|
];
|
||||||
|
for (const [type, model] of cases) {
|
||||||
|
assert.equal(aidToNavigationModel(type), model, type);
|
||||||
|
assert.ok(catalog[model], model);
|
||||||
|
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
|
||||||
|
assert.equal(spec.model, model, type);
|
||||||
|
assert.equal(spec.category, 'buoys', type);
|
||||||
|
assert.equal(spec.fixed, true, type);
|
||||||
|
// A buoy is drawn as built, whatever length the report gives.
|
||||||
|
assert.equal(spec.scale, 1, type);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// An aid of no stated kind, known only by its MMSI, is the default can.
|
||||||
|
assert.ok(catalog[DEFAULT_AID_MODEL]);
|
||||||
|
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
|
||||||
|
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
|
||||||
|
assert.equal(unnamed.fixed, true);
|
||||||
|
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
|
||||||
|
// A virtual aid is broadcast where nothing stands: it keeps its disc.
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, {
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
virtual: true,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Vessels, including those that work on buoys, keep their hulls.
|
||||||
|
for (const vessel of [
|
||||||
|
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
|
||||||
|
{ type: '70', mmsi: '366999002' },
|
||||||
|
{ type: 'Tug', mmsi: '' },
|
||||||
|
]) {
|
||||||
|
const spec = vesselModelSpec(set, vessel);
|
||||||
|
assert.notEqual(spec.category, 'buoys', vessel.type);
|
||||||
|
assert.equal(spec.fixed, undefined, vessel.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
||||||
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
||||||
model: 'intercity',
|
model: 'intercity',
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
|
|||||||
'infrastructureOverlayEntry.js',
|
'infrastructureOverlayEntry.js',
|
||||||
'localGeojsonCore.js',
|
'localGeojsonCore.js',
|
||||||
'localGeojsonLod.js',
|
'localGeojsonLod.js',
|
||||||
|
'terrainSurface.js',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
entry.imports,
|
entry.imports,
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import {
|
|||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
|
||||||
/** The catalog's services, with the overlay host each test supplies. */
|
/** The catalog's services, with the overlay host each test supplies. */
|
||||||
const createLocalGeoJsonLayer = (options) =>
|
const createLocalGeoJsonLayer = (options, services = {}) =>
|
||||||
createLayer(options, {
|
createLayer(options, {
|
||||||
...localGeoJsonServices,
|
...localGeoJsonServices,
|
||||||
|
...services,
|
||||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,10 +59,15 @@ class MockLayerEvent {
|
|||||||
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
||||||
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
||||||
* (default: throws like a scene whose tiles are not sampleable yet).
|
* (default: throws like a scene whose tiles are not sampleable yet).
|
||||||
|
* @param {number[][]} [options.points] `[lon, lat]` sites to load
|
||||||
|
* instead of the single dam polygon.
|
||||||
|
* @param {object} [options.services] Extra services for the layer.
|
||||||
*/
|
*/
|
||||||
async function createRealLocalLayerHarness({
|
async function createRealLocalLayerHarness({
|
||||||
sampleHeightSupported = false,
|
sampleHeightSupported = false,
|
||||||
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
||||||
|
points = null,
|
||||||
|
services = {},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
|
|||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
text: async () => JSON.stringify({
|
text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `site-${index}`,
|
||||||
|
properties: { name: `Site ${index}` },
|
||||||
|
// A small outline: the layer stems its centre. (Cesium draws a point
|
||||||
|
// feature as a canvas pin, which Node cannot make.)
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [[
|
||||||
|
[lon - 0.001, lat - 0.001],
|
||||||
|
[lon + 0.001, lat - 0.001],
|
||||||
|
[lon, lat + 0.001],
|
||||||
|
[lon - 0.001, lat - 0.001],
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
})).join('\n') : JSON.stringify({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
id: 'real-dam',
|
id: 'real-dam',
|
||||||
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
||||||
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
|
|||||||
setInputAction() {},
|
setInputAction() {},
|
||||||
destroy() {},
|
destroy() {},
|
||||||
}),
|
}),
|
||||||
});
|
}, services);
|
||||||
try {
|
try {
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
|
|||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
||||||
globe.tilesLoaded = true;
|
globe.tilesLoaded = true;
|
||||||
clock.advance(500);
|
// The terrain surface reuses a height for 2 s before reading the mesh again.
|
||||||
|
clock.advance(2_100);
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
||||||
|
|
||||||
terrain = 110;
|
terrain = 110;
|
||||||
clock.advance(500);
|
clock.advance(2_100);
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
||||||
terrain = 140;
|
terrain = 140;
|
||||||
@@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
|||||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/** A loaded terrain globe that counts its ray casts. */
|
||||||
|
function countingTerrain(height) {
|
||||||
|
const globe = {
|
||||||
|
show: true,
|
||||||
|
tilesLoaded: true,
|
||||||
|
reads: 0,
|
||||||
|
getHeight: () => { globe.reads += 1; return height; },
|
||||||
|
};
|
||||||
|
return globe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of every stem base, in entity order. */
|
||||||
|
function allBaseHeightsM(env) {
|
||||||
|
return env.dataSources[0].entities.values.map(
|
||||||
|
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_SITES = [
|
||||||
|
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
for (let walk = 0; walk < 40; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||||
|
// 20 s parked: a few reads while the surface settles, then none.
|
||||||
|
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
|
||||||
|
const settled = globe.reads;
|
||||||
|
for (let walk = 0; walk < 20; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.setBaseInView(false);
|
||||||
|
for (let walk = 0; walk < 40; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.equal(env.sampleCalls.count, 0);
|
||||||
|
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
|
||||||
|
const asked = [];
|
||||||
|
const terrainSurface = {
|
||||||
|
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
|
||||||
|
};
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
points: [[-97.695, 30.205]],
|
||||||
|
services: { terrainSurface },
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
|
||||||
|
clock.advance(500);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.ok(asked.length >= 2);
|
||||||
|
for (const call of asked) {
|
||||||
|
assert.equal(call.viewer, env.viewer);
|
||||||
|
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
|
||||||
|
`asked at ${call.lat}, ${call.lon}`);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
|
||||||
|
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
|
||||||
|
const heights = new Map();
|
||||||
|
let clock = null;
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
points: SAMPLE_SITES,
|
||||||
|
sampleHeight: (carto) => {
|
||||||
|
clock.advance(15); // one GPU depth render
|
||||||
|
const height = 100 + heights.size;
|
||||||
|
heights.set(`${carto.longitude},${carto.latitude}`, height);
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
||||||
|
installRenderGovernor({ scene: governorScene });
|
||||||
|
governorScene.renders = 0; // discard the governor's own install settling frame
|
||||||
|
clock = installFakeClock(t);
|
||||||
|
t.after(() => {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
env.cleanup();
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
});
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
|
||||||
|
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
|
||||||
|
|
||||||
|
// The following frames arrive well inside the 450 ms walk interval.
|
||||||
|
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
|
||||||
|
clock.advance(16);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
|
||||||
|
}
|
||||||
|
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
|
||||||
|
clock.advance(16);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
|
||||||
|
|
||||||
|
const expected = env.dataSources[0].entities.values.map((entity) => {
|
||||||
|
const carto = entity.__localBaseCarto;
|
||||||
|
return heights.get(`${carto.longitude},${carto.latitude}`);
|
||||||
|
});
|
||||||
|
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
|
||||||
|
for (const entity of env.dataSources[0].entities.values) {
|
||||||
|
const stem = entity.polyline.positions.getValue();
|
||||||
|
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
points: SAMPLE_SITES,
|
||||||
|
sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
|
||||||
|
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
|
||||||
|
});
|
||||||
+134
-15
@@ -11,6 +11,10 @@ import {
|
|||||||
applyInfraEvictionGrace,
|
applyInfraEvictionGrace,
|
||||||
shouldRecomputeInfraLod,
|
shouldRecomputeInfraLod,
|
||||||
} from './localGeojsonLod.js';
|
} from './localGeojsonLod.js';
|
||||||
|
import {
|
||||||
|
createTerrainSurface,
|
||||||
|
TERRAIN_NEED,
|
||||||
|
} from '../services/terrainSurface.js';
|
||||||
|
|
||||||
const DEFAULT_LABEL_MAX = 900;
|
const DEFAULT_LABEL_MAX = 900;
|
||||||
const DEFAULT_LABEL_GRID_PX = 132;
|
const DEFAULT_LABEL_GRID_PX = 132;
|
||||||
@@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
|||||||
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
||||||
*/
|
*/
|
||||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||||
|
/**
|
||||||
|
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
|
||||||
|
* offscreen depth render (about 15 ms measured), and a first walk near a dense
|
||||||
|
* cluster wants one per site in view: 168 of them held the main thread for
|
||||||
|
* 2.6 s. Past the budget the remaining sites queue, and each following frame
|
||||||
|
* takes as many as fit, so the sites reach the same heights a frame or a few
|
||||||
|
* apart instead of all inside one blocked frame.
|
||||||
|
*/
|
||||||
|
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
|
||||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||||
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||||
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
showOsmCredit,
|
showOsmCredit,
|
||||||
hideOsmCredit,
|
hideOsmCredit,
|
||||||
|
terrainSurface,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* What ground sampling shares across a layer's records: the terrain
|
||||||
|
* surface (the application's, so every layer reads a point once and spends
|
||||||
|
* one budget, or this layer's own), the sample time spent this frame, and
|
||||||
|
* the records the frame budget left for a later frame.
|
||||||
|
*/
|
||||||
|
const _ground = {
|
||||||
|
terrain: terrainSurface || createTerrainSurface(),
|
||||||
|
sampleMs: 0,
|
||||||
|
queued: [],
|
||||||
|
};
|
||||||
let _dataSource = null;
|
let _dataSource = null;
|
||||||
let _enabled = false;
|
let _enabled = false;
|
||||||
let _clickHandler = null;
|
let _clickHandler = null;
|
||||||
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
|
|||||||
clearTimeout(_groundRetryTimer);
|
clearTimeout(_groundRetryTimer);
|
||||||
_groundRetryTimer = null;
|
_groundRetryTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample the sites an earlier frame's budget left over, within this
|
||||||
|
* frame's. They were never attempted, so the 2 s retry spacing does not
|
||||||
|
* apply to them.
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {number} now
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function sampleQueuedGround(viewer, now) {
|
||||||
|
const queued = _ground.queued;
|
||||||
|
if (queued.length === 0) return;
|
||||||
|
_ground.queued = [];
|
||||||
|
for (const record of queued) {
|
||||||
|
record.groundSampleQueued = false;
|
||||||
|
// The camera may have left the site since it queued.
|
||||||
|
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
|
||||||
|
const distance = Cesium.Cartesian3.distance(
|
||||||
|
viewer.camera.positionWC,
|
||||||
|
record.base,
|
||||||
|
);
|
||||||
|
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
|
||||||
|
// Past the budget this puts the record back in the queue.
|
||||||
|
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
|
||||||
|
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||||
|
_groundRetryArms = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask for the frame that samples what this one's budget left over. */
|
||||||
|
function requestQueuedGroundFrame() {
|
||||||
|
if (_ground.queued.length > 0)
|
||||||
|
governorRequestRender(`local-ground-sample:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
||||||
sourceId: id,
|
sourceId: id,
|
||||||
host: overlayHost,
|
host: overlayHost,
|
||||||
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
const source = _dataSource;
|
const source = _dataSource;
|
||||||
_dataSource = null;
|
_dataSource = null;
|
||||||
_stemRecords = [];
|
_stemRecords = [];
|
||||||
|
_ground.queued = [];
|
||||||
_stemGeometryDirty = true;
|
_stemGeometryDirty = true;
|
||||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||||
removeEntityContextsForLayer(id);
|
removeEntityContextsForLayer(id);
|
||||||
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
|
|||||||
// Store references for bounded stem scaling and native picking.
|
// Store references for bounded stem scaling and native picking.
|
||||||
feature.__localBaseCarto = carto;
|
feature.__localBaseCarto = carto;
|
||||||
feature.__localBaseCartesian = base;
|
feature.__localBaseCartesian = base;
|
||||||
|
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
||||||
|
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
||||||
const context = registerEntityContext(feature, {
|
const context = registerEntityContext(feature, {
|
||||||
id: `${id}:${recordId}`,
|
id: `${id}:${recordId}`,
|
||||||
layerId: id,
|
layerId: id,
|
||||||
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
|
|||||||
stemPositionBuffers,
|
stemPositionBuffers,
|
||||||
stemPositionBufferIndex: 0,
|
stemPositionBufferIndex: 0,
|
||||||
groundHeight,
|
groundHeight,
|
||||||
|
// Degrees, as the terrain surface takes them.
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
groundSampled: false,
|
groundSampled: false,
|
||||||
|
groundSampleQueued: false,
|
||||||
lastGroundSampleMs: 0,
|
lastGroundSampleMs: 0,
|
||||||
priority,
|
priority,
|
||||||
entry: labels
|
entry: labels
|
||||||
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
|
|||||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||||
if (!_enabled || !_dataSource) return;
|
if (!_enabled || !_dataSource) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
|
// One sample budget per frame, shared by the queue and the walk.
|
||||||
|
_ground.sampleMs = 0;
|
||||||
|
sampleQueuedGround(viewer, now);
|
||||||
|
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
|
||||||
|
requestQueuedGroundFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_lastVisibilityUpdate = now;
|
_lastVisibilityUpdate = now;
|
||||||
|
|
||||||
const cameraPos = viewer.camera.positionWC;
|
const cameraPos = viewer.camera.positionWC;
|
||||||
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
_lastGroundSampleCapability = canSampleGround;
|
_lastGroundSampleCapability = canSampleGround;
|
||||||
let groundRetryPending = false;
|
let groundRetryPending = false;
|
||||||
let groundSampleProgress = false;
|
let groundSampleProgress = false;
|
||||||
|
// This walk asks for no height while tiles load, so it shows the
|
||||||
|
// surface the load itself; see terrainSurface.js observe().
|
||||||
|
_ground.terrain.observe?.(viewer);
|
||||||
|
|
||||||
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
||||||
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
||||||
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
|
|||||||
const terrainFloorChanged = refreshLocalTerrainFloor(
|
const terrainFloorChanged = refreshLocalTerrainFloor(
|
||||||
viewer,
|
viewer,
|
||||||
record,
|
record,
|
||||||
|
_ground,
|
||||||
);
|
);
|
||||||
if (refreshStemGeometry || terrainFloorChanged) {
|
if (refreshStemGeometry || terrainFloorChanged) {
|
||||||
updateLocalStemGeometry(viewer, record, now);
|
updateLocalStemGeometry(viewer, record, now, _ground);
|
||||||
} else if (
|
} else if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
sampleLocalGroundHeight(viewer, record, now)
|
sampleLocalGroundHeight(viewer, record, now, _ground)
|
||||||
) {
|
) {
|
||||||
updateLocalStemGeometry(viewer, record, now, distance);
|
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!wasGroundSampled && record.groundSampled)
|
if (!wasGroundSampled && record.groundSampled)
|
||||||
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
|
|||||||
// the user happens to move. Schedule the frame the retry needs.
|
// the user happens to move. Schedule the frame the retry needs.
|
||||||
// Gated on a sampleable scene and in-range, in-view records only,
|
// Gated on a sampleable scene and in-range, in-view records only,
|
||||||
// so a far camera (or a keyless scene) stays fully idle; the
|
// so a far camera (or a keyless scene) stays fully idle; the
|
||||||
// distance is only computed for still-unsampled stems.
|
// distance is only computed for still-unsampled stems. A record
|
||||||
|
// the frame budget queued is sampled by the frame the queue asks
|
||||||
|
// for, with no timer.
|
||||||
if (
|
if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
|
!record.groundSampleQueued &&
|
||||||
!groundRetryPending &&
|
!groundRetryPending &&
|
||||||
Cesium.Cartesian3.distance(
|
Cesium.Cartesian3.distance(
|
||||||
viewer.camera.positionWC,
|
viewer.camera.positionWC,
|
||||||
record.base,
|
record.base,
|
||||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
groundSampleInView(viewer, record)
|
groundSampleInView(viewer, record, _ground)
|
||||||
) {
|
) {
|
||||||
groundRetryPending = true;
|
groundRetryPending = true;
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
// so the records still waiting get their own bounded run of retries.
|
// so the records still waiting get their own bounded run of retries.
|
||||||
if (groundSampleProgress) _groundRetryArms = 0;
|
if (groundSampleProgress) _groundRetryArms = 0;
|
||||||
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
||||||
|
requestQueuedGroundFrame();
|
||||||
|
|
||||||
const canvas = viewer.scene.canvas;
|
const canvas = viewer.scene.canvas;
|
||||||
const cohort = selectLocalInfrastructureOverlayCohort(
|
const cohort = selectLocalInfrastructureOverlayCohort(
|
||||||
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
|
|||||||
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLocalTerrainFloor(viewer, record) {
|
function refreshLocalTerrainFloor(viewer, record, ground) {
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
||||||
return false;
|
return false;
|
||||||
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
|
|||||||
// Camera motion can refine terrain after the first successful depth sample.
|
// Camera motion can refine terrain after the first successful depth sample.
|
||||||
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
||||||
// another GPU readback just to maintain this floor. Keep roof elevations.
|
// another GPU readback just to maintain this floor. Keep roof elevations.
|
||||||
const height = globe.getHeight?.(record.carto);
|
// The surface keeps the height per point and casts the ray again only after
|
||||||
|
// the camera moves or tiles load. MARKER: the answer is this point's own
|
||||||
|
// height, never a neighbour's, since a height taken here is kept for good.
|
||||||
|
const height = ground.terrain.height(
|
||||||
|
viewer,
|
||||||
|
record.latDeg,
|
||||||
|
record.lonDeg,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(height) ||
|
!Number.isFinite(height) ||
|
||||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||||
@@ -1323,11 +1413,20 @@ function groundSamplePoint(record, height, result) {
|
|||||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||||
* sees it.
|
* sees it.
|
||||||
|
*
|
||||||
|
* The terrain height comes from the shared surface, which keeps it per point:
|
||||||
|
* this runs for every unsampled site in range on every walk, and a ray cast
|
||||||
|
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
|
||||||
|
* height only roughly, so a height the surface has not read yet, or tiles
|
||||||
|
* still loading, count as unknown ground.
|
||||||
*/
|
*/
|
||||||
function groundSampleInView(viewer, record) {
|
function groundSampleInView(viewer, record, ground) {
|
||||||
const camera = viewer.camera;
|
const camera = viewer.camera;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
const terrain =
|
||||||
|
globe?.show && globe.tilesLoaded !== false
|
||||||
|
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
|
||||||
|
: undefined;
|
||||||
const known =
|
const known =
|
||||||
Number.isFinite(terrain) &&
|
Number.isFinite(terrain) &&
|
||||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||||
@@ -1368,18 +1467,30 @@ function groundSampleInView(viewer, record) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleLocalGroundHeight(viewer, record, now) {
|
function sampleLocalGroundHeight(viewer, record, now, ground) {
|
||||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||||
if (!groundSampleInView(viewer, record)) return false;
|
// This frame's samples have used its budget: leave the record for the next
|
||||||
|
// frame, unattempted, so the retry spacing does not hold it back.
|
||||||
|
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
|
||||||
|
if (!record.groundSampleQueued) {
|
||||||
|
record.groundSampleQueued = true;
|
||||||
|
ground.queued.push(record);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!groundSampleInView(viewer, record, ground)) return false;
|
||||||
record.lastGroundSampleMs = now;
|
record.lastGroundSampleMs = now;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||||
let sampled;
|
let sampled;
|
||||||
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
||||||
} catch {
|
} catch {
|
||||||
return false; // tiles not ready; retry on a later bounded update
|
return false; // tiles not ready; retry on a later bounded update
|
||||||
|
} finally {
|
||||||
|
ground.sampleMs += performance.now() - startedAt;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(sampled) ||
|
!Number.isFinite(sampled) ||
|
||||||
@@ -1389,7 +1500,9 @@ function sampleLocalGroundHeight(viewer, record, now) {
|
|||||||
// A coarse scene-depth sample can lie below the terrain already loaded by
|
// A coarse scene-depth sample can lie below the terrain already loaded by
|
||||||
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
||||||
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
||||||
// inactive terrain must not constrain their geometry.
|
// inactive terrain must not constrain their geometry. Read from the globe
|
||||||
|
// itself, once per site: the floor under a sample must be the mesh that is
|
||||||
|
// loaded now, not a height kept from a moment before.
|
||||||
const terrainHeight = globe?.show
|
const terrainHeight = globe?.show
|
||||||
? globe.getHeight?.(record.carto)
|
? globe.getHeight?.(record.carto)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
|
|||||||
record.entity.__localBaseCartesian = record.base;
|
record.entity.__localBaseCartesian = record.base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
function updateLocalStemGeometry(
|
||||||
|
viewer,
|
||||||
|
record,
|
||||||
|
now,
|
||||||
|
ground,
|
||||||
|
knownDistance = null,
|
||||||
|
) {
|
||||||
const distance = Number.isFinite(knownDistance)
|
const distance = Number.isFinite(knownDistance)
|
||||||
? knownDistance
|
? knownDistance
|
||||||
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
||||||
sampleLocalGroundHeight(viewer, record, now);
|
sampleLocalGroundHeight(viewer, record, now, ground);
|
||||||
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
||||||
// minimum made a marker hundreds of metres tall beside a nearby building.
|
// minimum made a marker hundreds of metres tall beside a nearby building.
|
||||||
const effectiveDistance = Math.max(distance, 1);
|
const effectiveDistance = Math.max(distance, 1);
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid to navigation's disc and card accent: a bone white apart from every
|
||||||
|
* ship type's colour.
|
||||||
|
*/
|
||||||
|
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
|
||||||
|
css: '#d9d2c0',
|
||||||
|
accent: '217, 210, 192',
|
||||||
|
});
|
||||||
|
|
||||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||||
// and tests match on them.
|
// and tests match on them.
|
||||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||||
|
|||||||
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
|
|||||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
|
||||||
|
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
|
||||||
|
* floating marks by their cardinal quadrant, lateral side, safe water,
|
||||||
|
* isolated danger or special mark, and the lights, RACONs and reference
|
||||||
|
* points around them. Code 0 names no kind.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_NAMES = Object.freeze([
|
||||||
|
'Navigation aid',
|
||||||
|
'Reference point',
|
||||||
|
'RACON',
|
||||||
|
'Fixed structure off shore',
|
||||||
|
'Emergency wreck marking buoy',
|
||||||
|
'Light, without sectors',
|
||||||
|
'Light, with sectors',
|
||||||
|
'Leading light front',
|
||||||
|
'Leading light rear',
|
||||||
|
'Beacon, Cardinal N',
|
||||||
|
'Beacon, Cardinal E',
|
||||||
|
'Beacon, Cardinal S',
|
||||||
|
'Beacon, Cardinal W',
|
||||||
|
'Beacon, Port hand',
|
||||||
|
'Beacon, Starboard hand',
|
||||||
|
'Beacon, Preferred channel port hand',
|
||||||
|
'Beacon, Preferred channel starboard hand',
|
||||||
|
'Beacon, Isolated danger',
|
||||||
|
'Beacon, Safe water',
|
||||||
|
'Beacon, Special mark',
|
||||||
|
'Cardinal Mark N',
|
||||||
|
'Cardinal Mark E',
|
||||||
|
'Cardinal Mark S',
|
||||||
|
'Cardinal Mark W',
|
||||||
|
'Port hand Mark',
|
||||||
|
'Starboard hand Mark',
|
||||||
|
'Preferred Channel Port hand',
|
||||||
|
'Preferred Channel Starboard hand',
|
||||||
|
'Isolated danger',
|
||||||
|
'Safe Water',
|
||||||
|
'Special Mark',
|
||||||
|
'Light Vessel / LANBY / Rigs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
|
||||||
|
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
|
||||||
|
* @param {string|number|null|undefined} code
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function aidToNavigationTypeName(code) {
|
||||||
|
const text = String(code ?? '').trim();
|
||||||
|
const index = /^\d+$/.test(text) ? Number(text) : -1;
|
||||||
|
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
|
||||||
|
const AID_TYPE_NAME_SET = new Set(
|
||||||
|
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words a vessel type uses for an aid to navigation. A vessel that works on
|
||||||
|
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
|
||||||
|
* not a mark on the water.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_TEXT =
|
||||||
|
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
|
||||||
|
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
|
||||||
|
|
||||||
|
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
||||||
|
const AID_MMSI = /^99\d{7}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each type text seen names an aid. Vessel layers ask per vessel on
|
||||||
|
* their draw passes, and a feed uses few distinct type texts, so each is
|
||||||
|
* matched once; the memo is bounded against a feed of free-form types.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_MEMO = new Map();
|
||||||
|
const AID_TYPE_MEMO_MAX = 1024;
|
||||||
|
|
||||||
|
function typeNamesAid(text) {
|
||||||
|
let aid = AID_TYPE_MEMO.get(text);
|
||||||
|
if (aid !== undefined) return aid;
|
||||||
|
aid =
|
||||||
|
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
|
||||||
|
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
|
||||||
|
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
|
||||||
|
AID_TYPE_MEMO.set(text, aid);
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
|
||||||
|
* RACON or light) rather than a vessel: its type is one of the kinds an aid
|
||||||
|
* report names or uses a word for one, or its MMSI is an aid-to-navigation
|
||||||
|
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
|
||||||
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
|
* virtual?: boolean}} vessel
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
|
||||||
|
if (virtual === true) return true;
|
||||||
|
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
|
||||||
|
if (text && typeNamesAid(text)) return true;
|
||||||
|
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
|
||||||
|
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -776,6 +776,8 @@
|
|||||||
"TANKER": "ناقلة",
|
"TANKER": "ناقلة",
|
||||||
"OTHER": "أخرى",
|
"OTHER": "أخرى",
|
||||||
"VESSEL": "سفينة",
|
"VESSEL": "سفينة",
|
||||||
|
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||||
|
"VIRTUAL": "افتراضي",
|
||||||
"POS: LIVE": "الموضع: مباشر",
|
"POS: LIVE": "الموضع: مباشر",
|
||||||
"POS: {time}Z": "الموضع: {time}Z",
|
"POS: {time}Z": "الموضع: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -416,6 +416,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "SCHIFF",
|
"VESSEL": "SCHIFF",
|
||||||
|
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||||
|
|||||||
@@ -650,6 +650,8 @@
|
|||||||
"TANKER": "PETROLERO",
|
"TANKER": "PETROLERO",
|
||||||
"OTHER": "OTRO",
|
"OTHER": "OTRO",
|
||||||
"VESSEL": "BUQUE",
|
"VESSEL": "BUQUE",
|
||||||
|
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"POS: LIVE": "POS: EN VIVO",
|
"POS: LIVE": "POS: EN VIVO",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS : {name}",
|
"AIS: {name}": "AIS : {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||||
"VESSEL": "NAVIRE",
|
"VESSEL": "NAVIRE",
|
||||||
|
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||||
|
"VIRTUAL": "VIRTUEL",
|
||||||
"{speed}KT": "{speed} ND",
|
"{speed}KT": "{speed} ND",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS : DIRECT",
|
"POS: LIVE": "POS : DIRECT",
|
||||||
|
|||||||
@@ -1149,6 +1149,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "אונייה",
|
"VESSEL": "אונייה",
|
||||||
|
"AID TO NAVIGATION": "עזר ניווט",
|
||||||
|
"VIRTUAL": "וירטואלי",
|
||||||
"{speed}KT": "{speed} קשר",
|
"{speed}KT": "{speed} קשר",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: חי",
|
"POS: LIVE": "POS: חי",
|
||||||
|
|||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||||
"VESSEL": "NAVE",
|
"VESSEL": "NAVE",
|
||||||
|
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||||
|
"VIRTUAL": "VIRTUALE",
|
||||||
"{speed}KT": "{speed}KT",
|
"{speed}KT": "{speed}KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
|
|||||||
@@ -1253,6 +1253,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "航路標識",
|
||||||
|
"VIRTUAL": "仮想",
|
||||||
"{speed}KT": "{speed} kt",
|
"{speed}KT": "{speed} kt",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: ライブ",
|
"POS: LIVE": "POS: ライブ",
|
||||||
|
|||||||
@@ -1289,6 +1289,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "EMBARCAÇÃO",
|
"VESSEL": "EMBARCAÇÃO",
|
||||||
|
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"{speed}KT": "{speed} KT",
|
"{speed}KT": "{speed} KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: AO VIVO",
|
"POS: LIVE": "POS: AO VIVO",
|
||||||
|
|||||||
@@ -394,6 +394,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||||
|
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||||
|
|||||||
@@ -382,6 +382,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "FARTYG",
|
"VESSEL": "FARTYG",
|
||||||
|
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||||
|
|||||||
@@ -1309,6 +1309,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||||
|
"VIRTUAL": "ВІРТУАЛЬНИЙ",
|
||||||
"{speed}KT": "{speed} ВУЗ",
|
"{speed}KT": "{speed} ВУЗ",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||||
|
|||||||
@@ -1229,6 +1229,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航标志",
|
||||||
|
"VIRTUAL": "虚拟",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:实时",
|
"POS: LIVE": "POS:实时",
|
||||||
|
|||||||
@@ -1224,6 +1224,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航標誌",
|
||||||
|
"VIRTUAL": "虛擬",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:實時",
|
"POS: LIVE": "POS:實時",
|
||||||
|
|||||||
@@ -411,7 +411,8 @@
|
|||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional",
|
"Regional",
|
||||||
"{datasets} · {tiles} · {packs}"
|
"{datasets} · {tiles} · {packs}",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -1067,7 +1068,8 @@
|
|||||||
"ICAO: {id}",
|
"ICAO: {id}",
|
||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional"
|
"Regional",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// Station ground heights. A city's stations are all drawn at once at the
|
||||||
|
// fallback height; their ground heights are found afterwards a few
|
||||||
|
// milliseconds at a time, so a city of hundreds of docks never holds the main
|
||||||
|
// thread for the length of hundreds of depth renders. Each case drives the
|
||||||
|
// real rendering part (and the real lifecycle where it tears down) over a
|
||||||
|
// scene whose height reads are counted and timed.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
import { createModel } from './model.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
|
||||||
|
import { createTerrainSurface } from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
|
globalThis.document ??= {
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Milliseconds one fake depth render holds the thread. */
|
||||||
|
const SAMPLE_COST_MS = 1;
|
||||||
|
/** The ground under a point, as the fake scene and globe both report it. */
|
||||||
|
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
|
||||||
|
|
||||||
|
function busyWait(ms) {
|
||||||
|
const until = performance.now() + ms;
|
||||||
|
while (performance.now() < until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `count` stations on a line through Boston. */
|
||||||
|
function stations(count, prefix = 's') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
stations: Array.from({ length: count }, (_, i) => ({
|
||||||
|
station_id: `${prefix}${i}`,
|
||||||
|
name: `Dock ${i}`,
|
||||||
|
lat: 42.3 + i * 1e-4,
|
||||||
|
lon: -71.1 + i * 1e-4,
|
||||||
|
capacity: 10,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendering part over a scene that counts its height reads.
|
||||||
|
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
|
||||||
|
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
|
||||||
|
* `shared`: the application's terrain surface is supplied;
|
||||||
|
* `unloaded`: latitudes whose terrain tile is not drawn.
|
||||||
|
*/
|
||||||
|
function makeLayer({
|
||||||
|
terrainGlobe = false,
|
||||||
|
shared = false,
|
||||||
|
unloaded = () => false,
|
||||||
|
} = {}) {
|
||||||
|
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
|
||||||
|
// sampleHeight calls in this turn of the event loop, and the most in any
|
||||||
|
// turn so far. Two numbers, not a list: a slow run has a great many turns.
|
||||||
|
let thisTurn = 0;
|
||||||
|
let mostInATurn = 0;
|
||||||
|
let ticking = true;
|
||||||
|
const tick = () => {
|
||||||
|
if (!ticking) return;
|
||||||
|
thisTurn = 0;
|
||||||
|
setImmediate(tick);
|
||||||
|
};
|
||||||
|
setImmediate(tick);
|
||||||
|
|
||||||
|
const degrees = (carto) => [
|
||||||
|
Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
];
|
||||||
|
const viewer = {
|
||||||
|
camera: {
|
||||||
|
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
|
||||||
|
changed: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
sampleHeight(carto) {
|
||||||
|
calls.sampleHeight += 1;
|
||||||
|
thisTurn += 1;
|
||||||
|
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
|
||||||
|
busyWait(SAMPLE_COST_MS);
|
||||||
|
return groundAt(...degrees(carto));
|
||||||
|
},
|
||||||
|
globe: {
|
||||||
|
show: terrainGlobe,
|
||||||
|
tilesLoaded: true,
|
||||||
|
getHeight(carto) {
|
||||||
|
calls.getHeight += 1;
|
||||||
|
const [lat, lon] = degrees(carto);
|
||||||
|
return unloaded(lat) ? undefined : groundAt(lat, lon);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primitives: { add() {}, remove() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
const services = {
|
||||||
|
render: {
|
||||||
|
governorRequestRender() {
|
||||||
|
calls.render += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
setOverlayEntries() {},
|
||||||
|
setOverlaySourceVisible() {},
|
||||||
|
clearOverlaySource() {},
|
||||||
|
},
|
||||||
|
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||||
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||||
|
};
|
||||||
|
if (shared) {
|
||||||
|
services.ground = {
|
||||||
|
terrainSurface: {
|
||||||
|
...surface,
|
||||||
|
height(...args) {
|
||||||
|
calls.sharedHeight += 1;
|
||||||
|
return surface.height(...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const state = createState({ services });
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services, parts, source: {} };
|
||||||
|
parts.model = createModel(context);
|
||||||
|
parts.rendering = createRendering(context);
|
||||||
|
parts.selection = {
|
||||||
|
_installClickHandler() {},
|
||||||
|
_clearSelection() {},
|
||||||
|
_onKeyDown() {},
|
||||||
|
};
|
||||||
|
parts.ingestion = { abortAllInFlight() {} };
|
||||||
|
// Leaves the points alone, so a test of disable() sees what disable() stops.
|
||||||
|
parts.viewport = {
|
||||||
|
onCameraChanged() {},
|
||||||
|
deactivateAllCities() {},
|
||||||
|
runProximityCheck: async () => {},
|
||||||
|
};
|
||||||
|
parts.lifecycle = createLifecycle(context);
|
||||||
|
parts.lifecycle.methods.init(viewer);
|
||||||
|
state._pointCollection = {
|
||||||
|
show: true,
|
||||||
|
add: (options) => ({ ...options }),
|
||||||
|
remove() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const heightOf = (key) =>
|
||||||
|
Cesium.Cartographic.fromCartesian(
|
||||||
|
state._stationRenderMap.get(key).point.position,
|
||||||
|
).height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
viewer,
|
||||||
|
calls,
|
||||||
|
rendering: parts.rendering,
|
||||||
|
lifecycle: parts.lifecycle.methods,
|
||||||
|
heightOf,
|
||||||
|
add(cityId, count) {
|
||||||
|
const info = parts.model.parseStationInformation(stations(count, cityId));
|
||||||
|
parts.rendering.ensureCityPoints(cityId, info);
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
/** Wait until every queued station has its height, or fail. */
|
||||||
|
async refined() {
|
||||||
|
const deadline = performance.now() + 20_000;
|
||||||
|
while (parts.rendering.pendingHeightCount() > 0) {
|
||||||
|
assert.ok(performance.now() < deadline, 'refinement finished');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** The most sampleHeight calls made without yielding to the loop. */
|
||||||
|
longestBurst() {
|
||||||
|
return mostInATurn;
|
||||||
|
},
|
||||||
|
done() {
|
||||||
|
ticking = false;
|
||||||
|
parts.rendering.stopHeightRefinement();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
test('a city of stations is drawn at once without sampling heights', (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
const started = performance.now();
|
||||||
|
layer.add('boston', 300);
|
||||||
|
const spent = performance.now() - started;
|
||||||
|
|
||||||
|
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
|
||||||
|
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
|
||||||
|
'drawn at the fallback height',
|
||||||
|
);
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
const info = layer.add('boston', 120);
|
||||||
|
const rendersBefore = layer.calls.render;
|
||||||
|
await layer.refined();
|
||||||
|
|
||||||
|
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
|
||||||
|
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
|
||||||
|
assert.ok(
|
||||||
|
layer.longestBurst() <= 10,
|
||||||
|
`longest burst was ${layer.longestBurst()} samples`,
|
||||||
|
);
|
||||||
|
for (const station of info.values()) {
|
||||||
|
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||||
|
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(actual - expected) < 1e-3,
|
||||||
|
`${station.stationId}: ${actual} vs ${expected}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
|
||||||
|
const layer = makeLayer({ terrainGlobe: true, shared: true });
|
||||||
|
t.after(layer.done);
|
||||||
|
const info = layer.add('boston', 200);
|
||||||
|
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
|
||||||
|
await layer.refined();
|
||||||
|
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0);
|
||||||
|
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
|
||||||
|
assert.equal(layer.calls.getHeight, 200);
|
||||||
|
for (const station of info.values()) {
|
||||||
|
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||||
|
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||||
|
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
|
||||||
|
const layer = makeLayer({ terrainGlobe: true });
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 50);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0);
|
||||||
|
assert.equal(layer.calls.getHeight, 50);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
layer.heightOf('boston:boston7') -
|
||||||
|
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
|
||||||
|
) < 1e-3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
|
||||||
|
// Stations 10 and up lie on a tile the globe is not drawing.
|
||||||
|
const layer = makeLayer({
|
||||||
|
terrainGlobe: true,
|
||||||
|
shared: true,
|
||||||
|
unloaded: (lat) => lat > 42.30095,
|
||||||
|
});
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 20);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 10);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
layer.heightOf('boston:boston15') -
|
||||||
|
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
|
||||||
|
) < 1e-3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a city stops its refinement and leaves the other running', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 40);
|
||||||
|
layer.add('toronto', 40);
|
||||||
|
layer.rendering.removeCityPoints('boston');
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 40);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
|
||||||
|
assert.ok(layer.heightOf('toronto:toronto3') > 50);
|
||||||
|
|
||||||
|
layer.add('boston', 40);
|
||||||
|
layer.rendering.removeCityPoints('boston');
|
||||||
|
layer.rendering.removeCityPoints('toronto');
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||||
|
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||||
|
await pause(60);
|
||||||
|
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabling the layer stops refinement and leaves no timer', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.lifecycle.enable(layer.viewer);
|
||||||
|
layer.add('boston', 200);
|
||||||
|
await pause(30);
|
||||||
|
const partWay = layer.calls.sampleHeight;
|
||||||
|
assert.ok(partWay < 200, 'still refining');
|
||||||
|
layer.lifecycle.disable(layer.viewer);
|
||||||
|
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||||
|
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||||
|
await pause(60);
|
||||||
|
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a station selected before its height arrives is re-selected at it', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 3);
|
||||||
|
const reselected = [];
|
||||||
|
layer.state._selectedKey = 'boston:boston1';
|
||||||
|
// The selection part's select path; here it records the key.
|
||||||
|
layer.parts.selection._selectStation = (key) => reselected.push(key);
|
||||||
|
await layer.refined();
|
||||||
|
assert.deepEqual(reselected, ['boston:boston1']);
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export function createLifecycle({
|
|||||||
layerState._inFlightInfo = new Map();
|
layerState._inFlightInfo = new Map();
|
||||||
layerState._inFlightStatus = new Map();
|
layerState._inFlightStatus = new Map();
|
||||||
layerState._stationRenderMap = new Map();
|
layerState._stationRenderMap = new Map();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
layerState._clickHandler = null;
|
layerState._clickHandler = null;
|
||||||
layerState._selectedKey = null;
|
layerState._selectedKey = null;
|
||||||
layerState._selectedEntity = null;
|
layerState._selectedEntity = null;
|
||||||
@@ -134,6 +135,7 @@ export function createLifecycle({
|
|||||||
|
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
parts.viewport.deactivateAllCities();
|
parts.viewport.deactivateAllCities();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
layerState._cityRuntime.clear();
|
layerState._cityRuntime.clear();
|
||||||
layerState._pointCollection.show = false;
|
layerState._pointCollection.show = false;
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
@@ -165,6 +167,7 @@ export function createLifecycle({
|
|||||||
layerState._cameraChangedAttached = false;
|
layerState._cameraChangedAttached = false;
|
||||||
}
|
}
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
if (layerState._pointCollection) {
|
if (layerState._pointCollection) {
|
||||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||||
layerState._pointCollection = null;
|
layerState._pointCollection = null;
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
|
|||||||
|
|
||||||
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds one step may spend finding station ground heights. A height
|
||||||
|
* the terrain globe cannot give is a depth render of a millisecond or more.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const HEIGHT_REFINE_BUDGET_MS = 4;
|
||||||
|
|
||||||
|
/** Milliseconds between those steps, about a frame. */
|
||||||
|
|
||||||
|
export const HEIGHT_REFINE_INTERVAL_MS = 16;
|
||||||
|
|
||||||
/** Hard cap on total rendered station points across all cities. */
|
/** Hard cap on total rendered station points across all cities. */
|
||||||
|
|
||||||
export const MAX_TOTAL_POINTS = 8000;
|
export const MAX_TOTAL_POINTS = 8000;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
POINT_HEIGHT_OFFSET_M,
|
POINT_HEIGHT_OFFSET_M,
|
||||||
MAX_TOTAL_POINTS,
|
MAX_TOTAL_POINTS,
|
||||||
COLOR_OUTLINE,
|
COLOR_OUTLINE,
|
||||||
|
HEIGHT_REFINE_BUDGET_MS,
|
||||||
|
HEIGHT_REFINE_INTERVAL_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import {
|
||||||
|
createTerrainSurface,
|
||||||
|
TERRAIN_NEED,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -20,6 +26,14 @@ export function createRendering({
|
|||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { governorRequestRender } = services.render;
|
const { governorRequestRender } = services.render;
|
||||||
|
// The terrain globe's loaded surface: the application's shared reader, or
|
||||||
|
// the layer's own when it is built without one.
|
||||||
|
const terrainSurface =
|
||||||
|
services.ground?.terrainSurface || createTerrainSurface();
|
||||||
|
/** Stations drawn at the fallback height, waiting for their ground height. */
|
||||||
|
let heightQueue = [];
|
||||||
|
let heightTimer = null;
|
||||||
|
const heightPoint = new Cesium.Cartographic();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective dock capacity for a station.
|
* Resolve the effective dock capacity for a station.
|
||||||
@@ -87,8 +101,10 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
* Create a Cartesian3 position for a station at the fallback height. Its
|
||||||
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
* ground height is found afterwards (refineHeights): a city's stations are
|
||||||
|
* drawn at once, and finding hundreds of heights in the same task held the
|
||||||
|
* main thread for most of a second.
|
||||||
* @param {Object} station - Station info object with lat/lon.
|
* @param {Object} station - Station info object with lat/lon.
|
||||||
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
||||||
*/
|
*/
|
||||||
@@ -97,18 +113,78 @@ export function createRendering({
|
|||||||
const lon = Number(station?.lon);
|
const lon = Number(station?.lon);
|
||||||
const lat = Number(station?.lat);
|
const lat = Number(station?.lat);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
|
||||||
|
}
|
||||||
|
|
||||||
let height = POINT_HEIGHT_OFFSET_M;
|
/**
|
||||||
// Sample terrain height so points sit on ground rather than at ellipsoid level
|
* The ground height under a station, or undefined when the scene cannot say.
|
||||||
if (layerState._viewer?.scene?.sampleHeightSupported) {
|
* A terrain globe answers from its loaded surface, which is cheap. Where it
|
||||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
|
||||||
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
* at the point) the scene renders the depth there, at a millisecond or more
|
||||||
if (Number.isFinite(sampled)) {
|
* a point.
|
||||||
height = sampled + POINT_HEIGHT_OFFSET_M;
|
*/
|
||||||
}
|
|
||||||
|
function sampleGroundHeight(lat, lon) {
|
||||||
|
const viewer = layerState._viewer;
|
||||||
|
const scene = viewer?.scene;
|
||||||
|
if (!scene) return undefined;
|
||||||
|
// A station is a marker: its one read is not left for a later window.
|
||||||
|
// The step's own time budget bounds what a pass spends.
|
||||||
|
const terrain = terrainSurface.height(
|
||||||
|
viewer,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
|
if (Number.isFinite(terrain)) return terrain;
|
||||||
|
if (!scene.sampleHeightSupported) return undefined;
|
||||||
|
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
|
||||||
|
const sampled = scene.sampleHeight(heightPoint);
|
||||||
|
return Number.isFinite(sampled) ? sampled : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move queued stations onto the ground for a few milliseconds, then leave
|
||||||
|
* the rest to the next step. A station the scene has no height for keeps
|
||||||
|
* the fallback height, as it did when heights were read while drawing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function refineHeights() {
|
||||||
|
heightTimer = null;
|
||||||
|
const started = performance.now();
|
||||||
|
let moved = false;
|
||||||
|
while (
|
||||||
|
heightQueue.length > 0 &&
|
||||||
|
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
|
||||||
|
) {
|
||||||
|
const { record, lat, lon } = heightQueue.pop();
|
||||||
|
const ground = sampleGroundHeight(lat, lon);
|
||||||
|
if (ground === undefined) continue;
|
||||||
|
record.point.position = Cesium.Cartesian3.fromDegrees(
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
ground + POINT_HEIGHT_OFFSET_M,
|
||||||
|
);
|
||||||
|
moved = true;
|
||||||
|
// The highlight and card of a selected station copy its position.
|
||||||
|
if (layerState._selectedKey === record.key)
|
||||||
|
parts.selection._selectStation(record.key);
|
||||||
}
|
}
|
||||||
|
if (moved) governorRequestRender('bikeshare-heights');
|
||||||
|
scheduleHeightRefinement();
|
||||||
|
}
|
||||||
|
|
||||||
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
function scheduleHeightRefinement() {
|
||||||
|
if (heightTimer !== null || heightQueue.length === 0) return;
|
||||||
|
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every queued station and the pending step. */
|
||||||
|
|
||||||
|
function stopHeightRefinement() {
|
||||||
|
heightQueue = [];
|
||||||
|
clearTimeout(heightTimer);
|
||||||
|
heightTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +254,7 @@ export function createRendering({
|
|||||||
id: key,
|
id: key,
|
||||||
});
|
});
|
||||||
|
|
||||||
layerState._stationRenderMap.set(key, {
|
const record = {
|
||||||
key,
|
key,
|
||||||
cityId,
|
cityId,
|
||||||
stationId: station.stationId,
|
stationId: station.stationId,
|
||||||
@@ -191,12 +267,19 @@ export function createRendering({
|
|||||||
isInstalled: station.isInstalled,
|
isInstalled: station.isInstalled,
|
||||||
isRenting: station.isRenting,
|
isRenting: station.isRenting,
|
||||||
isReturning: station.isReturning,
|
isReturning: station.isReturning,
|
||||||
|
};
|
||||||
|
layerState._stationRenderMap.set(key, record);
|
||||||
|
heightQueue.push({
|
||||||
|
record,
|
||||||
|
lat: Number(station.lat),
|
||||||
|
lon: Number(station.lon),
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.stationKeys.add(key);
|
runtime.stationKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
|
scheduleHeightRefinement();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +307,12 @@ export function createRendering({
|
|||||||
layerState._stationRenderMap.delete(key);
|
layerState._stationRenderMap.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Its stations no longer wait for a height.
|
||||||
|
heightQueue = heightQueue.filter(
|
||||||
|
({ record }) => layerState._stationRenderMap.get(record.key) === record,
|
||||||
|
);
|
||||||
|
if (heightQueue.length === 0) stopHeightRefinement();
|
||||||
|
|
||||||
layerState._cityRuntime.delete(cityId);
|
layerState._cityRuntime.delete(cityId);
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
}
|
}
|
||||||
@@ -280,5 +369,10 @@ export function createRendering({
|
|||||||
ensureCityPoints,
|
ensureCityPoints,
|
||||||
removeCityPoints,
|
removeCityPoints,
|
||||||
applyStatusToPoints,
|
applyStatusToPoints,
|
||||||
|
stopHeightRefinement,
|
||||||
|
/** Stations still waiting for their ground height. */
|
||||||
|
pendingHeightCount: () => heightQueue.length,
|
||||||
|
/** Whether a refinement step is waiting on a timer. */
|
||||||
|
heightRefinementScheduled: () => heightTimer !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
||||||
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
||||||
|
import {
|
||||||
|
TERRAIN_NEED,
|
||||||
|
createTerrainSurface,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
import {
|
import {
|
||||||
COLD_CELL_FLUSH_MS,
|
COLD_CELL_FLUSH_MS,
|
||||||
COLD_CELL_RETRY_MS,
|
COLD_CELL_RETRY_MS,
|
||||||
@@ -44,6 +48,11 @@ const CATEGORY_TINTS = Object.fromEntries(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SPRITE_PX = 64;
|
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. */
|
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
||||||
const FULL_TINT = Cesium.Color.WHITE;
|
const FULL_TINT = Cesium.Color.WHITE;
|
||||||
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
||||||
@@ -117,8 +126,11 @@ export function createRendering({ state, services, parts }) {
|
|||||||
neighborFloorM = null,
|
neighborFloorM = null,
|
||||||
coarseFloorCoord = null,
|
coarseFloorCoord = null,
|
||||||
} = services.ground;
|
} = 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 sprites = new Map();
|
||||||
const terrainPoint = new Cesium.Cartographic();
|
|
||||||
const scratchPosition = new Cesium.Cartesian3();
|
const scratchPosition = new Cesium.Cartesian3();
|
||||||
/** Scratch for the on-line position; place() runs every frame. */
|
/** Scratch for the on-line position; place() runs every frame. */
|
||||||
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
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) {
|
function floorHeight(lat, lon, entry = null, stop = false) {
|
||||||
const globe = state._viewer?.scene?.globe;
|
const globe = state._viewer?.scene?.globe;
|
||||||
if (globe?.show === true) {
|
if (globe?.show === true) {
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
// A moving train is asked about every frame, at a new point each
|
||||||
const rendered = globe.getHeight?.(terrainPoint);
|
// 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))
|
if (Number.isFinite(rendered))
|
||||||
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
||||||
withinViewBounds,
|
withinViewBounds,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import {
|
||||||
|
TERRAIN_NEED,
|
||||||
|
createTerrainSurface,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
/** Surface preparation off the render path, shared by markers and history. */
|
/** Surface preparation off the render path, shared by markers and history. */
|
||||||
export function createHeight({ state, services, parts }) {
|
export function createHeight({ state, services, parts }) {
|
||||||
@@ -47,35 +51,44 @@ export function createHeight({ state, services, parts }) {
|
|||||||
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
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.
|
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
||||||
* @param {number} lat
|
* @param {number} lat
|
||||||
* @param {number} lon
|
* @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}}
|
* @returns {{cell: string, height: number|null, prior: number|null}}
|
||||||
*/
|
*/
|
||||||
const terrainPoint = new Cesium.Cartographic();
|
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
|
||||||
function requestHeight(lat, lon, entry = null) {
|
|
||||||
const coarse = coarseFloorCoord(lat, lon);
|
const coarse = coarseFloorCoord(lat, lon);
|
||||||
const cell = `${coarse.lat},${coarse.lon}`;
|
const cell = `${coarse.lat},${coarse.lon}`;
|
||||||
const fixtureFloor =
|
const fixtureFloor =
|
||||||
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
||||||
if (Number.isFinite(fixtureFloor))
|
if (Number.isFinite(fixtureFloor))
|
||||||
return { cell, height: fixtureFloor, prior: null };
|
return { cell, height: fixtureFloor, prior: null };
|
||||||
// On a terrain globe, use the loaded surface under the marker. This is
|
// On a terrain globe, use the loaded surface under the marker. This reads
|
||||||
// synchronous cache work and does not launch a terrain request.
|
// tiles already loaded and does not launch a terrain request.
|
||||||
const globe = state._viewer?.scene?.globe;
|
const globe = state._viewer?.scene?.globe;
|
||||||
if (globe?.show === true) {
|
if (globe?.show === true) {
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
if (
|
||||||
const rendered = globe.getHeight?.(terrainPoint);
|
|
||||||
const flat =
|
|
||||||
state._viewer?.terrainProvider instanceof
|
state._viewer?.terrainProvider instanceof
|
||||||
Cesium.EllipsoidTerrainProvider;
|
Cesium.EllipsoidTerrainProvider
|
||||||
if (Number.isFinite(rendered) || flat)
|
) {
|
||||||
return {
|
// Nothing is asked of the terrain while the globe is flat: show the
|
||||||
cell,
|
// reader the flat provider, so the heights kept from the terrain
|
||||||
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
|
// before it do not come back with the terrain after it.
|
||||||
prior: null,
|
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);
|
const floor = cachedGroundFloor(lat, lon);
|
||||||
if (Number.isFinite(floor)) {
|
if (Number.isFinite(floor)) {
|
||||||
@@ -107,6 +120,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
function rereadFloors() {
|
function rereadFloors() {
|
||||||
state._floorTimer = null;
|
state._floorTimer = null;
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
|
const waitingBefore = terrainSurface.waitingCount();
|
||||||
const onGround = nearGround(),
|
const onGround = nearGround(),
|
||||||
candidates = [];
|
candidates = [];
|
||||||
const entries = [...state._vehicles.values()].filter(
|
const entries = [...state._vehicles.values()].filter(
|
||||||
@@ -120,6 +134,13 @@ export function createHeight({ state, services, parts }) {
|
|||||||
Number(b.key === state._selectedKey) -
|
Number(b.key === state._selectedKey) -
|
||||||
Number(a.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) {
|
for (const entry of entries) {
|
||||||
const cells = new Map();
|
const cells = new Map();
|
||||||
const collect = (point, answer) => {
|
const collect = (point, answer) => {
|
||||||
@@ -141,7 +162,12 @@ export function createHeight({ state, services, parts }) {
|
|||||||
};
|
};
|
||||||
parts.trails.prepareEntry(entry, collect);
|
parts.trails.prepareEntry(entry, collect);
|
||||||
const newest = entry.fixes.at(-1);
|
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;
|
entry.heightCell = answer.cell;
|
||||||
// The selected vehicle's path also offers its history, newest segment
|
// The selected vehicle's path also offers its history, newest segment
|
||||||
// first; the vehicle's own cell is read where its newest fix is.
|
// 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);
|
for (const entry of entries) parts.trails.prepareEntry(entry);
|
||||||
parts.rendering.requestVisibility();
|
parts.rendering.requestVisibility();
|
||||||
governorRequestRender('transit-floor');
|
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);
|
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +288,8 @@ export function createHeight({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
state._floorAttempts = 0;
|
state._floorAttempts = 0;
|
||||||
state._heightDirty.clear();
|
state._heightDirty.clear();
|
||||||
|
// The shared reader's heights belong to every layer still showing.
|
||||||
|
if (!sharedSurface) terrainSurface.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -267,5 +299,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
anchorFloors,
|
anchorFloors,
|
||||||
rereadFloors,
|
rereadFloors,
|
||||||
clear,
|
clear,
|
||||||
|
/** Terrain read counters since the layer was created, for checks. */
|
||||||
|
terrainStats: () => terrainSurface.stats(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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,6 +13,7 @@ import {
|
|||||||
transitVehicleKey,
|
transitVehicleKey,
|
||||||
vehicleReportTimeMs,
|
vehicleReportTimeMs,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||||
import {
|
import {
|
||||||
transitModeFor,
|
transitModeFor,
|
||||||
transitModeResolved,
|
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
|
// 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.
|
// cell still cold takes the lowest neighbour as a prior, as before.
|
||||||
const priorHeightM = entry.heightM;
|
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) {
|
if (height.height !== null) {
|
||||||
entry.heightCell = height.cell;
|
entry.heightCell = height.cell;
|
||||||
entry.heightM = height.height;
|
entry.heightM = height.height;
|
||||||
@@ -311,7 +317,12 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
continue;
|
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.
|
// Keep the local prior separate from resolved historical floors.
|
||||||
// A neighbouring cell is not a guaranteed lower bound.
|
// A neighbouring cell is not a guaranteed lower bound.
|
||||||
const prior = height.height === null ? height.prior : height.height;
|
const prior = height.height === null ? height.prior : height.height;
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300;
|
|||||||
export const FLOOR_REREAD_MS = 900;
|
export const FLOOR_REREAD_MS = 900;
|
||||||
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
||||||
export const FLOOR_REREAD_ATTEMPTS = 6;
|
export const FLOOR_REREAD_ATTEMPTS = 6;
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
||||||
export const MARKER_PIXEL_SIZE = 20;
|
export const MARKER_PIXEL_SIZE = 20;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../data/contactPlayback.js';
|
} from '../../data/contactPlayback.js';
|
||||||
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
||||||
import { fixDistanceM } from './movement.js';
|
import { fixDistanceM } from './movement.js';
|
||||||
|
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||||
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
||||||
|
|
||||||
const MODE_COLOR = {
|
const MODE_COLOR = {
|
||||||
@@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
b = {};
|
b = {};
|
||||||
let vertices = 0,
|
let vertices = 0,
|
||||||
signature = '';
|
signature = '';
|
||||||
const floor = (point) => {
|
const floor = (point, need = TERRAIN_NEED.PATH) => {
|
||||||
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
|
const answer = parts.height.requestHeight(
|
||||||
|
point.lat,
|
||||||
|
point.lon,
|
||||||
|
entry,
|
||||||
|
need,
|
||||||
|
);
|
||||||
if (collect) collect(point, answer);
|
if (collect) collect(point, answer);
|
||||||
return answer.height;
|
return answer.height;
|
||||||
};
|
};
|
||||||
@@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
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(
|
const activeIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
entry.sample.fromSeq - entry.track.baseSeq,
|
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);
|
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
||||||
if (entry === selected)
|
if (entry === selected)
|
||||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||||
|
|||||||
+55
-20
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
accentForVesselType,
|
accentForVesselType,
|
||||||
displayVesselType,
|
displayVesselType,
|
||||||
normalizeVesselType,
|
normalizeVesselType,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
|
|
||||||
@@ -16,14 +18,21 @@ export function createCards({
|
|||||||
function updateSelectedVesselHud(record) {
|
function updateSelectedVesselHud(record) {
|
||||||
// Pinned vessels missing from recent refreshes get a stale marker
|
// Pinned vessels missing from recent refreshes get a stale marker
|
||||||
const stale = (record.missedRefreshes || 0) > 0;
|
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({
|
services.readouts?.presentSelectedVessel({
|
||||||
text: [
|
text: [
|
||||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||||
t('{type} SPD: {speed} HDG: {heading}', {
|
aid
|
||||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
? aidKind(
|
||||||
speed: formatSpeed(record.speed),
|
trimHudValue(record.type || t('AID TO NAVIGATION'), 24),
|
||||||
heading: formatHeading(record.heading ?? record.course),
|
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')}` : ''}`,
|
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
});
|
});
|
||||||
@@ -43,20 +52,22 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
||||||
* compact type/speed/heading detail line, anchored at the record's current
|
* compact type/speed/heading detail line, anchored at the record's current
|
||||||
* rendered position (height-datum caveat: no datum work here). Pure —
|
* rendered position (height-datum caveat: no datum work here). An aid to
|
||||||
* exported for unit tests.
|
* navigation's line is its kind alone. Pure — exported for unit tests.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildVesselCard(record) {
|
function buildVesselCard(record) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const type = vesselTypeShort(record);
|
const type = vesselTypeShort(record);
|
||||||
if (type) parts.push(type);
|
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));
|
parts.push(formatSpeed(record.speed));
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
if (Number.isFinite(direction))
|
if (!aid && Number.isFinite(direction))
|
||||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
@@ -65,7 +76,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 26),
|
title: trimHudValue(displayVesselName(record), 26),
|
||||||
details: parts.length ? [parts.join(' · ')] : [],
|
details: parts.length ? [parts.join(' · ')] : [],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -76,22 +87,26 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for the click-selected vessel — the full-detail card, drawn last
|
* 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
|
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
||||||
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
* vessels carry a STALE marker (mirrors the HUD readout). An aid to
|
||||||
* for unit tests.
|
* navigation shows its kind with no speed or heading. Pure — exported for
|
||||||
|
* unit tests.
|
||||||
* @param {Object} record - Selected vessel record.
|
* @param {Object} record - Selected vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildSelectedVesselCard(record) {
|
function buildSelectedVesselCard(record) {
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
const details = [
|
const details = [
|
||||||
[
|
aid
|
||||||
vesselTypeShort(record) || t('VESSEL'),
|
? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record)
|
||||||
formatSpeed(record.speed),
|
: [
|
||||||
Number.isFinite(direction)
|
vesselTypeShort(record) || t('VESSEL'),
|
||||||
? `${roundHeadingDegrees(direction)}°`
|
formatSpeed(record.speed),
|
||||||
: '--°',
|
Number.isFinite(direction)
|
||||||
].join(' · '),
|
? `${roundHeadingDegrees(direction)}°`
|
||||||
|
: '--°',
|
||||||
|
].join(' · '),
|
||||||
];
|
];
|
||||||
const destination = String(record.destination || '').trim();
|
const destination = String(record.destination || '').trim();
|
||||||
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
||||||
@@ -106,7 +121,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 12,
|
gapPx: 12,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 32),
|
title: trimHudValue(displayVesselName(record), 32),
|
||||||
details,
|
details,
|
||||||
selected: true,
|
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. */
|
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||||
|
|
||||||
function vesselOverlayEntryId(record) {
|
function vesselOverlayEntryId(record) {
|
||||||
@@ -129,9 +158,15 @@ export function createCards({
|
|||||||
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
* 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
|
* 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").
|
* 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) {
|
function vesselTypeShort(record) {
|
||||||
|
if (isAidToNavigation(record)) {
|
||||||
|
const kind = String(record.type || '').trim();
|
||||||
|
return kind ? trimHudValue(kind.toUpperCase(), 26) : '';
|
||||||
|
}
|
||||||
const feedType = normalizeVesselType(record.type);
|
const feedType = normalizeVesselType(record.type);
|
||||||
const shown = displayVesselType(record.type);
|
const shown = displayVesselType(record.type);
|
||||||
return shown === feedType
|
return shown === feedType
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export function createIngestion({
|
|||||||
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
||||||
course: record.courseDeg,
|
course: record.courseDeg,
|
||||||
heading: record.headingDeg,
|
heading: record.headingDeg,
|
||||||
|
virtual: record.virtual === true,
|
||||||
last_position_epoch:
|
last_position_epoch:
|
||||||
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
||||||
last_position_UTC:
|
last_position_UTC:
|
||||||
|
|||||||
@@ -515,3 +515,50 @@ test('AIS feed rows stay keyed by their MMSI', async () => {
|
|||||||
['235000001', '235000001'],
|
['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/);
|
||||||
|
});
|
||||||
@@ -31,6 +31,8 @@ export function normalizeVessel(row) {
|
|||||||
speed: finiteNumber(row.speed),
|
speed: finiteNumber(row.speed),
|
||||||
course: finiteNumber(row.course),
|
course: finiteNumber(row.course),
|
||||||
heading: finiteNumber(row.heading),
|
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 || ''),
|
lastPositionUtc: String(row.last_position_UTC || ''),
|
||||||
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
||||||
missedRefreshes: 0,
|
missedRefreshes: 0,
|
||||||
@@ -160,6 +162,7 @@ export class VesselRecords {
|
|||||||
record.speed = next.speed;
|
record.speed = next.speed;
|
||||||
record.course = next.course;
|
record.course = next.course;
|
||||||
record.heading = next.heading;
|
record.heading = next.heading;
|
||||||
|
record.virtual = next.virtual;
|
||||||
record.lastPositionUtc = next.lastPositionUtc;
|
record.lastPositionUtc = next.lastPositionUtc;
|
||||||
record.lastPositionEpoch = next.lastPositionEpoch;
|
record.lastPositionEpoch = next.lastPositionEpoch;
|
||||||
record.missedRefreshes = 0;
|
record.missedRefreshes = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
vesselTypeCss,
|
vesselTypeCss,
|
||||||
vesselOverlayCohortLimit,
|
vesselOverlayCohortLimit,
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
vesselModelSpec,
|
vesselModelSpec,
|
||||||
} from '../../data/fleetModels.js';
|
} from '../../data/fleetModels.js';
|
||||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.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). */
|
/** How often vessels move on as the world clock plays (wall ms). */
|
||||||
const FOLLOW_POSITION_MS = 250;
|
const FOLLOW_POSITION_MS = 250;
|
||||||
@@ -182,12 +184,14 @@ export function createRendering({
|
|||||||
* Best available real-world direction of travel for a vessel, degrees
|
* Best available real-world direction of travel for a vessel, degrees
|
||||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||||
* Screen rotation is computed from this by the shared projected-rotation
|
* 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.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {number} Course in degrees (0 when unknown).
|
* @returns {number} Course in degrees (0 when unknown).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselCourseDeg(record) {
|
function vesselCourseDeg(record) {
|
||||||
|
if (isAidToNavigation(record)) return 0;
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
return Number.isFinite(direction) ? direction : 0;
|
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.
|
* 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.
|
* 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.
|
* One icon is generated per color+variant and reused across all billboards.
|
||||||
* @param {Object} record - Vessel record (drives per-type tint).
|
* @param {Object} record - Vessel record (drives per-type tint).
|
||||||
* @param {boolean} selected - True for the white/brighter selected variant.
|
* @param {boolean} selected - True for the white/brighter selected variant.
|
||||||
@@ -202,16 +207,25 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function shipIcon(record, selected) {
|
function shipIcon(record, selected) {
|
||||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
const aid = isAidToNavigation(record);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
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))
|
if (vesselState.shipIconCache.has(key))
|
||||||
return vesselState.shipIconCache.get(key);
|
return vesselState.shipIconCache.get(key);
|
||||||
|
|
||||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||||
const strokeWidth = selected ? 1.1 : 0.7;
|
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">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
<g transform="translate(16,16)">
|
<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>
|
</g>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
const icon = 'data:image/svg+xml;base64,' + btoa(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) {
|
function modelItem(record, set) {
|
||||||
|
if (record.virtual === true) return null;
|
||||||
let item = modelItems.get(record.id);
|
let item = modelItems.get(record.id);
|
||||||
if (!item || item.record !== record)
|
if (!item || item.record !== record)
|
||||||
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
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) {
|
if (item.specKey !== specKey) {
|
||||||
item.specKey = specKey;
|
item.specKey = specKey;
|
||||||
item.spec = vesselModelSpec(set, record);
|
item.spec = vesselModelSpec(set, record);
|
||||||
}
|
}
|
||||||
item.position = getVisual(record).waterline;
|
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;
|
item.show = record.id !== state.cockpitSubjectId;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -294,7 +314,8 @@ export function createRendering({
|
|||||||
limitSq
|
limitSq
|
||||||
)
|
)
|
||||||
continue;
|
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);
|
gatherModelItems(set, next);
|
||||||
} else {
|
} else {
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (state.records.byId.get(id) === item.record)
|
if (state.records.byId.get(id) === item.record) {
|
||||||
next.set(id, modelItem(item.record, set));
|
const kept = modelItem(item.record, set);
|
||||||
|
if (kept) next.set(id, kept);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
||||||
@@ -465,7 +488,8 @@ export function createRendering({
|
|||||||
visible &&
|
visible &&
|
||||||
record.id !== state.cockpitSubjectId &&
|
record.id !== state.cockpitSubjectId &&
|
||||||
!visual.modelOwnsVisual;
|
!visual.modelOwnsVisual;
|
||||||
if (visible && doRotations && scene) {
|
// An aid's disc is never turned (vesselCourseDeg).
|
||||||
|
if (visible && doRotations && scene && !isAidToNavigation(record)) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene,
|
scene,
|
||||||
visual.position,
|
visual.position,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createTerrainSurface } from './terrainSurface.js';
|
||||||
/** Construct an instance-owned groundFloor service with explicit dependencies. */
|
/** Construct an instance-owned groundFloor service with explicit dependencies. */
|
||||||
export function createGroundFloor({ terrain, signal }) {
|
export function createGroundFloor({ terrain, signal }) {
|
||||||
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
|
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
|
||||||
@@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) {
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
// The terrain globe's loaded surface, read once per point for every layer.
|
||||||
|
terrainSurface: createTerrainSurface(),
|
||||||
GROUND_FLOOR_LIFT_M,
|
GROUND_FLOOR_LIFT_M,
|
||||||
MESH_FLOOR_BELOW_PRIOR_M,
|
MESH_FLOOR_BELOW_PRIOR_M,
|
||||||
MESH_FLOOR_ABOVE_PRIOR_M,
|
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: {} });
|
||||||
|
});
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
* 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
|
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
||||||
* unknown), never its key, since vessels can share an MMSI. A source that
|
* 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
|
* 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.
|
* answer a snapshot at a given time, so the layer follows the world clock.
|
||||||
|
|||||||
@@ -221,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
|||||||
assert.equal(row.altitudeDatum, 'sea-surface');
|
assert.equal(row.altitudeDatum, 'sea-surface');
|
||||||
assert.equal(row.observedAtMs, now);
|
assert.equal(row.observedAtMs, now);
|
||||||
assert.equal('extra' in row, false);
|
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(
|
assert.equal(
|
||||||
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
|
|||||||
epoch(row.last_position_epoch, 1000) ??
|
epoch(row.last_position_epoch, 1000) ??
|
||||||
epoch(Date.parse(row.last_position_UTC)),
|
epoch(Date.parse(row.last_position_UTC)),
|
||||||
altitudeDatum: 'sea-surface',
|
altitudeDatum: 'sea-surface',
|
||||||
|
// A virtual aid to navigation: a mark broadcast with nothing there.
|
||||||
|
virtual: row.virtual === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
thinEvenly,
|
thinEvenly,
|
||||||
} from '../results.js';
|
} from '../results.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.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({
|
const MMSI_SCHEMA = Object.freeze({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({
|
|||||||
title: 'Vessels in an area',
|
title: 'Vessels in an area',
|
||||||
description:
|
description:
|
||||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
'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 ' +
|
'ship by type or destination, and stats summarizes their speed; both ' +
|
||||||
'cover all matches, not just the rows returned.',
|
'cover all matches, not just the rows returned.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({
|
|||||||
const rows = snapshot.records
|
const rows = snapshot.records
|
||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
|
// A buoy or beacon in the feed is not a ship to count.
|
||||||
|
!isAidToNavigation(record) &&
|
||||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||||
(!wanted ||
|
(!wanted ||
|
||||||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
||||||
|
|||||||
@@ -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 },
|
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)),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user