Compare commits

..
Author SHA1 Message Date
Sameh Khamis 70123fe4a9 Drape the open-data map overlays through the renderer's globe port
The sea-mark and ski-piste overlays add an XYZ imagery layer through the
globe port instead of building Cesium layers into a host collection. The
port drapes it on the globe on globe stacks and on the Google 3D tiles,
which the map stack now marks as its base tilesets. The row's hidden
reason reads the map stack's new getDrapeSurface, or the viewer's globe
when there is no map stack, and an overlay enabled before its viewer has
a globe port adds its layer once there is one. The overlays no longer
reach Cesium, so they leave the allow-list.
2026-11-06 18:29:40 -08:00
Sameh Khamis fbcac37f13 Give the Cesium renderer the contract's globe port
The globe port sets the base (photoreal tiles, terrain, imagery), shows
or hides the globe, and adds imagery layers: XYZ, WMTS, ArcGIS, world,
single-image and canvas sources, with admission, fetch and transform
hooks, a request budget and tile errors. Each layer drapes where its
drapeOn says: on the globe, or on the drawn base tileset, which is the
port's own photoreal tileset or one the map stack marks as its base,
followed every frame so a base switched outside the port moves the
layers. Port layers keep their order above other layers in each
collection. The sky reads the scene and writes through to it, and the
port reports settling and load progress.

The application renderer and the conformance renderer both have it, so
the application renderer now has every member of the contract. A viewer
with no renderer bound gets a globe port through findGlobe. The Cesium
conformance run checks imagery order, the split and the image-hook gate.
2026-11-06 18:29:40 -08:00
Sameh Khamis 9049357916 Add data credits through the renderer's credits port
Data attribution no longer builds Cesium credits itself: each credit is
added through the renderer's credits port, in the attribution list as
before. The port's credit carries one link, so a credit whose markup
holds several links becomes one credit per link, shown together in
order, its wording kept. dataCredits.js leaves the Cesium allow-list.
2026-11-06 17:59:00 -08:00
Sameh Khamis 8f612ba858 Read pixels and release unused caches through the Cesium renderer
The shared Cesium ports gain readPixels and releaseUnused, on both the
conformance renderer and the application's.

readPixels reads the drawn frame off the canvas at once, cropped in CSS
pixels and scaled from the canvas's CSS size, through one reused 2D canvas.
A canvas with no size reads blank.

releaseUnused trims every 3D tileset's cache, and after the next drawn frame
frees the globe tiles that frame did not draw. Cesium trims the globe's
cache only while tiles load, so a settled view would otherwise free nothing;
the trim goes through internals.js. The reader canvas is dropped too.

Two new checks, renderer.readPixels and renderer.releaseUnused, run on
Cesium and the stub.
2026-11-06 17:42:13 -08:00
Sameh Khamis 6ffdf30795 Settle the application's renderer through the shared Cesium ports
whenSettled moves from the conformance renderer into the shared Cesium
ports, unchanged but for what it waits for, and the application's renderer
gains it. A hidden globe loads nothing, so it no longer holds settling up:
the application hides its globe under photoreal tiles. Shown 3D tilesets in
the scene must have loaded what is in view, named 'tiles' while they have
not. Disposing the ports ends the watches under way, which fail as
destroyed, as the conformance renderer's destroy did.

It waits for the globe, the scene's tiles and what the draw sets hold, not
for what layers still draw through the viewer themselves.
2026-11-06 17:38:35 -08:00
Sameh Khamis 2bb0b1e367 Set scene time through the Cesium frame port, in the application too
frame.setTime moves from the conformance renderer into the shared Cesium
ports, so the application's renderer has it. It moves the clock and asks for
a frame, which on-demand drawing needs. It no longer stops the clock: the
conformance renderer stops its own at creation, and the application's clock
keeps whatever state its owner gave it.
2026-11-06 17:38:29 -08:00
Sameh Khamis 8b6bbf0f21 Give the Cesium draw port atlases and the clamp state
The shared Cesium ports gain draw.atlas, draw.clampState and
draw.on('clampState'), on both the conformance renderer and the
application's. An icon names an image by atlas slot; it draws once its atlas
holds the slot, and registering an atlas again rebuilds the icon sets that
read from it. A generated image with a key is one texture however many icons
draw it.

clampState is draped unless a shown 3D tileset is in the scene: the port
draws clamped items where it is given them and drapes nothing onto tiles yet,
so over tiles it says overlay. A change is told after the drawn frame that
shows it, never on subscribing.

A new draw.atlas check registers an atlas after its icons and picks them;
it and draw.clampState now run on Cesium.
2026-11-06 17:38:21 -08:00
Sameh Khamis 96bc73181b Give the Cesium camera port its user camera controls
camera.controls now maps the contract's CameraControls onto the scene's
ScreenSpaceCameraController: the enable flags, zoom distances, zoom
factor, coast as Cesium inertia (decay seconds, 0 for no coast), the
collision switch, the tilt limit as the upper end of pitchRange, and the
zoom bindings by name. suspend and enabled use the scene's shared input
holds, so they count with the input port's and the dispatcher's.

A follow's zoom hold now composes with the settings: minDistance and
coast.zoom read and set what the follow gives back, a higher floor takes
effect at once, and a follow never lengthens a shorter zoom coast.

The ctrl+wheel binding check reuses the controls' binding helpers, and
the controls checks run in the Cesium conformance set, the settings one
in Node as well.
2026-11-06 17:26:03 -08:00
Sameh Khamis c6bab5dcaa Give the Cesium renderer a quality port
The Cesium quality port supports pixel ratio, multisampling, shadows,
3D tile and terrain detail and the target frame rate, each applied when
first set and nothing changed before. Detail scales each tileset's and
the globe's own screen-space error and gives it back at full detail;
while tile detail is lowered, tilesets added later follow it. Frame
signals carry each drawn tick's CPU time, size, loop mode and holds.
The application and conformance renderers have the port, and the
conformance page runs the quality checks against Cesium.
2026-11-06 17:17:44 -08:00
Sameh Khamis 30c56a8ac8 Own the credits modules where the camera factory loads them
The Cesium camera module now loads the credits factory, so the flight,
military and satellite packages own the credits modules too, and the
infrastructure consumer build lists them.
2026-11-06 16:44:24 -08:00
Sameh Khamis 910a691494 Give a viewer with no renderer an effects port, as cameras get
The renderer bridge builds and keeps an effects port over a viewer no
renderer is bound to, with a factory the Cesium effects module provides,
so visual settings on a consumer's own viewer draw their styles, bloom
and sharpen as before and give the scene's bloom back when destroyed.
The application shell loads the factory; a test checks every export
that reaches the effects lookup loads it.
2026-11-06 16:33:40 -08:00
Sameh Khamis d0ef3a902b Drive the visual styles, bloom and sharpen through the effects port
VisualEffects defines each preset on the renderer and keeps its own
records of intensities and parameters, written through to the port, so
crossfades, the anime sky fade, bloom and sharpen behave as before. The
renderer animates the animated styles. Sensor picks read the style
records instead of the renderer's stages. The application renderer now
has the effects port and its capabilities.
2026-11-06 16:32:49 -08:00
Sameh Khamis d6b553646e Run styles, bloom and sharpen through a Cesium effects port
The effects port puts each style Core defines on its own post-process
stage, takes the scene's bloom stage and adds a sharpen stage, with the
mappings the application uses today. An animated style holds frames and
advances its time uniform while it draws. A replaced style keeps drawing
until its intensity is 0, so Core can crossfade; the contract, the stub
and a new conformance check say so. The conformance renderer has the
port; the application renderer does not yet.
2026-11-06 16:32:39 -08:00
Sameh Khamis cf915e8c16 Add layer and map credits through the renderer's credits port
The street-level provider credits, the map source credit, the shared
inline OpenStreetMap and OpenMapTiles credits and the ALPR provider credit
go through the renderer's credits port instead of Cesium's credit display.
Their markup has one link each and is read as the port's plain data; the
ALPR credit is built as plain data, which the port escapes. src/maps/credits.js
and src/layers/streetLevel/credits.js no longer reach Cesium and leave the
allow-list.

findCredits finds the port as findCamera finds the camera: a Cesium viewer
of a consumer's own, with no renderer bound, gets a credits port over its
own credit display, so the public entries keep their attribution. A port
built that way is released, not disposed, when a renderer binds later, and
a credit let go after its viewer is destroyed leaves the display alone.

The per-layer data credits and the transit and event credits stay on
Cesium's display: their markup carries several links each, which the
contract's credit cannot hold.
2026-11-06 16:32:23 -08:00
Sameh Khamis 9c9ba376e4 Give the Cesium renderer the contract's credits port
The shared Cesium ports gain credits, on both the conformance renderer and
the application's. list() reports what Cesium owes as plain data: its logo,
its static credits and the last drawn frame's imagery, terrain and tile
credits, read from the credit display through internals.js, with the
credits Core adds listed as given where the display shows them. addStatic
shows a credit through Cesium's display, as markup built from the plain
data with every value escaped and only web links kept, so attribution stays
on screen. change fires when Core adds or removes a credit, and after a
drawn frame whose Cesium credits differ from the last one's.

src/render/creditMarkup.js reads markup credits as plain data. The credits
listener check moves out of renderer.listenerRules into its own check, so a
renderer without every port can run it; it and the credits check now run on
Cesium.
2026-11-06 16:32:17 -08:00
Sameh Khamis 764b40950f Set the field of view and read the matrices through the camera port
setFov holds the vertical angle it is given across resizes (Cesium
keeps the wider one), waits for a frustum with a field of view, and
rejects angles outside (0, pi). matrices returns Cesium's view and
projection matrices, ECEF to eye in 3D, the only mode Core runs. The
conformance run checks both on Cesium.
2026-11-06 16:32:07 -08:00
Sameh Khamis c6f043d3fd Follow aircraft and satellites through the camera port
Flights and military follow their tracked aircraft with an
entityFollowTarget: framed in ENU from viewFrom, never closer than
150 m, presentation read from the drawn entity's record. Satellites
follow in Cesium's autodetect frame. The layers' own framing
(applyTrackedCameraFrame) goes; the port's follow has the same
close-range rules, and trackedCamera.js no longer imports Cesium.

The port's helper entity draws nothing: its point's outline is
transparent too, and click and voice picks of it count as picks of
the target's drawn entity. The fake camera binder follows when the
fake has Cesium's entity tracking.
2026-11-06 16:10:48 -08:00
Sameh Khamis e5f8e5753a Read the followed target, not trackedEntity, in Core's follow readers
The tracked card, screen annotations, north-up, share links and Cockpit
ask data/followed.js what the camera follows, so they work the same when
a layer follows through the camera port. followed.js gains
entityFollowTarget (a FollowTarget whose presentation reads the drawn
entity's record), resumeFollowing and followedPosition.
2026-11-06 15:31:36 -08:00
Sameh Khamis 2adec097de Read what the camera follows through one Core module
src/data/followed.js answers what the camera follows: the camera port's
target, or the entity a layer still tracks itself, seen through the same
fields. Ownership checks, render holds, guards, voice actions, the CCTV
pacing and the navigation release paths read it instead of Cesium's
trackedEntity, so they keep working when layers follow through the port.
The release paths stop following, cancel the flight and release the
camera through the port. The rotate refusal names the followed target by
its name or label title.
2026-11-06 12:27:23 -08:00
Sameh Khamis e40a4c78d5 Follow on the Cesium camera port
The Cesium camera port follows a target with Cesium's own entity
tracking, on a hidden helper entity per target, and gains followed,
followFramed, followOffset and followChanged. A target with a minimum
range gets GEV's close-range rules, moved here from trackedCamera.js:
an exact first framing, no zoom coasting, a zoom floor, and an eye that
never crosses through the target. followChanged now fires only when
what is followed changes, on the stub too. No layer follows through the
port yet.
2026-11-06 11:49:09 -08:00
Sameh Khamis 1d9c731c10 Orbit, move, look and zoom through the camera port
The Cesium camera port gains move, look, rotateAround and zoom, and the
browser conformance run now checks them, the lookAt-frame commands, the
changed thresholds and moveEnd (timed by the clock, not a frame count).
Orbit, the Bhote Koshi focus and cinematic, the launch zoom anchor and
replay chase, the camera verbs' motion tick and roll levelling, and
voice zoom write through the port.
2026-11-06 10:21:04 -08:00
Sameh Khamis 68855b2089 Undo the reformatting of four camera-slice tests
The camera-port commits ran prettier over four test files that were
never prettier-formatted. Restore their earlier text and keep only the
camera-port edits.
2026-11-06 09:55:05 -08:00
Sameh Khamis bc69e8a4ee Fly the director, annotations, voice framing and local GeoJSON through the camera port
The scene director's shot flights, start pose and stops, the annotation
framings and fly-to, the voice overhead framing and the local GeoJSON
click flight now drive the renderer's camera port. The director's stops
look the camera up with findCamera: teardown can run after the viewer is
gone, and then there is no flight to stop. The local GeoJSON flight,
part of the public infrastructure export, works on a plain Cesium viewer
through the unbound camera, and looks it up before taking its input
hold. Voice zoom stays for the move slice.
2026-11-06 09:49:30 -08:00
Sameh Khamis 0b1c5d0f23 Fly the remaining flights through the camera port, on any viewer
The embed view, the celestial-ring focus, the ground-guard lift, the
Bhote Koshi approach and orbit, launch selection and replay flights, and
the cockpit's view and takeover now drive the renderer's camera port.
The contract gains a sineInOut easing, pinned by a conformance trace.

A viewer with no renderer bound, such as one a consumer of a public
export brings, now gets a camera port too, as it already got an input
port: cameraOf builds one over the viewer's own Cesium camera and keeps
it for the viewer's life, until a renderer is bound. The Cesium adapter
provides the factory as it loads; code that reaches Cesium takes cameraOf
from the adapter so the factory comes with it, and findCamera answers
null where a missing camera is not an error (HUD, share link, world
focus, Recent Imagery's ZOOM IN). A test walks every package export and
fails one that can reach the bridge without loading the factory.

A destroyed viewer gets no camera and lets go of a built one, and the
application renderer stays bound until its viewer is destroyed.
2026-11-06 08:21:57 -08:00
Sameh Khamis e3baeec223 Fly layer focus flights through the camera port
flyToBounds takes an easing, as flyTo does: in the contract, the Cesium
port and the reference recorder, pinned by a new conformance trace.

The flights of radio, awareness, CCTV, submarine cables, cyclones, the
launch preview, installations, ALPR, street-level framing, recent
imagery, weather and world focus now go through the renderer's camera
port. Street-level framing cancels its own flight handle rather than
whatever flight is current. worldFocus.js no longer reaches Cesium and
leaves the allow-list.

Layer pose reads stay for each layer's own slice. The local GeoJSON
flight stays on Cesium: gods-eye-view/infrastructure is used with plain
Cesium viewers that have no renderer bound.
2026-11-05 20:46:58 -08:00
Sameh Khamis f95324acb1 Fly the camera through the renderer's camera port
The Cesium camera port moves into render/cesium/camera.js, with the
listener list and position conversion shared from common.js. It now
covers flights (flyTo, flyToBounds, cancelFlight, flying), lookAt and
release, command hooks, and a flightEnd event for the flights it starts.

A flight's end that Cesium raises inside one of the port's own calls is
delivered once Cesium's call returns, so its callback sees the flight
let go of. A flight Cesium declines to start, mid scene-mode morph,
ends as cancelled instead of never settling.

Flight traces run in Node on the recorder's headless camera with a
virtual clock; the browser harness keeps the instant traces.

The startup fly-in, landmark and viewport flights, share-link restore
and the shell's camera state now go through the port. camera.js and
sharelink.js leave the Cesium allow-list.
2026-11-05 20:15:52 -08:00
Sameh Khamis de0ff6cd2d Read the HUD and share-link camera through the renderer's camera port
The first camera-family slice. The camera port's events move into a
shared module (render/camera/events.js) that the stub and the Cesium
port both use: listener lists, delivery with errors reported, and the
contract's per-listener `changed` measure.

The Cesium port gains:
- camera.on: `changed` measured per listener on every tick, where Cesium
  measures its single threshold (the counts match Cesium's own at each
  threshold); moveStart and moveEnd relayed from Cesium's camera;
- camera.viewRectangle, in degrees (now in CESIUM_CHECKS);
- lon and lat in pose, and NaN where Cesium has no cartographic position.

Code that still holds the viewer reaches the renderer through
rendererOf(viewer); the application renderer binds it on create and
unbinds on destroy.

The HUD reads its telemetry from the camera port and no longer imports
Cesium (off the allow-list). The share link writes the hash from the
pose. Its restore path stays on the viewer's camera for a later slice.

Behaviour changes:
- The share link listens to `changed` at its own 0.5 threshold, not at
  whatever global threshold layers had set, and also to moveEnd, so the
  hash always catches up with where the camera comes to rest.
- The HUD's and share link's camera callbacks run from the port's single
  relay, ahead of other moveStart/moveEnd listeners, and a throw in one
  is reported to the renderer's error listeners instead of escaping from
  Cesium's render.
2026-11-05 19:48:58 -08:00
Sameh Khamis 24bc8fce7e Deliver the application's pointer input through the renderer's input port
The Cesium renderer now creates the input port and exposes it as
renderer.input. The input dispatcher becomes an adapter over the port:
one port listener per event type, fanned out to its subscribers with the
ownership check read once per event, keeping the legacy event shapes.

- Ctrl+wheel zoom is switched on through renderer.input.ctrlWheelZoom.
- A dispatcher made for a viewer with no port creates its own port with
  takeStock: false, which borrows the stock select and double-click
  follow only while they are suppressed.
- The stock actions see the press as it began: a click whose press
  started while select or follow was held off never runs them.
- A scene accepts one input port; cesiumInputOf(scene) returns it.
- Releasing one mouse button while another is held no longer gives the
  held button a click. Cesium still ends that press.
2026-11-05 19:22:43 -08:00
Sameh Khamis 1f2479d3ab Give the Cesium renderer the contract's input port
src/render/cesium/input.js feeds the shared router from one
ScreenSpaceEventHandler: every button's press, release and click, double
clicks, moves and pinches, under any one modifier. The DOM event being
dispatched gives the full modifiers and pointer type; an action Cesium
raises on a timer takes its own modifier. Wheel comes from the canvas in
lines and is never cancelled. Clicks are picked once, lazily, through the
query port. Cesium ends every held button on any release; those presses
end there, but only the button released clicks.

The viewer's stock select and follow move off its handler and run first,
as they did, but only for a press no lease took and while not suppressed.
Camera holds go through the scene's shared holds, ctrlWheelZoom through
ctrlWheelZoom.js. The router gains a before-hook and dispose, which stops
delivery mid-event.

The conformance renderer exposes the port and the harness feeds it;
input.lease, input.lease.grab, input.lease.press, input.clickPick and
input.behaviours join the Cesium checks.
2026-11-05 18:50:18 -08:00
Sameh Khamis 21f284f79c Move Ctrl+wheel and trackpad pinch zoom into the Cesium renderer
installTrackpadPinchZoom and its pinch scaling move unchanged from
src/app/viewer.js to src/render/cesium/ctrlWheelZoom.js, where the input
port's ctrlWheelZoom will turn it on and off. The application scene
imports it from there; the application/viewer export no longer carries
this undocumented helper.
2026-11-05 18:34:10 -08:00
Sameh Khamis 60a3a2808e Share a scene's camera-input holds beyond the dispatcher
The take-over of the camera controller's enableInputs moves, unchanged,
to src/render/cesium/cameraInput.js. acquireCameraInput(scene) gives every
user one set of counted holds per scene, so the renderer's input and
camera ports can hold camera input alongside the dispatcher; the
controller gets its own property back when the last user releases. The
dispatcher acquires its share once its handler is bound. The input guard
names the one module allowed to write the flag, and the new render files
join the format scope.
2026-11-05 18:29:38 -08:00
Sameh Khamis 468590485b Give the Cesium renderer the contract's listener rules and error channel
Frame-phase and draw-set ready listeners now run in subscription order,
skip one removed during delivery, defer one added during it, and hand a
throw to the renderer's error listeners (or reportError) instead of into
Cesium's render loop. The renderer gains on('error' | 'contextLost' |
'contextRestored'); Cesium cannot restore a lost context, so it never sends
contextRestored. Dispose clears every list. renderer.listeners joins the
Cesium checks.
2026-11-05 18:18:28 -08:00
Sameh Khamis 3e48e453d0 Share the stub's pointer routing as an engine-neutral input router
The stub's lease and press routing moves to src/render/input/router.js,
unchanged, so a real renderer can feed it its own engine's events. The
router also keeps the behaviour holds (telling the renderer when one turns
off or on, unless it lacks it) and ctrlWheelZoom, and dispatch says who an
event went to.
2026-11-05 18:09:38 -08:00
Sameh Khamis 3837e453ca Keep the render governor's holds on the frame port
The governor no longer writes the Cesium scene's render mode itself: each
owner holds the renderer's frame port once, requests go to it, and the
port is the one place the loop's mode is set. The port now also keeps
the loop from redrawing for scene time alone, which the governor did.

The application installs the governor as soon as its renderer exists and
holds the loop continuous ('boot') until its tools are up, where the
governor used to be installed, so boot draws as before. The hidden-tab
suspension goes through the port too, and the application's renderer now
has the whole frame port.
2026-11-05 17:24:34 -08:00
Sameh Khamis 48f06c68c8 Complete the Cesium frame port
The frame port now does everything the contract asks of it on Cesium:
preUpdate says whether the tick draws (Cesium's own render test, read
live until the tick is over), holds are counted per reason, and suspend,
target fps, renderNow and diagnostics are real. Releasing the last hold
asks for one more frame.

whenSettled also waits until Cesium stops redrawing on its own. Each
request or worker task Cesium completes queues a redraw (a terrain
picker build after the camera moved, say), which no loading flag shows,
so it now waits for quiet frames and then a short stretch in which the
loop draws nothing by itself. It is bounded by its timeout, names a
stopped loop, and ends on destroy.

The app renderer still leaves out the members that steer the loop:
the render governor and the page's visibility handling own those
settings until they move onto the port.

The new frame.prediction check compares the preUpdate answer with what
each tick drew.
2026-11-05 17:11:25 -08:00
Sameh Khamis bdc7692292 Complete the Cesium query port
The Cesium renderer now has every QueryPort member: picks and drill
picks, depth and globe picks, rays, projection, occlusion, heights now
and streamed, clamping and ray casts. Capabilities come from what the
scene supports (depth textures for pickPosition, sampleHeight,
finestHeights and rayCast), in both the conformance and app renderers.

- Picks skip unpickable and hidden items, and report each item once. A
  pick under an unpickable item stops at anything that hides the rest.
  drillPick's limit counts after that filtering, so 0 gives none.
- Height queries and ray casts see through sets not drawn with
  includeInHeightQueries, and through the sets a caller excludes, by
  their items' pick ids. The list is built once per change to the sets.
- `tiles` samples and casts run with the globe hidden for the call;
  `terrain` casts use the globe alone. A sample reports whether it came
  from tiles, terrain, the ellipsoid or a set.
- A position clampToSurface cannot read is a miss, not a failed batch.
- Terrain on an ellipsoid is height 0. Before, sampling it threw, for
  want of tile availability.

whenSettled also waits for icon images, and fails when one cannot load.
The conformance checks that pick or query settle first, because a
renderer answers from what it has loaded; the stub settles at once.
The Cesium renderer now passes 14 checks in the browser, and its
visual references are unchanged.
2026-11-05 16:22:48 -08:00
Sameh Khamis 92fd2b0c2e Run the conformance logic suite on the Cesium renderer
The conformance page can now run the logic checks against the Cesium
scene renderer as well as the stub. CESIUM_CHECKS lists the checks of
the ports Cesium already has; each phase 2 migration adds its own, so
the suite shows how far the renderer is through the contract. All eight
listed checks pass in the browser, and the stub still passes its 69.

A harness frame is one tick of the render loop, observed through
frame.on('preUpdate'). It asks for no frame itself, so a check's own
requestRender is what makes a tick draw, and a loop that stops fails
the check after five seconds instead of hanging the run. The harness
frees each WebGL context on destroy.

FrameTime.dt is now measured rather than fixed at 1/60, and renderOnce
honours timeoutMs. A scene renderer made without the models capability
has no draw.models.
2026-11-05 16:04:02 -08:00
Sameh Khamis a5143f4467 Share the Cesium ports between the app and conformance renderers
The frame, draw, camera, query and capture ports move out of the
conformance scene renderer into src/render/cesium/ports.js, unchanged.
The app's transitional renderer, now src/render/cesium/appRenderer.js,
builds the same ports over the full viewer; it still leaves out globe,
whenSettled and setTime, which would misreport photoreal tiles and stop
the app clock.

Disposing the ports now settles a pending renderOnce and detaches the
draw sets, so a model that loads after destroy is destroyed rather than
added to a destroyed collection. A viewer whose ports fail to build is
destroyed with its credits.

Checked in a browser: the app boots with one credits element, and all
eleven conformance scenes match their references exactly.
2026-11-05 15:50:48 -08:00
Sameh Khamis 03f2e2a9b9 Add the ratchet that keeps Cesium inside the renderer
Outside src/render/cesium/, only the 236 files on cesiumAllowList.json
may reach Cesium, and each must still do so, so porting a file removes
its entry. A file reaches Cesium by importing it or the Cesium adapter,
by name or by path, or by being handed the namespace as a parameter,
binding or property. Files added to the list since origin/main fail the
check, once the list is on main. The renderer contract, stub and
conformance suite must stay Cesium-free, and runtime code in a language
the check cannot read fails it.
2026-11-05 15:10:04 -08:00
Sameh Khamis 8d7f82f110 Create the application scene through a renderer plugin
createApplicationScene takes a renderer plugin, defaulting to Cesium,
which builds today's viewer and credits exactly as before. Its renderer
is transitional: it has only the members it really has, with read-only
capabilities, and a test pins the contract members still to come. Code
not yet ported reaches the viewer through cesiumViewerOf, never through
a renderer member. A plugin whose renderer has no viewer is refused and
destroyed, since the application still runs on the viewer. The result
now includes the renderer.

Not yet checked in a browser.
2026-11-05 14:51:42 -08:00
Sameh Khamis bcd24c458b Check the src/geo tests' formatting
The geo, celestial and quaternion tests join the format check, as the
runtime modules beside them already do.
2026-11-05 14:41:35 -08:00
Sameh Khamis 6060c2cee8 Convert voice action positions and distances through src/geo
Voice actions read degrees from ECEF positions with toLonLatH and
measure distances with src/geo, at the sites that only did maths. A
position that converts to NaN now reads as unknown, never as NaN
coordinates. The analyst test gives its subject a real ECEF position
instead of patching Cesium. Sites that keep a Cesium Cartographic, or
use the camera or picks, wait for the renderer ports.
2026-11-05 14:33:32 -08:00
Sameh Khamis bf758a0abb Convert share link angles and positions through src/geo
Share links convert degrees, radians and the flight destination with
src/geo. The hash and the destination are bit for bit the same as
before; the flight itself still goes through the Cesium camera.
2026-11-05 14:28:10 -08:00
Sameh Khamis e32988ae5e Match Cesium's fromDegrees exactly and keep toEcef in the stub
fromRadians divides by gamma as Cesium does, so geodetic to ECEF is now
bit for bit Cesium's, and the test checks it exactly. Its tests also
cover three height bands, points inside the Earth and NaN input. toEcef
reads the renderer contract's Position, so it moves to the stub's maths.
The ellipsoid docs pair each conversion with Cesium's.
2026-11-05 14:28:10 -08:00
Sameh Khamis 52d82b453c Add quaternions to src/geo
Ports Cesium's axis-angle, product, rotation-matrix and heading-pitch-roll
quaternions, with rotations given as frame axes. They match Cesium bit for
bit, including heading-pitch-roll frames on the polar axis. The model
convention is documented: at heading 0 the forward axis points east.
2026-11-05 14:15:13 -08:00
Sameh Khamis fd1c09dbbc Add the sun and moon to src/geo
A port of Cesium's Simon 1994 sun and moon positions, its TEME to
pseudo-fixed rotation and the JulianDate time handling, with epoch
milliseconds as the only input. Results are identical to Cesium's,
tested with exact equality from year -500 to 3000 and across every
leap second. Times outside the model throw a RangeError, where
Cesium's debug build throws.
2026-11-05 14:10:48 -08:00
Sameh Khamis 5652236a27 Read the agent world packet's camera through src/geo
The world packet converts the eye position with toLonLatH instead of
Cesium's Cartographic, so the agent-world-packet group no longer needs
cesium. Degree conversions use precomputed factors, as Cesium.Math does,
so results stay bit for bit the same.
2026-11-05 14:01:57 -08:00
Sameh Khamis 9f165e68f3 Move the stub's geodesy into a renderer-neutral src/geo
WGS84, ECEF and geodetic conversions, surface normals and east-north-up
frames, as plain {x, y, z} objects, exported as ./geo. A test checks
them against Cesium over random points and the poles. The stub's maths
re-exports them and keeps only the camera angle conventions.
2026-11-05 13:49:53 -08:00
Sameh Khamis fefdf242ee Settle imagery, world data and sky capabilities in the renderer contract
- setFrames takes a timed source (URL frames, or video tiles in
  segments) on the scene clock by default or its own; frameReady fires
  once each time the shown frame is drawn.
- drapeOn is 'surface' | 'objects' | 'all' plus lit; imagery.host() and
  onHostChange go, and the drape rule Core derives is written out.
- ImagerySpec.fetchTile answers bytes, missing or defer on every
  renderer; tileTransform, admit and canvas imagery need imageHooks, and
  add() throws for them without it.
- Generic names for the default renderer's world data: worldDataToken,
  the photoreal fallback route, and world terrain and imagery.
- clouds and water capabilities, with globe.setClouds and setWater.
2026-11-05 13:26:14 -08:00
Sameh Khamis e9c0dc5405 Settle leases, styles, credits and controls in the renderer contract
- claimPointer takes a grab filter, asked on each down: a lease takes
  only the presses it grabs, and wheel, pinch and every other press stay
  with the camera and everyone else. Each event of a press goes where its
  down went, per button, so a claim never takes a press under way.
- Core owns the styles: effects.defineStyle passes a GLSL (and optionally
  WGSL) full-screen pass, with the shader interface written out.
- Credits are plain data ({ text, href, logoUrl, inline }) that Core
  renders; CreateOptions.creditContainer goes.
- controls.coast { drag, zoom } in seconds replaces the inertia trio; the
  stub's defaults are the default renderer's, converted.
- The frame order is the default renderer's own: a move made by follow or
  in preUpdate reaches changed on the next frame.
2026-11-05 13:11:59 -08:00
Sameh Khamis 14f06df735 Settle the draw decisions in the renderer contract
- lodGroup links items across sets as one thing at different detail;
  picks name the member shown and report the group. displayRange is
  half-open.
- An orbits capability: the renderer propagates two-line elements with
  SGP4 on the set's motion clock. The stub propagates with satellite.js.
- Height queries see draw sets only with includeInHeightQueries (was
  excludeFromHeightQueries); HeightSample.source gains 'set'.
- courseGlide is a capability; courseBlendMs eases the course on every
  renderer.
- Arc segments climb at climbMps; a negative minDtS stays for the
  aircraft warm-up.
- The star catalogue is optional, a hidden column row keeps its motion,
  and draw.clampState() reports the drape fallback.

Checks cover the climb and warm-up, lodGroup ranges, orbits against SGP4
(with and without motionSegments), hidden rows resuming, and sets not
answering height queries by default.
2026-11-05 12:50:58 -08:00
Sameh Khamis 2fc99e7c19 Define the renderer contract, with a stub renderer and a conformance suite
The renderer contract is what the application asks of a 3D renderer, so a
renderer other than Cesium can draw the same application. It is nine ports
(frame, globe, draw, camera, query, input, effects, credits, quality) plus
the renderer itself, one strict .d.ts per port in src/render/contract/.
PORTS lists every member at run time, and a TypeScript-compiler test fails
when the list and the types disagree on one member. An optional member
exists only with its capability; a required one answers null or empty
without it. Nothing in the application uses the contract yet.

The stub renderer (src/render/stub/) implements every port in memory and
draws nothing. Its camera is a port of the default camera's maths: views,
lookAt, flights with their height arc and easing, rectangle framing on the
geodesic midpoint, the view rectangle with its horizon and pole cases,
changed thresholds, follows, and flights that finish inside the call.

The conformance suite (src/render/conformance/) runs 59 checks against
any renderer through the contract alone, in Node or in a browser page. The
camera checks compare traces recorded from Cesium's own Camera, headless on
a virtual clock (scripts/record-camera-goldens.mjs), to 1 mm, 1e-9 and
1e-3 px. The other checks cover listener rules on every subscription, the
frame loop, draw sets, picks, motion clocks, heights, leases, imagery,
styles, credits and quality knobs. The stub passes all of them but the
model check, which needs a glTF asset.

Reference scenes draw one scene per primitive family, a city and a
calibration path from orbit to the street, from contract calls only and
with no network. /dev/conformance/ draws them on the Cesium scene renderer
(src/render/cesium/sceneRenderer.js), records references through a
dev-server-only, loopback-only endpoint, and compares captures with
pixelmatch's antialias detection plus a solid-pixel count. Measured on
Cesium: every MSAA setting passes, and a dropped 4 px point, a dropped 2 px
line and a 1 px shift of the view fail. visual.js states the limits.

The package exports ./render/contract, ./render/stub and
./render/conformance. Documented in docs/RENDERER-CONTRACT.md,
docs/CODE-BOUNDARIES.md and docs/CURRENT-STATE.md.
2026-11-04 14:21:48 -08:00
Sameh Khamis fb30287898 Draw the last native world labels through the label overlay
The Directions A and B letters and a data pack's media label were the two
Cesium text labels left, kept by an allow-list in noCesiumLabels.test.mjs.
Both now publish entries to the shared world-overlay host, and the
allow-list is gone, so any Cesium label in src is a test failure.

Directions publishes the letters to the directions-markers source; the
markers keep only their points. A clicked endpoint's letter hangs over the
surface that was clicked. One placed without a click stays hidden until its
ground-floor cell resolves, then requests a render, so a still camera shows
it; a cell that settles with no height falls back to the ellipsoid. Each
position is computed once.

A pack resource handle gains label(entry). The application passes its
shared overlay host to SceneDirector through dataPacks.overlayHost; without
one a media pack fails to load rather than drawing a native label. Each
labelled resource takes a source from a pool and returns it on dispose, as
the host never removes a source, so two presentations of one pack never
clear each other's labels and repeat loads add no sources. A label written
after dispose is ignored. The media label is protected, so it always draws
as before.

Live (Denver, keyless globe): a clicked A and a programmatic B both anchor
on their dots over 1.58 km ground. Documented in docs/CURRENT-STATE.md and
docs/DIRECTOR-DATA-PACKS.md.
2026-11-04 12:30:31 -08:00
Sameh Khamis ad8adc85f4 Replace Cesium monkey-patches with hooks and keep internals in one module
The arrival ground guard replaced camera.flyTo, flyToBoundingSphere,
setView, lookAt and cancelFlight with wrappers so a direct caller would stop
it. It now remembers the pose it started on and, before each retry, yields
if a flight is under way or the camera has moved across the ground, down or
around. A straight rise is not a move: Cesium's terrain collision lifts a
buried eye that way as tiles load, which is the case the guard exists for.
This also catches owners the wrappers missed (a pose written directly,
lookAtTransform), and no camera method is replaced.

Recent imagery and weather overwrote provider.requestImage (weather also
provider.credit) to ration tiles. They now state that policy as a hook with
withTileRequestHook(provider, { requestImage, credit }) from
src/render/cesium/tileRequests.js, which hands Cesium a provider delegating
to the original and leaves the original untouched.

Every private Cesium field Core reaches goes through a named helper in
src/render/cesium/internals.js: the main and offscreen pick views and their
command extents, a billboard's collection, a cluster's point collection,
the widget's render-loop latch, the WebGL context and the current camera
flight. internals.test.mjs fails when the installed Cesium stops declaring
one of them, and a source scan fails on private access anywhere else. The
cyber sonar's primitives.update wrap stays until the effects work.

Live (Austin, keyless globe): a buried eye is lifted to 253 m over a 133 m
surface; a setView or flyTo by another caller makes the guard yield; radar
loads its tiles through the hook. Documented in docs/APPLICATION.md and
docs/CURRENT-STATE.md.
2026-11-04 11:15:35 -08:00
Sameh Khamis d51ea4007b Ask for heights through one service per scene
About 20 sites called scene.sampleHeight, globe.getHeight,
scene.clampToHeight or the MostDetailed variants directly, each with its own
idea of a miss (undefined, NaN, a throw). They now ask heightsFor(scene) in
src/queries/heights.js:

- now(point, { against, exclude }) answers from what is loaded, or null;
  against is 'surface' (what is drawn) or 'terrain' (loaded terrain only).
- clampNow(position) moves a position onto the drawn surface, or null.
- batch() and clampBatch() wait for the most detailed tiles: null per
  missed point, a rejection when the whole query fails.
- supported() reports { surface, clamp } without allocating.

Cesium still answers now() synchronously, so nothing changes on screen and
nothing is slower: in the live app 1,000 calls cost the same through the
service as direct (terrain 0.5-0.7 ms, surface 2-6 ms a call, run-to-run
noise larger than the difference). Every site already treated a miss as
"hold the last value or fall back"; a renderer that can only answer from a
cache or asynchronously can now plug in at one place.

- A Bhote Koshi evidence point whose terrain sample misses stays at its
  fallback elevation instead of reading the miss as sea level.
- The mesh floor sampler skips a scene without depth-texture support
  instead of spending its budget on probes that throw.
- The world annotation probe's trace reads hit/missed.
- A source scan forbids direct height calls outside the service.

The service is exported as ./queries/heights and documented in
docs/APPLICATION.md.
2026-11-04 09:24:30 -08:00
Sameh Khamis b51f1bf7c5 Take pointer input through one dispatcher per scene
Every layer and tool bound its own ScreenSpaceEventHandler and picked the
same click again: with the usual layers on, one click ran about 26
scene.pick and 2 drillPick passes (120-215 ms in NYC with 18 layers). Pointer
input now comes in through src/input/dispatcher.js, one handler per scene.
A click is picked lazily and at most once per kind of query, and every
subscriber reads the same ClickEvent (pick, hit, id, ownerId, gesture,
drill, ground). The same click now costs 3 picks and 2 drills, 17-70 ms.

- Ambient onClick subscribers run only while no tool holds the pointer,
  checked once per event; tools subscribe with their lease. Moves and
  releases always reach ambient subscribers, so hover and drags end cleanly.
- Camera input and the viewer's stock select/track clicks are ref-counted
  holds (suspendCamera, suppressStockClicks). The dispatcher owns the
  controller's enableInputs, so a Cesium flight that ends no longer turns
  input back on under a drag or the cockpit.
- The draw tool, imagery box, CCTV gizmo, cockpit, directions and every
  selecting layer use it; bindTrackingClickGesture is gone (the gesture is
  on the event). A source scan forbids new handlers, enableInputs writes and
  stock-click removal outside the dispatcher.
- The sensor-style pick correction transit had now applies to every layer.
- Radio's 8-pick near-miss ring is one 17x17 windowed drill.
- A missed pick passed to pickContact is no longer picked again, and a
  touch tap's contact search shares the click's drill.
- A gizmo drag whose release Cesium never reported ends on the next press
  instead of leaving the camera held.

The dispatcher is exported as ./input with the pointer-lease functions, and
documented in docs/APPLICATION.md.
2026-11-04 08:48:51 -08:00
Sameh Khamis 6035de88e2 Delete the billboard and model mirrors; models become layer-held records
Aircraft draw from GPU icon sets, so the billboard mirror, the billboard
treatment and the aircraft visual-state module had no callers left. 3D models
are now records the layer holds under the contact's id (modelRecord.js):
show and placement are written through showModel/placeModel and read back
from the record; renderer-measured values stay on record.model.

The tracked-model click check only matches when a tracked model exists, so
an id-only pick is never mistaken for a click on the tracked plane.
2026-11-03 22:36:27 -08:00
Sameh Khamis f008120b4c Draw FIRMS detections from a GPU icon set 2026-11-03 22:09:57 -08:00
Sameh Khamis 00d1bee127 Draw CCTV cameras from a GPU icon set 2026-11-03 21:47:16 -08:00
Sameh Khamis 89b07fddfd Draw live AIS vessels from a GPU icon set 2026-11-03 21:16:19 -08:00
Sameh Khamis fe32e47bdf Share the aircraft 3D-model regime between flights and military 2026-11-03 20:55:09 -08:00
Sameh Khamis 9fde0a2a59 Draw military flights from the shared GPU icon set
The flights icon planning and presentation move into
`createAircraftIcons` (src/data/aircraftIconLayer.js), and the motion
regime both layers dead-reckon from into src/data/aircraftMotionRegime.js.
Flights keeps only its grounded-floor pass on top.

Military now draws every contact as a row of that icon set, like
flights: the billboards, the cull positions and the rotation-pass
signature go. Tracking hides the row and untracking re-plans it from
the tracked course. Queries read the drawn row position and judge the
horizon as the shader does. Snapshots that arrive before init or after
destroy are ignored, and per-contact lookups find no contact then
instead of throwing.

With about 120 live military contacts, preRender p95 drops from
0.7-0.8 ms to 0.2 ms on the same views. Render time and frame gaps are
unchanged or slightly lower.
2026-11-03 20:30:43 -08:00
Sameh Khamis 2c4c66c710 Draw civil flights from an icon set moved on the GPU
Every flight is now a row in the layer's icon set. Its motion segment
follows the same regime `_deadReckon` picks (interpolate between fixes,
warm up backward from the oldest, coast forward from the newest), and
one shared `_motionRegime` makes that choice for both. The shader moves
every untracked contact between polls, so the 12 Hz fleet tick no
longer touches each billboard. It now only re-plans expired rows,
floors grounded contacts, advances focus while needed, and runs the 3D
model regime.

Grounded contacts the floor lifts are pinned to a hold at the floored
position. Their nose glides on the GPU. Contacts within 50 km re-floor
every tick, and farther ones take turns about once a second. Low
contacts are judged for the horizon at no lower than 12 m above the
ellipsoid, as before. Below hover speed a contact's nose holds. A row
whose fleet model is drawn keeps its focus updated, because the model
reads it. Untracking re-plans and re-floors the icon from the tracked
course.

On the same views with about 6,700 OpenSky flights, frame gaps at p95
drop from 28-40 ms to 17 ms, and the fleet tick from 16-37 ms to about
2 ms at most.
2026-11-03 19:40:39 -08:00
Sameh Khamis 5f36eb0e0d Add columnar icon sets drawn by an instanced Cesium primitive
Icon rows live in typed-array columns: one packed motion segment (hold,
lerp or arc, with a course glide), style, colour and pick ids per row.
The primitive draws every row in one instanced call, dead-reckons in the
vertex shader, matches the billboard treatment the fleet layers use, and
picks and drill-picks like a billboard. Cyber sonar covers icon sets.
2026-11-03 18:35:12 -08:00
Sameh Khamis 7204d66f0b Keep installation and local GeoJSON pick state off Cesium entities
Installation selection fills and outlines record the installation they
belong to in the layer's installationMarkers store instead of an
installationId expando, and the click handler resolves them from there.
Named-marker points keep their { installationId } pick payload, which is
Core's own object, so sibling layers treat a point click as before; the
marker remembers its record id for visit() instead of reading the point.

The local GeoJSON package tracks the entities each layer loaded in a
per-instance set instead of a __localLayerId tag, and passes the stem
base to registerEntityContext as targetPosition instead of hanging it on
the entity; FIRMS does the same, and voice targeting reads the context
record. QA scripts find a layer's features by their Core context id.
2026-11-03 14:57:31 -08:00
Sameh Khamis c5c0803901 Answer earthquake, CCTV pick and tracked sprite reads from Core
Earthquake analyst records come from the events the layer drew, gated on
its own enabled flag, instead of reading each entity's position and
property bag back; the discs no longer carry the bag. CCTV coverage rays
and projection planes record their camera id in a layer pick store
instead of a cctvCameraId entity property, with the same ownership checks.

The tracked flight, military and local ADS-B billboards record their size
and distance scaling as the entity record's sprite, which the close-range
camera guard and the screen annotations' tracked footprint now read in
place of the billboard's property getters.
2026-11-03 13:32:53 -08:00
Sameh Khamis 66869b09ef Read aircraft model placement from Core's mirror
Flights, military and local ADS-B now write their models' show and world
matrix through setModel in src/data/modelMirror.js, and read them back
through modelState: the fleet and tracked handoffs, the tracked visual and
trail anchors, detection anchors and modelAnchorWorld. A matrix is copied
into Core's record and into the model's own matrix, so the shared scratch
can be passed directly and models still never share one matrix object.

What Cesium measures (ready, computedScale) is still read from the model,
and colour stays live as on billboards, because cyber sonar writes it too.
Test seams mirror the models they install, as they do billboards.
2026-11-03 12:58:50 -08:00
Sameh Khamis 47f7eeb00e Read vessel, CCTV and FIRMS billboard state from Core's mirror
The three layers now add their billboards through addBillboard and write
the mirrored fields through setBillboard, so selection, cards, queries,
focus, view thinning and the FIRMS horizon cull read what was pushed from
Core instead of from Cesium. Vessels and FIRMS state their sprites' own
sizes (32 px ship artwork, the glow canvas at twice the core size), which
the mirror needs. A vessel helper, iconPosition, replaces the repeated
mirror-or-visual position lookups, and the vessel visibility pass writes
show only when it changes, as CCTV and FIRMS already did.

A billboard never given a position now has none in the mirror rather than
the origin.
2026-11-03 11:14:33 -08:00
Sameh Khamis 1cfff5803a Read flight and military billboard state from Core's mirror
Flights and military now add their billboards through addBillboard and
write show, position, scale, rotation, size and distance scaling through
setBillboard, which keeps Core's copy in src/data/billboardMirror.js.
Their reads go to billboardState instead of the Cesium billboard, so the
layers no longer read back what they pushed to the renderer.

A mirrored billboard needs an explicit width and height, because unset
Cesium reports the loaded image's size. Colour is not mirrored yet: the
cyber-sonar theme writes it too and tells the writers apart by the live
value, so it moves with the effects port.
2026-11-03 10:56:08 -08:00
Sameh Khamis a40e5c334c Keep cable pick info and the prewarm flag off Cesium objects
Submarine cables wrote each pick's info both to a per-load WeakMap and to a
__gevTeleGeography expando, always together; one cablePicks store replaces
both and resolves the same objects. The voice view-target prewarm marks its
viewer in a WeakSet instead of a __gevViewTargetPrewarmInstalled property.
2026-11-03 10:01:12 -08:00
Sameh Khamis f86dcc9179 Keep aircraft, installation and viewshed state off Cesium objects
The aircraft layers' model spec key, IR boost and belly offset and the
billboards' large-icon flag move into aircraftVisuals, one store shared by
flights, military and local ADS-B. Installation markers' last drawn height
and selection move into installationMarkers. The CCTV viewshed QA tag is now
the viewshedCameraId record field, which qa-cctv-v2 reads through
entityRecords. Test fixtures drop a placement flag nothing reads.
2026-11-03 09:54:41 -08:00
Sameh Khamis 42b17ffab6 Keep the context id in Core's entity records instead of on picked objects
registerEntityContext stamped __gevContextId on the entities, primitives and
carriers it registered, and selection and pick resolution read it back. It
is now the contextId field of the entity's Core record.
2026-11-03 09:40:15 -08:00
Sameh Khamis 07c8d7a17c Keep ALPR marker state in a layer store instead of on Cesium entities
The ALPR layer's private per-entity fields (record, clamp, canvas and display
anchors, wedge, native appearance, pick position, shown, surface check) move
from gevAlpr* expandos into alprMarkers, a store made by the new
createEntityStore(). The shared entity records are built on the same factory.
2026-11-03 08:24:33 -08:00
Sameh Khamis 95c0070412 Keep tracked-entity state in Core records instead of on Cesium entities
Flights, military, satellites, installations, local ADS-B and ALPR hung
gevTrackedId, gevDisplayPosition, gevVisualPosition, gevSelectionOrigin and
gevLabelModel on the Cesium entities they draw, and the tracked readout,
follow camera, cockpit, share links and awareness read them back. They now
live in src/data/entityRecords.js, a Core-owned record per entity: layers
write entityRecord(entity), readers use readEntityRecord(entity). Nothing
changes on screen. This is the first slice of the renderer plan's state
mirror (P1).

The module is exported as ./data/entity-records so hosted code can read the
same records, and window.__godsEyeView.entityRecords gives the QA scripts
the same access from the page.
2026-11-03 08:09:00 -08:00
Said Turksever ea69bb4ce2 Merge pull request #1435 from Rohilalala/fix/d-military-positionless-rows
fix(military): don't treat adsb.lol's position-less rows as a partial snapshot
2026-11-03 15:44:49 +00:00
Said Turksever 3afbd0c638 Merge branch 'main' into fix/d-military-positionless-rows 2026-11-03 15:28:59 +00:00
Said Turksever 3ad3cc2ab7 Merge pull request #1308 from bilawalsidhu/codex/overpass-admission-review-fixes
fix(overpass): keyless gate behind a reverse proxy, loopback admission headroom, per-phase arrival budgets
2026-11-03 15:27:19 +00:00
Said Turksever 34906c9abb Merge branch 'main' into codex/overpass-admission-review-fixes 2026-11-03 15:07:08 +00:00
Said Turksever e5d15f24d5 Merge pull request #114 from Tom-Neverwinter/prereq-10-osm-overlays
feat: OpenSeaMap / OpenSnowMap overlay layers (#10)
2026-11-03 15:02:24 +00:00
Said Turksever a6d20120a7 Merge upstream main into PR #1435 for integration review 2026-11-03 14:50:23 +00:00
Said Turksever 315c561a7b Restore main's catalog strings and re-pin the voice schema after the main merges
The two Update-branch merges resolved the locale conflicts by keeping
this branch's side, which dropped nine strings main had added (Utilities
panel, drawing colours, cockpit exit). Rebuild each catalog as main's copy
plus Sea Marks and Ski Pistes, and re-pin the voice tool schema length and
digests on top of main's Utilities target.
2026-11-03 14:49:54 +00:00
Said Turksever 8e40d2689b Merge branch 'main' into prereq-10-osm-overlays 2026-11-03 14:34:32 +00:00
German Khokhlov f3adccd01a Merge pull request #1456 from quanticsoul4772/upstream/terrain-partial-heights
fix(terrain): keep known heights when a terrain request fails
2026-11-03 16:03:07 +04:00
German Khokhlov c664f8eacb Merge branch 'main' into upstream/terrain-partial-heights 2026-11-03 15:28:20 +04:00
German Khokhlov 39c648094b Merge pull request #1488 from bilawalsidhu/german/hal-623-hud-summary-locale
fix(hud): AI summary in the UI language, canonical feed state, partial aggregation
2026-11-03 15:22:40 +04:00
German Khokhlov be53c338c8 Merge branch 'main' into upstream/terrain-partial-heights 2026-11-03 14:59:12 +04:00
German Khokhlov d413362467 Merge branch 'main' into german/hal-623-hud-summary-locale 2026-11-03 14:55:40 +04:00
German Khokhlov b80c54b7f1 Merge pull request #1474 from bilawalsidhu/fix/hal-722-radio-next-no-autoplay
Keep Radio Next and Previous from starting playback before Play (HAL-722)
2026-11-03 14:55:14 +04:00
German Khokhlov ac5164b14b Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale 2026-11-03 14:45:45 +04:00
German Khokhlov 03c168d7c9 refactor(hud): share the warning line and trim the summary helpers
One provenanceLine() builds STATE | text | LAYERS for the HUD and
get_hud_caption. hasWord is one lookaround regex, capAtWord one grapheme
pass. Feed-state helpers are imported from layerSnapshot.js directly, and the
kept-whole sanitizer cases are one table.
2026-11-03 14:45:32 +04:00
German Khokhlov ab8691383b docs(hud): describe the character-only summary cap and trim comments 2026-11-03 14:34:32 +04:00
German Khokhlov 238a3d0634 Merge remote-tracking branch 'origin/main' into fix/hal-722-radio-next-no-autoplay 2026-11-03 14:33:12 +04:00
German Khokhlov 81ffa5b663 Suspend the tuner noise context once a pending resume settles
A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests.
2026-11-03 14:32:55 +04:00
German Khokhlov 00cce1e672 Merge pull request #1515 from bilawalsidhu/codex/docs-codespaces-limitations
docs: clarify Codespaces feature restrictions
2026-11-03 14:28:44 +04:00
German Khokhlov 33381c5981 Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts:
#	src/voice/gevActions.test.mjs
2026-11-03 14:28:10 +04:00
German Khokhlov 57da1090d0 docs: shorten Codespaces restrictions 2026-11-03 12:09:46 +04:00
German Khokhlov c6de459436 docs: clarify Codespaces feature restrictions 2026-11-03 12:06:01 +04:00
Said Turksever bd7ef69843 Merge branch 'main' into prereq-10-osm-overlays 2026-11-02 17:48:19 +00:00
Said Turksever c35eba1704 Merge main into prereq-10-osm-overlays 2026-11-02 17:27:07 +00:00
Said Turksever fc7ff1055c Split Open Map Overlays into Sea Marks and Ski Pistes layers
Each overlay is now its own Infrastructure row instead of a chip on one
shared row: sea-marks (token 6) and ski-pistes (token 7), both
enabled-only, built by createOsmOverlayLayers() from one descriptor
list. Drops the option group and its share-codec defaults, gives each
layer its own name, voice entry and CURRENT-STATE row, and shifts the
token-ledger fixtures to the next free digit 8.
2026-11-02 16:20:08 +00:00
Said Turksever c489e65796 Keep one OpenStreetMap copyright link in the data credits
The generic OpenStreetMap credit owns the link; the overlay credits name
OSM in plain text, as dataCredits.test.mjs requires.
2026-11-02 16:07:01 +00:00
Said Turksever ce1ad3b95a Merge main into prereq-10-osm-overlays and port Open Map Overlays
Main moved on since this PR opened; the overlay layer is ported to the
current contracts:

- Layer-state token 6 (j belongs to transit; new single-character
  tokens are digits) with a reservation-ledger row, and the option owner
  renamed to the layer id so the compact share codec can encode it.
- Constructed per catalog by createOsmOverlaysLayer() in
  constructCatalog.js; src/standalone/data.js no longer registers layers.
- Drapes through maps/imageryHost.js, so the overlays show on Google 3D
  (tileset imagery) as well as the globe stacks, and follow map-stack
  switches. Imagery layers are created on enable and destroyed on
  disable/destroy.
- Translated chip labels and layer name in every locale, LAYER_NAME_KEYS,
  Infrastructure panel group, voice manifest entry.
- Attribution: OpenSeaMap tiles CC BY-SA 2.0, OpenSnowMap credit as
  required, OSM ODbL credit for both; bulk-download note in DATA_SOURCES.
- CURRENT-STATE row, changelog fragment, share-codec absent defaults,
  updated token-ledger fixtures and voice schema pins.
2026-11-02 15:55:40 +00:00
quanticsoul4772 468d5078e3 Merge branch 'main' into upstream/terrain-partial-heights 2026-11-02 06:50:47 -08:00
German Khokhlov cfc83a6078 Release the tuner noise graph after a silent dial release
A release that only selects a station now stops the looping noise source and suspends its AudioContext. The qa-radio dial scenarios start from an active stream, or expect a silent release from a stopped Radio.
2026-11-02 17:19:53 +04:00
German Khokhlov 645e13aeaa fix(hud): catch the UNAVAILABLE word flush against Han and keep multi-word names whole
Han and kana have no word edges, so a CJK forbidden word now counts anywhere
in the reply. The proxy no longer counts words: the model reads Rue de Rivoli
as one, so only the 80-character word-boundary cap remains.

Also restore src/voice/gevActions.test.mjs, reformatted by mistake in 818d2c82.
2026-11-02 17:13:58 +04:00
German Khokhlov 818d2c8295 fix(hud): keep localized summary words whole and drop padding
- The proxy keeps combining marks, in-word apostrophes and name dots
  (ar vowel signs, fr d'Orléans, ja ・), normalizes to NFC, caps every
  summary in characters and cuts at a word boundary.
- feedState is nullable instead of a `none` token, and the summary has at
  most five words: "exactly five" made the model pad short views with
  "no data" or "unavailable".
- The HUD caches only the model's words and adds the current warning on
  every paint; an RTL locale gets a leading right-to-left mark.
- The echo check also rejects an all-Latin reply in a non-Latin language,
  translated layer ids and, with every feed healthy, the UNAVAILABLE word;
  places are matched by word and one-word layer names are skipped in
  Latin-script locales.
- The warning tag names the worst layers first; a partial analyst card
  shows one partial chip.
2026-11-02 13:44:53 +04:00
German Khokhlov 857b007fb1 Keep the Radio tuning dial silent unless a stream is playing
Releasing the tuning dial always started the chosen station, plus tuning
static, even when nothing was playing. It now follows the Previous/Next
rule: the release plays only if a stream was loading, buffering or playing
when the drag began; otherwise it just selects the station.

The privacy note no longer lists finishing tuning as a way to connect and
now names voice instead, in all locales. Docs, changelog and comments are
tightened to match.
2026-11-02 13:27:55 +04:00
German Khokhlov 0ec6735440 Merge remote-tracking branch 'origin/fix/hal-722-radio-next-no-autoplay' into fix/hal-722-radio-next-no-autoplay 2026-11-02 12:36:51 +04:00
German Khokhlov a2be947aba Merge origin/main into fix/hal-722-radio-next-no-autoplay
main moved the Radio markup into Vue views and rebuilt the UI tests on
stores. Resolved:
- src/ui/templates/context.html: deleted in main; the new privacy note now
  lives in src/ui/views/panels/RadioPanel.vue.
- src/ui/radioControls.test.mjs: main's store fixture; the Next/Previous
  autoplay test drives store.act('cycleRadio').
- src/voice/gevRealtime.test.mjs: main's createTestVoiceUi; the stop and
  select handoff cases stay one table-driven test.
- src/data/radioNextState.test.mjs reads the published gev-radio view.
- src/i18n/locales/ar.json: the new note translated for the Arabic locale.
2026-11-02 12:36:31 +04:00
German Khokhlov 0d3c1ef1d9 fix(hud): ask again in the new language after an in-place switch
- _relocalize() now drops the received reply and aborts one in flight, so
  the next tick asks in the new language instead of keeping old-language
  text under the new-language state tag.
- The English-echo check also allows state words inside a place label
  (Estación Loading Dock).
- Reword the non-English instruction so it reads for every language name.
- Add the two new test files to the format scope.
2026-11-02 12:00:44 +04:00
German Khokhlov 1a6687632d Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts:
#	src/hud.js
#	src/hudAltitudeDatum.test.mjs
2026-11-02 11:59:08 +04:00
German Khokhlov dbfbfcceb9 fix(hud): ask the model only with a place label, catch flush English echoes
- Skip the model when the view has layers but no place, street or nearby
  label: with layers alone it invented a place.
- The English-echo check treats only Latin letters as part of a word, so
  ids flush against kana or Han and hyphenated names are caught; it skips
  nominal, state words a locale keeps in English and names inside place
  labels.
- Outside English, per-layer state ids are left out of the request.
- The ja/zh cap steps back to the start of a Latin word, not the last space.
- Fold the CJK cap cases into hudSummaryResponse.test.mjs.
2026-10-30 20:53:31 +04:00
German Khokhlov ebdd4628db Merge branch 'main' into fix/hal-722-radio-next-no-autoplay 2026-10-30 20:37:12 +04:00
German Khokhlov 5ed86200e5 Bring back the tuner-release and connecting-label Radio fixes
Restore the two fixes the previous commit moved out:

- A tuner release onto a station the directory dropped mid-drag stops the
  stream the drag paused, publishing one state with the gesture's origin,
  so the button reads PLAY and a voice handoff is cancelled.
- The transport buttons offer Pause while a stream connects, matching
  what a click does.

Their tests now use the shared Radio layer harness.
2026-10-30 16:44:15 +04:00
German Khokhlov a55450e2e7 Trim the Radio Next/Previous change to the HAL-722 scope
- Selections take the playback fallback as a `playFallback` option and
  set it after releasing a stale stream, instead of callers arming state
  that the release then had to save and restore.
- Move the tuner `station-unavailable` release and the Pause label while
  a stream connects out of this branch: both were wrong on main already
  and go in a separate change.
- Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs
  and fold overlapping Next/Previous tests (13 to 7).
- One-line comments and a two-line changelog entry.
2026-10-30 16:37:36 +04:00
German Khokhlov 7713fb8ae8 fix(hud): keep English out of localized summaries, cap ja/zh by characters only
Second follow-up review fixes:

- Japanese and Chinese summaries are capped at 20 characters only, without
  the five-token cut first, and the cap steps back to a space instead of
  splitting a Latin word.
- The HUD request sends localized layer names in feedProvenance too and
  leaves the English narration note out outside English. A non-English reply
  that still echoes a feed-state id or a translated English layer name falls
  back to the deterministic line.
- A camera move during typing shows the deterministic line, not a fragment.
- A view with no place labels and no enabled layer does not ask the model.
- Behaviour tests on IntelHUD replace the regex checks of hud.js; docs and
  the instruction wording updated.
2026-10-30 16:28:56 +04:00
German Khokhlov 7b223d7d7b fix(hud): state word first, layer names last; CJK cap by locale
Follow-up review fixes:

- The HUD line is STATE | text | LAYERS for the AI summary and the
  fallback line, so the narrow minimal and cyber layouts show both the
  warning and the summary. get_hud_caption builds its caption the same
  way. The unused "{state} {layers}" key is pruned from the catalogs.
- The 20-character cap applies only when the request locale is Japanese
  or Chinese; other languages keep five words around a Han place name.
- enabledLayers names are localized too, and the instructions name layers
  only from enabledLayerLabels, never from feedProvenance or its note.
- The keyless test restores GEV_RATELIMIT_OPENAI_PER_MIN again; comments
  and docs updated.
2026-10-30 14:27:59 +04:00
German Khokhlov fa840ae3c3 Translate the Radio privacy note into the new locales
main added de, fr, it, ja and pt with the old "after you press play"
note; give them the updated wording. main also removed the compact
Context Radio player, so the Next/Previous tests stop checking its
button.
2026-10-30 14:26:58 +04:00
German Khokhlov a7c16a701c Merge remote-tracking branch 'origin/main' into fix/hal-722-radio-next-no-autoplay 2026-10-30 14:23:09 +04:00
German Khokhlov 4dd5a313b3 Keep Radio playback state consistent after a silent Next
Review follow-ups for HAL-722. A Next or Previous that only selects a
station now leaves nothing of the previous stream behind:

- Selecting another station without playing stops a paused or failed
  stream, so the panel, Stop, voice summaries and the audio element agree
  on "stopped". A fallback the caller armed for the new selection is kept.
- A silent UI step arms no broadcaster fallback, and a user Play of the
  selected station drops one left by a cancelled voice step. Voice steps
  pass `fallback: true` because voice starts the prepared playback later.
- A user step to another station emits a `select` playback control, and
  the realtime voice handoff treats it like Pause and Stop.
- A tuner release onto a station the directory no longer has stops the
  stream the drag paused, publishing one state with the gesture's origin,
  so the button reads PLAY and a voice handoff is cancelled.
- The transport buttons offer Pause while a stream connects, matching
  what a click does.
- The privacy note names every action that connects to a broadcaster
  (play, finishing tuning, choosing a station on the globe) in all
  locales.

The Next/Previous layer tests live in src/data/radioNextState.test.mjs.
2026-10-30 13:44:48 +04:00
German Khokhlov f967218a04 fix(hud): lead with the state tag, keep feedState in get_hud_caption
Review fixes for the HUD summary language change:

- The translated feed-state tag now leads the one-line summary and the
  fallback line, so the readout's ellipsis cannot hide it.
- get_hud_caption checks the reply's feedState, returns it and appends the
  state tag; a reply naming another state keeps only the place.
- The model gets the layer names the user sees (localizeLayerName).
- Japanese and Chinese summaries are capped at 20 characters; pt, zh-CN and
  zh-Hant get explicit language names.
- A partial view is narrated as incomplete coverage, not as not live.
- provenanceTag and hudSummaryMatchesProvenance move to layerSnapshot.js so
  the tools package can use them without browser globals.
- docs/CURRENT-STATE.md, changelog and comments updated; redundant tests
  trimmed.
2026-10-30 13:06:53 +04:00
German Khokhlov 54750a01e5 changelog: HUD summary language (#1488) 2026-10-30 12:38:18 +04:00
German Khokhlov 563df3bad9 fix(hud): AI summary in the UI language, canonical feed state, partial aggregation
The HUD sends its locale with the summary context; the proxy names that
language in the instructions and the input, English when missing or
unsupported. The model returns {summary, feedState}; the HUD shows the
summary only when feedState matches its own state for a view that is not
nominal, and appends its own translated state tag.

partial joins the shared feed-state table between fallback and nominal,
so one partial layer no longer reads off and partial plus nominal no
longer reads nominal.
2026-10-30 12:37:43 +04:00
German KhokhlovandClaude Opus 5.5 b775bfed70 Add the changelog entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:36:28 +04:00
German KhokhlovandClaude Opus 5.5 1ef9be2248 Keep Radio Next and Previous from starting playback before Play
The Next and Previous buttons cycled stations with the layer's default
autoplay, so a first press started buffering a broadcaster stream even
though the panel says audio connects only after Play. Stepping now only
selects until a stream is loading, buffering or playing; once one is,
playback follows the new station so the old broadcaster does not keep
playing under the new label.

The compact Next fallback scenario in qa-radio.mjs started from a stopped
stream and relied on the old behaviour, so it now starts from a playing
station.

Fixes HAL-722

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:36:02 +04:00
quanticsoul4772andClaude Opus 5.5 692e99729c Merge branch 'main' into upstream/terrain-partial-heights
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:58:22 -07:00
quanticsoul4772 9b918d475a Merge branch 'upstream/terrain-partial-heights' of https://github.com/quanticsoul4772/gods-eye-view into upstream/terrain-partial-heights 2026-10-28 11:24:36 -07:00
quanticsoul4772andClaude Opus 5.5 d0f6f5c0d8 docs(changelog): add the terrain partial-heights entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:24:23 -07:00
quanticsoul4772 49b65ddc55 Merge branch 'main' into upstream/terrain-partial-heights 2026-10-28 11:24:19 -07:00
quanticsoul4772andClaude Opus 5.5 19b4a7a7e0 fix(terrain): keep known heights when a terrain request fails
A request with any point the proxy cannot answer gets 502, and the page
used to put every point of that chunk on its geoid, including the ones the
proxy had real (cached or stale) heights for. During an upstream 429 pause
that drops cached ground under every chunk that also holds a new point.

The 502 body now carries `partial`: the request's real heights in order,
null for points without one, left out when there are none. The terrain
request service attaches it to the error it throws, and the page caches
those heights and uses its geoid only for the rest. `get_terrain_height`
still reports the failure as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:10:50 -07:00
Aditya dc56ada3a5 docs(changelog): add #1435 fragment 2026-10-27 21:57:34 +05:30
Aditya 60dad0c35e fix(military): don't treat adsb.lol's position-less rows as a partial snapshot
readsb omits lat/lon for a contact heard without a current position; about a
third of every /v2/mil response is such rows. They made every military
snapshot partial, so a contact that left the feed was retained at full
opacity for five minutes instead of fading and leaving on the missed-poll
limits. A row with a hex and no lat/lon now leaves the snapshot complete and
is listed in retainIds, as OpenSky's null-position rows already are.
2026-10-27 15:44:36 +05:30
Said Turksever 0ac32f82ba Merge remote-tracking branch 'origin/main' into codex/overpass-admission-review-fixes
# Conflicts:
#	README.md
#	SECURITY.md
#	scripts/format-scope.json
#	server/providers/overpass.js
2026-10-26 14:21:39 +00:00
Said Turksever d026952397 fix(track): name a measured angle violation in the harness log; docs: nginx Host $http_host keeps the port the Origin check compares
- fmtArr prints reveal-err and flags an angle violation on a timeout result.
- Regression: a late hidden reveal frame records no angle and stays retryable.
- README/SECURITY: $host drops a non-default port, so recommend $http_host.
- requestAdmission tests: say why a new admission test must use freshClock.
2026-10-26 14:19:21 +00:00
Said Turksever 01c764d4fd fix(track): read a late arrival frame before timing it out, so a measured wrong angle is never retried away
- observeArrivalRotation reads the sample before the deadline check and keeps
  a late frame's angle on the timeout result; arrivalAngleViolated then makes
  it final. Regressions: wrong late reveal and wrong late settle, each followed
  by a clean second attempt, still fail; a clean late frame stays retryable.
- Overpass admission tests: a monotonic per-test clock removes order
  dependence on the module-level limiters; the proxy test covers ::1 and
  ::ffff:127.0.0.1 sockets; a new test pins the proxied: upstream bucket.
- Retry edges (wrong on-time reveal, maxAttempts, inclusive 10° limit) and a
  check that every name the track harness imports is exported.
- Docs: nginx needs Host, X-Forwarded-Proto and X-Forwarded-For; Docker bridge
  networking makes every host client one remote peer; list the exact
  forwarding headers; owner misses can also see 503.
2026-10-26 14:10:52 +00:00
Said Turksever 8f16d10f7a fix(overpass): keep proxied traffic out of the direct-loopback admission reserve; keep a measured arrival-angle failure through a reveal timeout
- /api/overpass: a loopback socket with forwarding headers (a local reverse
  proxy) is admitted from the remote pool under a socket-keyed proxied: bucket,
  never the owner's direct-loopback window; the upstream limiter uses the same
  key. Regression: owner cache hits survive an exhausted proxy bucket.
- Arrival check: a reveal timeout keeps the settle angle it measured; the new
  observeArrivalRotationWithRetry retries only inconclusive timeouts, so a
  real violation cannot be retried into a pass. Regression: wrong settle angle
  -> reveal timeout -> clean second attempt still fails.
- SECURITY.md, README, changelog and CURRENT-STATE: scope the guarantee to
  admission isolation, name the forwarding headers a local proxy must send,
  and state that the upstream budget stays shared.
2026-10-26 13:59:18 +00:00
Said TurkseverandClaude Fable 5.1 7a421c35fc chore: drop the duplicate format-scope entry from the main merge
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 17:34:58 +01:00
Said Turksever 09ea8f33b9 Merge remote-tracking branch 'upstream/main' into codex/overpass-admission-review-fixes
# Conflicts:
#	scripts/format-scope.json
2026-10-21 17:34:23 +01:00
Said TurkseverandClaude Fable 5.1 daedd76acc docs(changelog): fragment for #1308
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 17:33:13 +01:00
Said TurkseverandClaude Fable 5.1 bf2c96db0a chore: leave the #1264 changelog fragment as merged
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:33:10 +01:00
Said TurkseverandClaude Fable 5.1 e3c4e88d6f test(track): freshen rotation passes before the arrival probe and retry an inconclusive timeout
A headless SwiftShader run showed a 926 ms first frame on the military probe,
which no wall-clock budget can accept: the 1 s idle refresh may already have
run inside it. Raise moveEnd and wait one frame before each attempt so the
settle frame is measured against a pass that just ran, and treat a timeout
(never a wrong angle) as inconclusive with one more attempt. Document why the
per-phase budget stays at half the refresh interval.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
Said TurkseverandClaude Fable 5.1 9059cd942f style: format the Overpass admission tests
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
Said TurkseverandClaude Fable 5.1 55f979c128 fix(overpass): keyless gate, loopback admission headroom and per-phase arrival budgets
Address gekh's review of 69a87fe2:

- P1: /api/overpass now uses admitSameSiteKeyless, like /api/terrain/heights,
  so an instance behind the README's reverse proxy keeps its Overpass-backed
  layers; cross-site shapes are still refused and forwarding headers no
  longer launder them. SECURITY.md, the gate header and CURRENT-STATE say so.
- P2: admission keys IPv6 peers by /64 and folds IPv4-mapped peers
  (clientNetworkKey), and loopback has its own 300/min window outside the
  900/min global, so a few LAN peers cannot lock the owner's browser out.
- P2 (track): the settle and reveal frames each get their own 500 ms budget,
  measured from moveEnd and from the settle frame respectively, so a ~300 ms
  SwiftShader frame no longer trips the reveal deadline while both stay
  inside the 1 s idle refresh.
- Smaller: drop the dead settings-block check and stale probe comment in
  query.js, clamp whitespace-tolerant [ timeout : N ], and add tests for the
  global window, the /64 keying, loopback headroom, proxied admission and
  cache hits spending admission but not upstream budget.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
Tom-NeverwinterandClaude Sonnet 5 feba5af0b9 Add OpenSeaMap / OpenSnowMap overlay layers (#10)
Two independently toggleable open-data raster overlays — OpenSeaMap
sea marks (buoys, lighthouses, ports) and OpenSnowMap ski pistes —
drawn as Cesium.ImageryLayers directly on the Cesium globe surface.
Same "small array of descriptors" shape as MAP_STACKS in
mapStackController.js.

Visible on the osm/bing-aerial/bing-labels map stacks and invisible
(not broken — the layer's stats say so in plain text) on the default
photoreal stack, since Google Photorealistic 3D Tiles render with the
globe hidden.

OpenRailwayMap and OpenInfraMap, also requested in #10, are
deliberately NOT included in this pass: OpenRailwayMap's public tile
server enforces a Referer allowlist scoped to its own site (confirmed
by direct request — every other Referer, including none, 403s), and
OpenInfraMap serves MapLibre GL vector tiles, not raster XYZ — that
would need a genuine vector-tile rendering pipeline, not a
Cesium.ImageryLayer drop-in. Worth its own dedicated pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 17:07:33 -05:00
597 changed files with 45837 additions and 11055 deletions

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ coverage
pinokio/ENVIRONMENT
pinokio/.installed
src/data/local_data/
# Written by scripts/record-camera-goldens.mjs and compared byte for byte.
src/render/conformance/goldens/
*.geojson
*.geojsonl
+3
View File
@@ -98,6 +98,8 @@ How to read this:
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
### Local receiver input (not fetched from a service)
@@ -118,6 +120,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
<a id="gnss-integrity"></a>
+11 -8
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Start without an account or API keys.** Every path opens the same app with
**Local setup needs no account or API keys.** The app opens with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
</details>
@@ -236,7 +236,11 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
Settings is unavailable on the forwarded URL. Never commit your workspace
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
chip in the bottom-right corner: Provider Settings lists every supported key,
For a local setup, keys are upgrades, not prerequisites. When you want one,
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
+1 -1
View File
@@ -80,7 +80,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+3
View File
@@ -0,0 +1,3 @@
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
overlay layers under Infrastructure. They drape on the globe stacks and on
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
@@ -0,0 +1,13 @@
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
now uses the keyless same-site gate, admits forwarding headers and still
refuses cross-site requests. Direct loopback requests keep their own
admission window; proxied requests, including those a local reverse proxy
relays over loopback, are admitted as remote traffic keyed by their socket,
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
exhaust the owner's admission. The upstream budget stays shared, so an owner
cache miss can still be rate-limited (asturksever, #1308).
- Give the tracking arrival check a wall-clock budget per observed frame and
retry only an inconclusive timeout: a timeout keeps every angle it
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
frames no longer fail the check and a real violation is never retried into a
pass (asturksever, #1308).
@@ -0,0 +1,5 @@
- Military aircraft that leave the adsb.lol feed fade and are removed again.
The feed's routine position-less rows no longer mark every snapshot partial,
which kept vanished aircraft at full opacity for up to five minutes; those
rows keep only their own aircraft, and a likely-landed one still leaves on
the landed limit (Aditya, @Rohilalala, #1435).
@@ -0,0 +1,3 @@
- A terrain request the proxy can only partly answer (a 429 pause, or a point
the upstream returns without a height) now keeps the real heights it has
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
@@ -0,0 +1,5 @@
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
before Play is pressed; while a stream plays, it follows the new station.
Play reads Pause while a stream connects, and a tuner release onto a station
the directory removed or changed no longer leaves a Resume for another
station (gekh, #1474).
+3
View File
@@ -0,0 +1,3 @@
- Write the AI HUD summary in the interface language, led by the HUD's own
translated feed-state warning. A partial layer no longer reads as off or
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
-5
View File
@@ -1,5 +0,0 @@
- One world clock now drives everything with a time of its own: the sky,
the HUD clock, satellites, launches and weather. A timeline along the
bottom of the screen, shown while one of those layers is on, plays, pauses
and scrubs it, with a tick for each launch or frame and a way back to
live. Share links made away from live carry the time (samehkhamis, #1512).
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Renderer conformance</title>
<style>
:root {
color-scheme: dark;
font:
13px/1.4 system-ui,
sans-serif;
}
body {
margin: 0;
background: #0d1117;
color: #e6edf3;
}
header {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid #30363d;
flex-wrap: wrap;
}
h1 {
font-size: 15px;
margin: 0 12px 0 0;
}
button,
select {
font: inherit;
padding: 4px 10px;
}
#stage {
width: 640px;
height: 360px;
position: fixed;
left: -10000px;
top: 0;
}
main {
padding: 12px 16px;
}
table {
border-collapse: collapse;
width: 100%;
}
td,
th {
border-bottom: 1px solid #21262d;
padding: 4px 8px;
text-align: left;
vertical-align: top;
}
.pass {
color: #3fb950;
}
.fail {
color: #f85149;
}
.skip {
color: #8b949e;
}
.views {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 12px;
margin-top: 12px;
}
figure {
margin: 0;
background: #161b22;
border: 1px solid #30363d;
padding: 6px;
}
figure img {
width: 100%;
display: block;
}
figure .row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px;
}
figcaption {
margin-top: 4px;
}
</style>
</head>
<body>
<header>
<h1>Renderer conformance</h1>
<label
>Renderer
<select id="renderer">
<option value="cesium">cesium (scene renderer)</option>
</select></label
>
<button id="logic">Run logic suite (stub)</button>
<button id="logicCesium">Run logic suite (cesium)</button>
<button id="check">Check scenes</button>
<button id="record">Record references</button>
<span id="status"></span>
</header>
<div id="stage"></div>
<main>
<section id="logicResults"></section>
<section class="views" id="views"></section>
</main>
<script type="module" src="./main.js"></script>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
/**
* The conformance page: runs the logic suite in the browser, and draws the
* reference scenes on a renderer to check them against the stored
* references (or record new ones). Driven by hand or through the
* chrome-devtools MCP; `window.conformance` exposes the same actions.
*/
import {
DEFAULT_TOLERANCE,
runConformance,
} from '../../src/render/conformance/index.js';
import {
REFERENCE_SCENES,
sceneSupport,
} from '../../src/render/conformance/scenes.js';
import {
compareImages,
drawScene,
referenceName,
} from '../../src/render/conformance/visual.js';
import { createStubHarness } from '../../src/render/stub/harness.js';
import { toEcef } from '../../src/render/stub/math.js';
import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js';
import {
CESIUM_CHECKS,
createCesiumHarness,
} from '../../src/render/cesium/harness.js';
const REFERENCES = '/src/render/conformance/goldens/visual/';
const SAVE = '/__conformance/references/';
const ctx = { toEcef, assets: { model: '/models/c172.glb' } };
const $ = (id) => document.getElementById(id);
const status = (text) => ($('status').textContent = text);
const RENDERERS = {
cesium: () => createCesiumSceneRenderer({ container: $('stage') }),
};
const HARNESSES = {
stub: () => [createStubHarness('stub (browser)'), {}],
cesium: () => [
createCesiumHarness({ model: ctx.assets.model }),
{ only: (id) => CESIUM_CHECKS.has(id) },
],
};
/** Runs the logic suite on `which`; `all` runs every check, listed or not. */
async function runLogic(which = 'stub', { all = false } = {}) {
status(`running the logic suite on ${which}…`);
const [harness, options] = HARNESSES[which]();
const report = await runConformance(harness, all ? {} : options);
$('logicResults').innerHTML =
`<p>${report.harness}: <b class="pass">${report.passed} passed</b>, <b class="fail">${report.failed} failed</b>, ${report.skipped} skipped</p>
<table>${report.results
.filter((r) => r.status !== 'pass')
.map(
(r) =>
`<tr><td class="${r.status}">${r.status}</td><td>${r.id}</td><td>${escape(r.detail ?? '')}</td></tr>`,
)
.join('')}</table>`;
status('');
return {
passed: report.passed,
failed: report.failed,
skipped: report.skipped,
failures: report.results.filter((r) => r.status === 'fail'),
};
}
const escape = (s) =>
s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
async function pixels(blob) {
const bitmap = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const g = canvas.getContext('2d');
g.drawImage(bitmap, 0, 0);
return g.getImageData(0, 0, bitmap.width, bitmap.height);
}
// Object URLs shown for the current run, revoked when the next one starts.
let objectUrls = [];
function objectUrl(blob) {
const url = URL.createObjectURL(blob);
objectUrls.push(url);
return url;
}
async function toUrl(imageData) {
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
canvas.getContext('2d').putImageData(imageData, 0, 0);
return objectUrl(await canvas.convertToBlob());
}
/** The views the stored manifest lists, or none. */
async function storedViews() {
try {
const res = await fetch(REFERENCES + 'manifest.json', {
cache: 'no-store',
});
return res.ok ? ((await res.json()).views ?? []) : [];
} catch {
return [];
}
}
async function reference(name) {
const res = await fetch(REFERENCES + name, { cache: 'no-store' });
return res.ok && res.headers.get('content-type')?.startsWith('image/')
? res.blob()
: null;
}
/** Draws every scene; records references or compares against them. */
async function runScenes({ record = false, only } = {}) {
const which = $('renderer').value;
const renderer = await RENDERERS[which]();
const results = [];
$('views').innerHTML = '';
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls = [];
try {
for (const scene of REFERENCE_SCENES) {
if (only && !only.includes(scene.id)) continue;
const unsupported = sceneSupport(scene, renderer, ctx);
if (unsupported) {
results.push({ scene: scene.id, status: 'skip', detail: unsupported });
continue;
}
status(`${scene.id}…`);
await drawScene(renderer, scene, ctx, async (view, capture, settle) => {
const name = referenceName(scene.id, view.name);
const figure = document.createElement('figure');
$('views').append(figure);
if (settle.state !== 'settled') {
const detail = `${settle.state}: ${settle.blockers.join(', ')}`;
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail,
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: ${escape(detail)}</figcaption>`;
return;
}
if (record) {
const res = await fetch(SAVE + name, {
method: 'PUT',
body: capture,
});
results.push({
scene: scene.id,
view: view.name,
status: res.ok ? 'recorded' : 'fail',
detail: res.ok ? undefined : `save ${res.status}`,
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption>${name}: recorded</figcaption>`;
return;
}
const refBlob = await reference(name);
if (!refBlob) {
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail: 'no reference',
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: no reference</figcaption>`;
return;
}
const [actual, expected] = await Promise.all([
pixels(capture),
pixels(refBlob),
]);
const diff = new Uint8ClampedArray(actual.data.length);
const outcome = compareImages(actual, expected, { diff });
results.push({
scene: scene.id,
view: view.name,
status: outcome.match ? 'pass' : 'fail',
diffFraction: outcome.diffFraction,
solidPixels: outcome.solidPixels,
mismatchedPixels: outcome.mismatchedPixels,
...(outcome.reason ? { detail: outcome.reason } : {}),
});
const diffUrl = outcome.reason
? ''
: await toUrl(new ImageData(diff, actual.width, actual.height));
figure.innerHTML = `<div class="row"><img src="${objectUrl(capture)}" title="${which}"><img src="${objectUrl(refBlob)}" title="reference"><img src="${diffUrl}" title="difference"></div>
<figcaption class="${outcome.match ? 'pass' : 'fail'}">${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}</figcaption>`;
});
}
if (record) {
// A partial recording keeps the other scenes' entries.
const kept = only
? (await storedViews()).filter(
(view) => !only.some((id) => view.startsWith(`${id}-`)),
)
: [];
const recorded = results
.filter((r) => r.status === 'recorded')
.map((r) => referenceName(r.scene, r.view));
await fetch(SAVE + 'manifest.json', {
method: 'PUT',
body:
JSON.stringify(
{
renderer: which,
size: {
width: renderer.canvas.width,
height: renderer.canvas.height,
},
views: [...kept, ...recorded],
},
null,
2,
) + '\n',
});
}
} finally {
renderer.destroy();
status('');
}
return results;
}
$('logic').onclick = () => runLogic('stub');
$('logicCesium').onclick = () => runLogic('cesium');
$('check').onclick = () => runScenes();
$('record').onclick = () => runScenes({ record: true });
window.conformance = { runLogic, runScenes };
+94
View File
@@ -81,6 +81,100 @@ constructed component objects; the component instances themselves remain mutable
`ready` means all constructors have returned. Background feed activity and the
standalone share-restoration result keep their existing separate contracts.
## Pointer input
`gods-eye-view/input` exports `inputFor(viewer)`, the scene's one input
dispatcher. Every layer and tool takes its pointer input from it, and so can
the caller: one `ScreenSpaceEventHandler` per scene, and a click picked once
and handed to every subscriber.
```js
import { inputFor } from 'gods-eye-view/input';
const input = inputFor(viewer);
const off = input.onClick((event) => {
if (event.ownerId === 'flights') showFlight(event.id);
});
// Later:
off();
```
`onClick` runs while no tool holds the pointer (the draw tool, the imagery
box), so a vertex placed on an aircraft is not also a click on it. Its event
reads lazily; a subscriber that reads nothing costs no pick:
| Member | What it is |
| ------------------ | -------------------------------------------------------------------- |
| `screenPosition` | Where the user clicked, in CSS pixels; for DOM and overlay hit-tests |
| `position` | Where that lands in the pick buffer (corrected under sensor styles) |
| `gesture` | `{ travelPx, durationMs }` of the press, to tell a click from a drag |
| `pick` | `scene.pick` at `position` |
| `hit` | `pick`, or on a coarse pointer the contact under a surface pick |
| `id`, `ownerId` | The hit's pick id and the layer that registered it, or `null` |
| `meta` | The hit's own metadata, when its pick carries any |
| `surface` | Whether the hit is the map itself |
| `drill(limit)` | `scene.drillPick`, shared by every subscriber of the click |
| `pickWindow(w, h)` | `scene.pick` over a `w`×`h` window around `position` |
| `ground()` | The world position under the click, or `null` over the sky |
`onPointer(type, fn, { lease })` subscribes to `down`, `move`, `up`, `click`
or `dblclick`; with a lease it runs only while that lease is current. A tool
takes its lease with `claimPointer(ownerId)` from the same export (and gives
it back with `releasePointer(lease)`), which also keeps every ambient
subscriber off its clicks; `isPointerFree()` and `pointerOwner()` report who
holds it. `suspendCamera(reason)` and `suppressStockClicks()` return their
own release: camera input and the viewer's stock select and double-click-track
come back when the last hold is released, so two holders cannot undo each
other. The dispatcher owns the camera controller's `enableInputs`: a direct
write (Cesium's camera flights make one when they start and end) is kept, but
reads true only while no hold is out. A caller-supplied `createScene` that creates its own viewer registers
`defer(() => inputFor(viewer).destroy())` right after it; the standalone scene
does this and returns the dispatcher as `scene.input`.
## Height queries
`gods-eye-view/queries/heights` exports `heightsFor(scene)`, the one place
height queries go. Layers, tools and the camera ask it instead of calling
`scene.sampleHeight`, `globe.getHeight`, `scene.clampToHeight` or the
`*MostDetailed` variants themselves, so a renderer that answers heights another
way plugs in without touching them.
```js
import { heightsFor } from 'gods-eye-view/queries/heights';
const heights = heightsFor(viewer.scene);
const ground = heights.now({ lon, lat }, { exclude: [ownMarkers] });
if (ground !== null) place(ground);
```
| Member | What it answers |
| ----------------------------- | ------------------------------------------------------------------------- |
| `now(point, opts)` | Height in metres from what is loaded, or `null` |
| `clampNow(position, opts)` | The `Cartesian3` moved onto the drawn surface, or `null` |
| `batch(points, opts)` | A promise of heights once the most detailed tiles stream; `null` per miss |
| `clampBatch(positions, opts)` | A promise of clamped copies of the positions; `null` per miss |
| `supported()` | `{ surface, clamp }`: whether the drawn surface can be queried at all |
A point is `{ lon, lat }` in degrees or a `Cesium.Cartographic` in radians.
`against: 'surface'` (the default) reads what is drawn, 3D tiles and globe;
`against: 'terrain'` reads the loaded terrain tiles alone. `exclude` lists the
primitives and entities a query sees through. Every miss is `null`, including
a Cesium throw for missing depth-texture support, and every caller treats it
as one. Cesium answers `now()` synchronously from the scene, so it costs what
the direct call did.
## Cesium internals
Core reaches private Cesium fields only through
`src/render/cesium/internals.js`, one named helper per field (the main and
offscreen pick views, a billboard's collection, a cluster's point collection,
the widget's render-loop latch, the WebGL context and whether a camera flight
is under way). `internals.test.mjs` fails when the installed Cesium stops
declaring one of them, and when a private field is reached anywhere else.
Imagery that rations its tile requests states the policy as a hook with
`withTileRequestHook(provider, { requestImage, credit })` from
`src/render/cesium/tileRequests.js`, which leaves the provider untouched.
## Standalone wiring
`src/main.js` reads the existing browser configuration and starts
+2 -1
View File
@@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces |
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
| `server/standalone/` | Environment, local settings writes and server composition |
See [application construction](APPLICATION.md) and the
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces.
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides.
## Import direction gates
+119 -68
View File
@@ -1197,12 +1197,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
longitude and latitude is a contact without a position, not a malformed row: it
leaves the snapshot complete and is listed in `retainIds`, so civil flights
retain that aircraft under the same five-minute bound, unless it is likely
landed, while other absent aircraft fade and leave on the missed-poll limits. A
nonempty snapshot with no positioned rows is still malformed. Military readsb
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
contact without a position, not a malformed row: it leaves the snapshot complete
and is listed in `retainIds`, so civil and military flights retain that aircraft
under the same five-minute bound, unless it is likely landed, while other absent
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -1414,7 +1414,7 @@ and a Denver coordinate underground. Search now resolves the application's
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
surface under the target and the camera once tiles stream in and lifts the eye
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls), any other caller moving the camera or starting a flight before a retry, layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners; the guard compares the pose it started on rather than wrapping camera methods. `scripts/qa-context-arrivals.mjs` types Camp Mabry
and a Denver coordinate and requires at least 60 m clearance.
## Layer panel ownership
@@ -1473,11 +1473,14 @@ remain unchanged.
## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration.
VisualEffects keeps the records of the styles, their crossfades, bloom and
sharpen, and drives them through the renderer's effects port: it defines each
preset there as a style, and the renderer runs the passes and animates the
animated ones. Display supplies actions and renders settings; effect execution
has no DOM dependency. Existing presets and the 500 ms transition remain
unchanged. Stopping the controller revokes animation before asynchronous UI
teardown; final destruction removes its styles and turns bloom and sharpen off,
and the renderer restores the scene's bloom configuration when it goes.
Snow is no longer a visual preset. A share link that names it, in either the
long or compact form, opens on Normal and drops the Snow shader parameters. A
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
@@ -2396,9 +2399,9 @@ which embeds the MAP_KEY.
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
> requests one frame through `governorRequestRender` after every layer
> disable and then trims those arrays. It reads private Cesium fields;
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames
> them or changes which passes it truncates.
> disable and then trims those arrays. It reads private Cesium fields through
> `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins
> them and fails if Cesium renames them or changes which passes it truncates.
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
> governor (`src/renderGovernor.js` — hold/release from every per-frame
@@ -3092,9 +3095,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
> Its Radio popover exposes compact power,
> transport, station, and volume controls. Cockpit Previous/Next preserves
> selection, autoplay, and broadcaster fallback audio without starting the
> map-view station flights that compete with the first-person camera. These
> transport, station, and volume controls. Cockpit Previous/Next changes the
> selection without starting the map-view station flights that compete with
> the first-person camera; like the Context Previous/Next buttons, it plays
> (with broadcaster fallback) only while a stream is already active. These
> are a Cockpit-only accordion:
> each static header uses the left accordion's label and divider with a dedicated
> directional-chevron disclosure button and no full-row hover treatment. Only one
@@ -3257,16 +3261,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> gets 502, whose `partial` lists the real heights it has (null for the rest)
> when it has any, and the page keeps those and uses its geoid fallback only
> for the points without one. The same holds when the upstream answers a
> point with a null height. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
> POSTs through the shared local-request gate, then limits requests to 300/min
> per socket peer and 900/min globally before reading or validating bodies.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
> through the keyless same-site gate (reverse-proxy forwarding headers are
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
> before reading or validating bodies. Direct loopback (no forwarding
> headers) has its own 300/min window outside that global; a local reverse
> proxy's requests carry forwarding headers and are counted as remote, keyed
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
> quota stays shared, so owner cache misses can still hit 429.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and
@@ -3453,8 +3466,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> celestial ring, annotations SVG, missions frame tick, and traffic).
> - **World-label accounting:** All intended label/card migrations use the
> shared host. The former native `LabelGraphics` exception for cable reference
> labels was retired on 2026-08-18; cable text may render over tile geometry,
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness
> labels was retired on 2026-08-18; cable text may render over tile geometry.
> The Directions A/B letters (`directions-markers` source) and data-pack media
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
> the pack handle's `label`) moved to the host on 2026-11-04, and
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
> native world-label creation sites remain**. A media pack shown without an
> `overlayHost` fails to load rather than drawing a native label. The mission
> replay DOM vehicle, awareness
> compass, annotation SVG callouts, and host-owned detection isolation target
> are explicit exceptions; the awareness ring and cockpit contact pips are
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
@@ -3503,7 +3522,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> ambient-label host source. Its getter reads the already-propagated point
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
> propagation. Satellite tracking continues to publish exactly one protected
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
> tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text
> preference changes, catalog rebuild, and destroy clear/hide the source.
@@ -3518,7 +3537,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity
> is a billboard-only camera target with no native label. The flight source
> retains callsign/registration fallback, flight-level/altitude, speed, stale
> state, airline/type, and plausible-route formatting in `gevLabelModel`; the
> state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the
> one protected tracked-lane host card renders that complete model. Its
> position getter reads only `_trackedDisplayCached`, the same authoritative
> Cartesian already consumed by the tracked visual and camera, and never
@@ -3625,8 +3644,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
> bridge only; it owns no canvas, projection, post-render listener, layout,
> keyhole fade, or paint path. Civilian flights, military flights, satellites,
> and mapped installations write explicit `gevLabelModel` objects and expose
> `gevDisplayPosition` getters backed by their layer-owned frame/display cache.
> and mapped installations write explicit `labelModel` objects and
> `displayPosition` getters, backed by their layer-owned frame/display cache,
> into Core's entity records (`src/data/entityRecords.js`), never onto the
> Cesium entity. A layer's private per-entity state goes in its own store from
> `createEntityStore()` (ALPR markers: `src/layers/alpr/markerState.js`).
> Selected AIS vessels continue using the vessel source's protected selected
> card, so they do not create a duplicate tracked readout. The host registers
> the active readout in the protected tracked lane with a zero ambient quota;
@@ -3990,31 +4012,33 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4382,11 +4406,11 @@ the same navigation authority as every other camera destination.
given; re-arming for the other endpoint reuses that lease rather than asking
for a second claim, and every release names the lease, so a superseded instance
can never free the claim its replacement holds. The claim is held until
the click that uses it has finished being dispatched — every layer binds its
own handler to the same canvas and they all run inside one browser event, so a
claim dropped the instant the endpoint is placed would hand the rest of that
same click to the ambient handlers bound after this one. It is returned on the
next tick after placement, and immediately on cancel, Escape, CLEAR, disable
the click that uses it has finished being dispatched: the input dispatcher
checks the claim once per click, before any subscriber runs, but DOM canvas
listeners (the evidence cards) still see the same browser event afterwards, so
a claim dropped the instant the endpoint is placed would hand them that click.
It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable
and destroy; if another tool already holds it nothing is armed and the row says
so. Maneuver-dot selection is an ordinary ambient handler and yields while any
tool holds the pointer. FLY runs through the UI shell's immediate-navigation
@@ -4736,10 +4760,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
ownership tokens plus the session boundary keep late callbacks from a retired
session inert.
A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Release starts playback
only when the current same-ID record still exactly matches the frozen
presentation and stream metadata; removal or replacement reports the channel as
unavailable and never silently retargets the drag. A cold degraded fallback may
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
release starts playback only if a stream was active when the drag began;
otherwise it just selects the station. It commits only when the current same-ID
record still exactly matches the frozen presentation and stream metadata.
Removal or replacement reports the channel as unavailable and never silently
retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner.
@@ -4916,7 +4942,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
result reads enabled state from the manager and audio state from the Radio
module. Voice Pause is a playback-only no-op while the layer is disabled; it
never enables Radio and reports a fulfilled-false pause as failure. A
newer-response or user-origin Stop still cancels stale handoff work.
newer-response or user-origin Stop still cancels stale handoff work, as do a
user Pause, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory removed or changed mid-drag.
Only a newer user turn or session teardown aborts the complete active-tool set.
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
controls and generic layer-visibility commands both forward cancellation.
@@ -5405,8 +5433,11 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026)
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers
@@ -5609,9 +5640,9 @@ request viewbox and continues searching without that geographic preference.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`), and the scene's input dispatcher (`src/input/dispatcher.js`) hands its clicks, moves and double clicks to the draw tool's leased subscriptions only: **every** ambient subscriber — flights, military, CCTV and its calibration gizmo, vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers — is skipped for that event before anything is picked. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
- **Cesium's own click actions are suppressed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. The tool takes the dispatcher's `suppressStockClicks()` hold for the session; both actions come back, as the same function objects, when the last hold is released (the imagery box takes the same hold).
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
@@ -5691,7 +5722,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026)
@@ -5966,6 +5997,26 @@ easier to meet (detection is now on more often), but does not create it.
above and the DISPLAY-rail entry below (was `OFF`).
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
## Renderer contract
`src/render/contract/` types the renderer contract, version `0`: nine ports
(frame, globe, draw, camera, query, input, effects, credits and quality),
plus the renderer itself. `PORTS` lists every member at run time, and
`contract.test.mjs` keeps that list and the types in step. The application
does not yet draw through the contract: it still drives Cesium directly.
`src/render/stub/` is a renderer that draws nothing, with the default
camera's exact maths. `src/render/conformance/` is the suite: logic checks
in Node, plus camera traces recorded from Cesium's own `Camera` into
`goldens/camera.json`. It also holds the reference scenes, whose views are
checked against `goldens/visual/` on the `/dev/conformance/` page. See
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
`src/geo/` (export `./geo`) is the renderer-neutral maths: WGS84, ECEF and
geodetic conversions, surface normals and east-north-up frames, as plain
`{x, y, z}` objects. It follows Cesium's formulas step for step, and
`geo.test.mjs` checks it against Cesium. The stub renderer's maths is built
on it.
## Operational Notes
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
+5
View File
@@ -95,6 +95,11 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
signal, bound reads and return the real MIME type. They remain application code,
not scene document fields. Without registrations, referenced sources fail closed.
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
world-overlay host that draws pack world text such as a media pack's label (an
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
without it a media pack fails to load. `createApplicationTools` passes the
application's shared host.
The portable session receives format adapters separately. Each adapter receives
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
+171
View File
@@ -0,0 +1,171 @@
# Renderer contract
The renderer contract is what the application asks of a 3D renderer. A
renderer other than the default Cesium one can draw the same application if
it meets the contract. The contract is version `0`. Today the application
still drives Cesium directly; later work moves the layers onto the contract
one family at a time.
The package exports three modules:
| Export | What it is |
| ---------------------------------- | ------------------------------------------------------------------------------ |
| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check |
| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests |
| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison |
## Ports
A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It
exposes nine ports, each typed in its own file in `src/render/contract/`:
| Port | File | Covers |
| --------- | -------------- | ------------------------------------------------------------------------------ |
| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time |
| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky |
| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents |
| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices |
| `query` | `query.d.ts` | Picks, projection, rays, heights |
| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours |
| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation |
| `credits` | `credits.d.ts` | Data credits owed |
| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark |
The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling,
resize, context loss and `destroy`. `common.d.ts` holds the shared shapes
(ECEF positions, colours, listener semantics) and the capability list.
The rules that hold everywhere:
- **Core keeps its own records.** It never reads a renderer to learn its own
state, only what the renderer measures: picks, depth, heights and
readiness.
- **Capabilities.** An optional member (`?`) exists only with the capability
its documentation names first. A required member that names a capability
always exists, and without the capability it answers null or empty.
Quality support is listed by `quality.knobs()`, not by capabilities.
- **Heights are physical.** All heights are metres above the ellipsoid,
unexaggerated. Only drawing follows exaggerated terrain.
- **Listeners** run in subscription order. A listener removed during
delivery does not run; one added during delivery runs from the next event.
A listener that throws is reported to the renderer's `error` event, or to
the platform's `reportError` when nothing listens for errors. The other
listeners, and the frame, still run.
- **The frame order is fixed:** camera flight → input → camera `changed` →
follow → `preUpdate` → move detection → `preRender` → render →
`postRender`, the default renderer's own. A camera move made by follow or
in `preUpdate` is reported by `changed` on the next frame.
- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it
takes only the presses it grabs on `down`; the rest, and wheel and pinch,
stay with the camera and everyone else.
`src/render/contract/index.js` lists every member of every port in `PORTS`,
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
`checkRendererShape(renderer)` names each required member that is missing,
and each capability member present without its capability.
`contract.test.mjs` type-checks the contract under strict TypeScript, and
fails when `PORTS` and the types disagree on a single member.
## The stub renderer
`createStubRenderer({ capabilities, viewport, time })` implements every port
in memory and draws nothing. It has:
- the default camera's exact maths: views, `lookAt`, flights with their
height arc and easing, `changed` thresholds and follows;
- picks against what was drawn, or a scripted table;
- scripted heights;
- a virtual clock that nothing advances on its own.
`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`,
`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls.
A listener that throws with nothing subscribed to `error` goes to
`reportError`; Node has none, so the stub rethrows it on a microtask and
`node:test` fails the running test. A test that expects a throw subscribes
to `error`.
The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`).
## Conformance suite
`runConformance(harness)` runs every check against a renderer through the
contract alone and reports each one as passed, failed or skipped. It runs in
Node and in a browser page. A harness tells the suite how to drive the
renderer under test:
- `create({ viewport, capabilities })`;
- `frame(r)`: one frame, 1/60 s on a virtual clock;
- `now(r)` and `toEcef(p)`;
- `tolerance` and `flightPaths`.
Optional members widen what is checked: `pointer` for synthetic input,
`scriptHeights`, and `assets.model`.
There are two layers of checks.
**Logic checks** run in CI. They cover:
- the shape check;
- listener semantics and the frame loop;
- draw-set bookkeeping, picks and the `meta` round-trip;
- projection, rays and heights;
- leases and click picks;
- imagery order, sky, styles, credits and quality knobs;
- the camera traces.
**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's
terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own
`Camera`, headless, on a virtual clock, and writes
`src/render/conformance/goldens/camera.json`. A renderer under test must
match each recorded pose:
- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on
screen;
- with every flight ending within one frame of the recording.
`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium
no longer records the committed traces. After a Cesium upgrade or a script
change, run `node scripts/record-camera-goldens.mjs` and review the diff.
**Visual checks** run in the browser. The reference scenes
(`src/render/conformance/scenes.js`) are built from contract calls only:
- one scene per primitive family;
- a city scene;
- a calibration path from orbit down to the street.
The scenes load nothing from the network.
`npm run dev`, then open `/dev/conformance/`. The page can:
- **Run logic suite (stub)** runs the logic checks in the browser.
- **Record references** draws every scene the chosen renderer supports.
It saves each view to `src/render/conformance/goldens/visual/`, through a
dev-server-only endpoint.
- **Check scenes** draws the scenes again and compares each capture with its
reference. Pixels are compared as pixelmatch compares them: by YIQ
difference (threshold 0.1), with antialiasing in either image excused. A
capture matches when at most 250 pixels are unexcused and at most 2 are
solid (a differing pixel whose four neighbours differ too). Between them
the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift
of the view, and pass any MSAA setting. A change nowhere 3 px thick (a
thin line gone, an item moved a pixel or two) that covers at most 250 px
passes. `visual.js` has the measurements.
The page shows the capture, the reference and the difference side by side.
`window.conformance.runScenes()` and `runLogic()` do the same from a script.
Browser checks use the chrome-devtools MCP against a page in its own window.
The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the
icon, point, line, shape, model and city scenes, and the references were
recorded with it. It has only the members the scenes need. The quad, mesh,
field, agent and orbit scenes have no reference yet. Each family's reference
is recorded when the Cesium renderer gains that family.
Some behaviour is only drawn, so the logic checks cannot see it: course
easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
course. The stub keeps no drawn course. These wait for scenes that capture
an icon mid-turn. Height queries that count a set
(`includeInHeightQueries`) are not checked either: the stub's heights are
scripted, so it does not model them. Nor are video frames (a check needs a
video), `fetchTile` (the stub fetches nothing) or what clouds and water
draw.
-5
View File
@@ -119,7 +119,6 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -133,10 +132,6 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Field 28 is the world time the link was made at, written only when it was away
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
link's creation (a forecast moment), and it opens paused at that time.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+15
View File
@@ -32,6 +32,7 @@
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
"typescript": "5.9.3",
"vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
@@ -3545,6 +3546,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+9 -2
View File
@@ -79,6 +79,7 @@
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
"typescript": "5.9.3",
"vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
@@ -89,7 +90,6 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
@@ -228,6 +228,9 @@
"./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js",
"./application/operations": "./src/app/operations.js",
"./data/entity-records": "./src/data/entityRecords.js",
"./input": "./src/input/dispatcher.js",
"./queries/heights": "./src/queries/heights.js",
"./data/lifecycle": "./src/data/lifecycle.js",
"./ui/navigation": "./src/ui/navigationController.js",
"./ui/share-restoration": "./src/ui/shareRestoration.js",
@@ -291,6 +294,10 @@
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js"
"./agent/world-packet": "./src/agent/worldPacket.js",
"./geo": "./src/geo/index.js",
"./render/contract": "./src/render/contract/index.js",
"./render/stub": "./src/render/stub/index.js",
"./render/conformance": "./src/render/conformance/index.js"
}
}
+26 -38
View File
@@ -1,11 +1,8 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import {
advanceSpriteFocus,
} from '../src/data/focusDeemphasis.js';
import {
applyAircraftBillboardTreatment,
} from '../src/data/aircraftRecession.js';
import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js';
import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js';
import { createIconSet } from '../src/data/iconSet.js';
if (typeof global.gc !== 'function') {
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
@@ -66,17 +63,14 @@ const focusInput = {
target,
};
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 1, color: { alpha: 1 } };
const aircraftInput = {
billboard,
baseScale: 1,
baseAlpha: 1,
baseColor,
focusFactor: 1,
cameraDistanceM: 100,
cameraHeightM: 1000,
};
const icons = createIconSet({ capacity: 4 });
const row = icons.add('sprite', {});
const baseline = bytesPerCall(() => focusInput.nowMs);
const focus = netOfLoopBaseline(
@@ -84,30 +78,22 @@ const focus = netOfLoopBaseline(
baseline,
);
const aircraft = netOfLoopBaseline(
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)),
baseline,
);
const productionSpriteTick = netOfLoopBaseline(
bytesPerCall(() => {
// Match the hot civilian/military sprite path: the caller constructs fresh
// option literals, consumes the shared focus result immediately, and then
// constructs the composed aircraft-treatment options for the same sprite.
// Match the hot icon-set focus pass: the caller constructs a fresh option
// literal, consumes the shared focus result immediately and writes it to
// the row's focus column (the shader composes it with limb recession).
const focusResult = advanceSpriteFocus(sprite, {
screenPosition: focusInput.screenPosition,
cameraDistance: focusInput.cameraDistance,
nowMs: focusInput.nowMs,
target,
});
const treatment = applyAircraftBillboardTreatment({
billboard,
baseScale: aircraftInput.baseScale,
baseAlpha: aircraftInput.baseAlpha,
baseColor,
focusFactor: focusResult.factor,
cameraDistanceM: aircraftInput.cameraDistanceM,
cameraHeightM: aircraftInput.cameraHeightM,
});
return focusResult.factor + treatment.alpha;
icons.setFocus(row, focusResult.factor);
return focusResult.factor;
}),
baseline,
);
@@ -118,22 +104,24 @@ assert.ok(
);
assert.ok(
aircraft.roundedMedian <= PURE_CALL_BUDGET,
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
`aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
);
assert.ok(
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
);
console.log(JSON.stringify({
callsPerTrial: CALLS,
trials: TRIALS,
budgets: {
pureCall: PURE_CALL_BUDGET,
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
},
loopBaseline: baseline,
advanceSpriteFocus: focus,
applyAircraftBillboardTreatment: aircraft,
productionSpriteTick,
}));
console.log(
JSON.stringify({
callsPerTrial: CALLS,
trials: TRIALS,
budgets: {
pureCall: PURE_CALL_BUDGET,
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
},
loopBaseline: baseline,
advanceSpriteFocus: focus,
aircraftTreatmentAlpha: aircraft,
productionSpriteTick,
}),
);
+48 -94
View File
@@ -43,6 +43,25 @@
"scripts/build-admin-packs.mjs",
"scripts/qa-admin-outlines.mjs",
"src/data/inputOwnership.test.mjs",
"src/input/dispatcher.test.mjs",
"src/input/testDoubles.mjs",
"src/input/sensorPick.js",
"src/input/dispatcher.js",
"src/queries/heights.js",
"src/render/cesium/internals.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/cesium/input.js",
"src/render/cesium/input.test.mjs",
"src/render/cesium/cameraInput.test.mjs",
"src/render/cesium/cameraControls.js",
"src/render/cesium/cameraControls.test.mjs",
"src/render/input/router.js",
"src/render/input/router.test.mjs",
"src/render/cesium/internals.test.mjs",
"src/render/cesium/tileRequests.js",
"src/render/cesium/tileRequests.test.mjs",
"src/queries/heights.test.mjs",
"docs/CODE-BOUNDARIES.md",
"src/main.js",
"docs/APPLICATION.md",
@@ -661,6 +680,12 @@
"src/services/application.js",
"src/services/requests.js",
"src/app/scene.js",
"src/render/cesium/appRenderer.test.mjs",
"src/render/noCesiumOutsideRender.test.mjs",
"src/render/cesiumAllowList.json",
"src/geo/geo.test.mjs",
"src/geo/celestial.test.mjs",
"src/geo/quaternion.test.mjs",
"src/app/controls.js",
"src/app/data.js",
"src/app/tools.js",
@@ -1102,6 +1127,7 @@
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/cctvBadges.test.mjs",
@@ -1110,98 +1136,26 @@
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
"server/providers/common/rateLimitKeys.test.mjs",
"src/render/contract/contract.test.mjs",
"src/render/contract/common.d.ts",
"src/render/contract/renderer.d.ts",
"src/render/contract/frame.d.ts",
"src/render/contract/globe.d.ts",
"src/render/contract/draw.d.ts",
"src/render/contract/camera.d.ts",
"src/render/contract/query.d.ts",
"src/render/contract/input.d.ts",
"src/render/contract/effects.d.ts",
"src/render/contract/credits.d.ts",
"src/render/contract/quality.d.ts",
"src/render/contract/index.d.ts",
"src/render/stub/stub.test.mjs",
"src/render/conformance/scenes.test.mjs",
"src/render/cesium/cameraGoldens.test.mjs",
"scripts/record-camera-goldens.mjs",
"server/standalone/conformance-references.js",
"dev/conformance/index.html",
"dev/conformance/main.js",
"docs/RENDERER-CONTRACT.md"
]
+2 -1
View File
@@ -479,7 +479,8 @@ function readMarkup(file) {
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
// The conformance suite's check titles are developer output, not UI.
const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//];
function sourceFiles() {
return trackedFiles(
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -127,7 +127,7 @@ try {
let animeOn = false;
for (let i = 0; i < s.postProcessStages.length; i++) {
const stage = s.postProcessStages.get(i);
if (stage.name === 'godsEyeView_anime')
if (stage.name === 'effects_style_anime')
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
}
return {
+3 -3
View File
@@ -1669,7 +1669,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives;
let n = 0;
for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) n += 1;
}
return n;
});
@@ -1729,7 +1729,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives;
window.__qaViewshedRefs = [];
for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed)
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId)
window.__qaViewshedRefs.push(prims.get(i));
}
});
@@ -1738,7 +1738,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives;
const now = [];
for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) now.push(prims.get(i));
}
const before = window.__qaViewshedRefs || [];
const sameCount = now.length === before.length;
+2 -2
View File
@@ -763,7 +763,7 @@ try {
} : null,
navigation: snapshot?.navigation || null,
afterId: trackedInfo?.icao24 || null,
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null,
viewerTrackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
blockerStillRegistered: dataManager.layers.has(blockerId),
@@ -1362,7 +1362,7 @@ try {
contextMode: styleManager._contextControls._contextMode,
contextChanging: styleManager._contextControls._contextModeChanging,
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
tracked: tracker?.gevTrackedId || null,
tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null,
density: styleManager.getDetectionState().densityPct,
detectionMode: styleManager.getDetectionState().detectionMode,
installations: dataManager.getLayerLifecycleState('military-installations'),
+6 -3
View File
@@ -360,7 +360,9 @@ async function contacts() {
entities: ds?.entities.values.length ?? 0,
created,
tracked:
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
window.__godsEyeView.entityRecords.read(
window.__godsEyeView.viewer.trackedEntity,
)?.trackedId ?? null,
cockpit: document.body.classList.contains('cockpit-mode'),
});
if (samples.length > 5000) samples.splice(0, 1000);
@@ -375,8 +377,9 @@ async function contacts() {
await page
.waitForFunction(
() =>
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
'flights:aaa051',
window.__godsEyeView.entityRecords.read(
window.__godsEyeView.viewer.trackedEntity,
)?.trackedId === 'flights:aaa051',
{ timeout: 60_000 },
)
.catch(() => {});
+32 -68
View File
@@ -263,39 +263,16 @@ try {
}),
);
// The viewer's OWN click actions, captured before the tool ever runs. Both
// must be absent while drawing and back — as the SAME functions — after.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
// The viewer's own select and follow, which the input port runs: on before
// the tool runs, off while drawing, and on again after.
const stockActions = async () => {
const state = await drawState();
return {
clickOn: state.stockSingleClick,
doubleOn: state.stockDoubleClick,
};
});
const stockActions = () =>
page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
const live = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
return {
clickPresent: Boolean(live.click),
doublePresent: Boolean(live.double),
clickIsOriginal: live.click === window.__qaStock.click,
doubleIsOriginal: live.double === window.__qaStock.double,
hadClick: Boolean(window.__qaStock.click),
hadDouble: Boolean(window.__qaStock.double),
};
});
};
const beforeDraw = await stockActions();
// ── enable / disable cycles leave nothing behind ────────────────────────
const before = await drawState();
@@ -318,24 +295,21 @@ try {
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
);
// ── the viewer's own click actions are borrowed, then given back ────────
// ── the viewer's own click actions are held off, then given back ───────
await page.click('#draw-toggle');
const duringDraw = await stockActions();
check(
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
"Cesium's own selecting click and tracking double-click are both off while drawing",
duringDraw.clickOn === false && duringDraw.doubleOn === false,
JSON.stringify({ beforeDraw, duringDraw }),
);
await page.click('#draw-toggle');
const afterDraw = await stockActions();
check(
'and both are restored as the SAME functions, not replacements',
afterDraw.clickPresent === duringDraw.hadClick &&
afterDraw.doublePresent === duringDraw.hadDouble &&
afterDraw.clickIsOriginal &&
afterDraw.doubleIsOriginal,
JSON.stringify(afterDraw),
'and both are back after',
afterDraw.clickOn === beforeDraw.clickOn &&
afterDraw.doubleOn === beforeDraw.doubleOn,
JSON.stringify({ beforeDraw, afterDraw }),
);
// ── a pin, finished with Enter ──────────────────────────────────────────
@@ -664,19 +638,6 @@ try {
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 },
);
// The reload built a new page context: re-capture the viewer's own click
// actions so the identity comparisons below still mean something.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
@@ -827,24 +788,20 @@ try {
const wasActive = tool.active;
await tool.destroy();
await new Promise((resolve) => setTimeout(resolve, 50));
const Cesium = window.__CESIUM__;
const viewer = window.__godsEyeView.viewer;
const stock = viewer.screenSpaceEventHandler;
let previews = 0;
for (let i = 0; i < viewer.dataSources.length; i += 1)
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
const diagnostics = tool.diagnostics();
return {
wasActive,
diagnostics: tool.diagnostics(),
diagnostics,
previews,
dataSources: viewer.dataSources.length,
handleGone: window.__gevDrawTool === undefined,
clickRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
window.__qaStock.click,
doubleRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
window.__qaStock.double,
// The viewer's own select and follow run again.
clickRestored: diagnostics.stockSingleClick,
doubleRestored: diagnostics.stockDoubleClick,
drawingClass: document.body.classList.contains('gev-drawing'),
};
});
@@ -941,6 +898,11 @@ async function findClickableTarget() {
const site = await page
.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
// Core registers each local feature's context as `<layer id>:<feature id>`.
const isDataCentre = (entity) =>
window.__godsEyeView.entityRecords
.read(entity)
?.contextId?.startsWith('local-datacenters:');
await window.__godsEyeView.dataManager.setEnabled(
'local-datacenters',
true,
@@ -960,7 +922,7 @@ async function findClickableTarget() {
for (let attempt = 0; attempt < 40; attempt += 1) {
for (let i = 0; i < viewer.dataSources.length; i += 1) {
for (const entity of viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId !== 'local-datacenters') continue;
if (!isDataCentre(entity)) continue;
const point = centreOf(entity);
if (!point) continue;
const carto = window.Cesium
@@ -993,8 +955,10 @@ async function findClickableTarget() {
viewer.scene.requestRender();
}, site);
await wait(4000);
const spot = await onScreen(
(entity) => entity.__localLayerId === 'local-datacenters',
const spot = await onScreen((entity) =>
window.__godsEyeView.entityRecords
.read(entity)
?.contextId?.startsWith('local-datacenters:'),
);
return spot ? { ...spot, layer: 'data centre' } : null;
}
+3 -3
View File
@@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) {
const tracked = viewer?.trackedEntity;
const current = {
elapsedMs: Math.round(performance.now() - startedAt),
entryId: tracked?.gevTrackedId || null,
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title),
entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null,
hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title),
candidateCount: diagnostics.candidateCount || 0,
projectedCount: diagnostics.projectedCount || 0,
selectedCount: diagnostics.selectedCount || 0,
@@ -610,7 +610,7 @@ async function main() {
if (!pool.length) return null;
const ent = v.trackedEntity;
let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
if (!tracked) return pool[0];
let best = null, bestD = Infinity;
for (const m of pool) {
+3 -1
View File
@@ -218,9 +218,11 @@ try {
const gev = window.__godsEyeView;
const snap = () => infraIds.map((id) => {
const visibleIds = [];
// Core registers each local feature's context as `<layer id>:<feature id>`.
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
const contextId = gev.entityRecords.read(entity)?.contextId;
if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id));
}
}
return visibleIds.sort();
+5 -2
View File
@@ -17,9 +17,11 @@ export async function checkInstallationPan(page, dir) {
'military-installations',
).module;
const centerIds = [];
// Core registers each local feature's context as `<layer id>:<feature id>`.
for (let i = 0; i < g.viewer.dataSources.length; i++)
for (const e of g.viewer.dataSources.get(i).entities.values)
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:'))
centerIds.push(e.id);
window.__polishPanFrames = [];
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
const sites = [],
@@ -333,7 +335,8 @@ export async function checkInstallationSelection(
for (let y = 250; y < 650; y += 75)
for (let x = 400; x < 850; x += 75) {
const pick = g.viewer.scene.pick({ x, y });
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
// Nothing picked: no installation marker, outline or tracked contact.
if (!pick?.id)
return { x, y };
}
throw new Error('No empty map point');
+3 -2
View File
@@ -1277,8 +1277,9 @@ try {
outlines = 0;
for (let i = 0; i < viewer.dataSources.length; i++)
for (const entity of viewer.dataSources.get(i).entities.values) {
if (!ids.has(entity.installationId || entity.id) || !entity.show)
continue;
// Outline entities are `<record id>:outline:<ring>`.
const recordId = String(entity.id).replace(/:outline:\d+$/, '');
if (!ids.has(recordId) || !entity.show) continue;
if (entity.billboard || entity.point) markers++;
if (entity.polyline) outlines++;
}
+26 -9
View File
@@ -3073,20 +3073,27 @@ async function main() {
expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples,
spread: Math.max(...samples) - Math.min(...samples),
};
});
check(
'pointer preview never plays and release commits the nearest station once without a needle sweep',
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 1
&& tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3494,6 +3501,9 @@ async function main() {
});
});
await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
@@ -3692,7 +3702,14 @@ async function main() {
radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
// Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
@@ -3800,7 +3817,7 @@ async function main() {
id: `qa:${ownerPrefix}:radio-camera-owner`,
position: viewer.camera.positionWC.clone(),
});
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`;
window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`;
viewer.trackedEntity = owner;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const beforeStationId = radio.getUIState().selected?.id || null;
@@ -3814,7 +3831,7 @@ async function main() {
afterStationId: radio.getUIState().selected?.id || null,
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
trackedIdentityPreserved: viewer.trackedEntity === owner,
trackedId: viewer.trackedEntity?.gevTrackedId || null,
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
};
viewer.trackedEntity = undefined;
viewer.entities.remove(owner);
@@ -3850,11 +3867,11 @@ async function main() {
id: 'qa:flights:delayed-radio-fallback-owner',
position: viewer.camera.positionWC.clone(),
});
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner';
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner';
viewer.trackedEntity = owner;
window.__qaDelayedRadioTrackingOwner = owner;
window.__qaRejectRadioPlay();
return owner.gevTrackedId;
return window.__godsEyeView.entityRecords.read(owner)?.trackedId;
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
@@ -3863,7 +3880,7 @@ async function main() {
const result = {
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
trackedIdentityPreserved: viewer.trackedEntity === owner,
trackedId: viewer.trackedEntity?.gevTrackedId || null,
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
};
viewer.trackedEntity = undefined;
@@ -4205,7 +4222,7 @@ async function main() {
id: 'qa:flights:radio-recenter-stage-owner',
position: viewer.camera.positionWC.clone(),
});
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner';
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner';
viewer.trackedEntity = owner;
completeFlight(0);
const trackingBetweenStagesCalls = calls.length;
+18 -10
View File
@@ -169,7 +169,7 @@ try {
result.bloom.intensityPct === 98 &&
snapshot.bloom.intensity === 98 &&
manager._visualEffects.bloomIntensity === 98 &&
manager._bloomStage.enabled
manager.viewer.scene.postProcessStages.bloom.enabled
);
}),
);
@@ -183,7 +183,11 @@ try {
result.sharpen.intensityPct === 61 &&
manager.getVisualState().sharpen.intensity === 61 &&
manager._visualEffects.sharpenIntensity === 0.61 &&
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
Math.abs(
manager.viewer.scene.postProcessStages.getStageByName(
'effects_sharpen',
).uniforms.amount - 1.32,
) < 1e-9
);
}),
);
@@ -199,9 +203,10 @@ try {
const manager = window.__godsEyeView.styleManager;
const effects = manager._visualEffects;
manager.setStyle('retro');
const owned = [...Object.values(effects.stages), effects.sharpenStage];
const collection = manager.viewer.scene.postProcessStages;
const previousBloom = effects.previousBloom;
const owned = Object.keys(effects.stages).map((name) =>
collection.getStageByName(`effects_style_${name}`),
);
const pending = manager.dispose();
const stoppedBeforeAwait =
effects.stopped &&
@@ -210,12 +215,15 @@ try {
await pending;
return {
stoppedBeforeAwait,
removed: owned.every((stage) => !collection.contains(stage)),
restored:
effects.bloomStage.enabled === previousBloom.enabled &&
Object.entries(previousBloom.uniforms).every(
([name, value]) => effects.bloomStage.uniforms[name] === value,
removed:
owned.every(Boolean) &&
Object.keys(effects.stages).every(
(name) => !collection.getStageByName(`effects_style_${name}`),
),
// The renderer gives the scene's bloom back when it goes.
restored:
!collection.bloom.enabled &&
!collection.getStageByName('effects_sharpen')?.enabled,
};
});
check(
@@ -223,7 +231,7 @@ try {
teardown.stoppedBeforeAwait,
);
check(
'UI teardown removes owned stages and restores borrowed bloom',
'UI teardown removes its styles and turns bloom and sharpen off',
teardown.removed && teardown.restored,
);
check(
+7 -4
View File
@@ -150,7 +150,7 @@ try {
manager._bloomSlider.focus();
return {
value: Number(manager._bloomSlider.value),
contrast: manager._bloomStage.uniforms.contrast,
contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
};
});
await page.keyboard.press('ArrowRight');
@@ -160,7 +160,8 @@ try {
const manager = window.__godsEyeView.styleManager;
return (
Number(manager._bloomSlider.value) > before.value &&
manager._bloomStage.uniforms.contrast < before.contrast
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
before.contrast
);
}, displayBefore),
);
@@ -267,13 +268,15 @@ try {
const manager = window.__godsEyeView.styleManager;
manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
const contrast =
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
manager._bloomBtn.click();
manager._bloomSlider.value = '15';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
return (
manager.bloomEnabled === before &&
manager._bloomStage.uniforms.contrast === contrast
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
contrast
);
}),
);
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
// Writes the reference camera traces the conformance suite compares a
// renderer against: src/render/conformance/goldens/camera.json. Run it after
// a Cesium upgrade or a change to the camera scripts, and review the diff.
//
// node scripts/record-camera-goldens.mjs [--check]
//
// With --check it writes nothing and exits 1 when the file is out of date.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json');
const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`;
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
if (current !== text) {
console.error(`${path.relative(ROOT, OUT)} is out of date`);
process.exit(1);
}
} else {
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, text);
console.log(`wrote ${path.relative(ROOT, OUT)}`);
}
+40 -23
View File
@@ -94,7 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -701,7 +701,7 @@ async function main() {
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
return manager?.isEnabled?.('flights')
&& tracked?.icao24 === 'aaa001'
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
},
{ timeout: 30000, polling: 100 },
);
@@ -712,7 +712,7 @@ async function main() {
flightsEnabled: manager.isEnabled('flights'),
trackedId: flights.getTrackedInfo()?.icao24 || null,
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
};
});
record(
@@ -742,7 +742,7 @@ async function main() {
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
return manager?.isEnabled?.('flights')
&& tracked?.icao24 === 'aaa001'
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
},
{ timeout: 30000, polling: 100 },
);
@@ -753,7 +753,7 @@ async function main() {
flightsEnabled: manager.isEnabled('flights'),
trackedId: flights.getTrackedInfo()?.icao24 || null,
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
};
});
record(
@@ -815,7 +815,7 @@ async function main() {
const latchedShare = await evalPage(() => ({
tracked: window.__godsEyeView.dataManager.layers
.get('flights').module.getTrackedInfo()?.icao24 || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
}));
record(
'share-v2: the pending subject latches on when it arrives on a later poll',
@@ -1380,7 +1380,7 @@ async function main() {
if (exactPickModel) return exactPickModel;
const ent = window.__godsEyeView.viewer.trackedEntity;
let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
if (!tracked) return pool[0];
let best = null, bestD = Infinity;
for (const m of pool) {
@@ -1427,7 +1427,7 @@ async function main() {
const dm = window.__godsEyeView.dataManager;
const fl = dm.layers.get('flights').module;
const ent = window.__godsEyeView.viewer.trackedEntity;
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
const dets = fl.getDetectableObjects({ maxCount: 1000 });
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
if (!disp || !me || !me.position) return null;
@@ -1656,7 +1656,7 @@ async function main() {
const ti = fl.getTrackedInfo();
if (!ti) return null;
const ent = v.trackedEntity;
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
const pos = disp || ti.position;
if (!pos) return null;
const center = v.scene.cartesianToCanvasCoordinates(pos);
@@ -1908,7 +1908,7 @@ async function main() {
// presentation model (entities are asserted native-label-free);
// the native read stays as a fallback so this probe still fails
// loudly if BOTH surfaces are ever absent.
const model = ent.gevLabelModel;
const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel;
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
if (!ent.label || !ent.label.text) return null;
const t = ent.label.text;
@@ -2553,7 +2553,7 @@ async function main() {
}
// WELD (tracked): the tracked CARD anchors to the model you can see.
// `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on
// the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on
// purpose — the latter carries the follow-camera anti-jitter contract and
// must keep returning the cached dead-reckoned value the camera settled
// on. So this asserts both halves: the visual accessor is welded to the
@@ -2580,10 +2580,10 @@ async function main() {
const at = (p) => (p ? Math.hypot(
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
) : null);
const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null;
const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null;
const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
return {
hasVisualAccessor: typeof ent?.gevVisualPosition === 'function',
hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function',
visualDelta: at(visual),
displayDelta: at(display),
};
@@ -2599,7 +2599,7 @@ async function main() {
// anti-jitter accessor has been repurposed and the follow camera is at
// risk. Skipped (not failed) when the DR cache is momentarily invalid.
const displayDelta = trackedWeld.displayDelta;
record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)',
record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)',
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
Number.isFinite(displayDelta)
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
@@ -2698,16 +2698,18 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// independently of rendered FPS. Observe the first completed settle frame
// within 500 ms of forcing moveEnd and the first reveal frame within
// 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2769,7 +2771,23 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const observed = await observeArrivalRotation({
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
// landed where the 1 s idle refresh could already have repaired it
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
// observation gets one more attempt; one that measured a wrong angle —
// a wrong first reveal, or a wrong settle frame before a reveal
// timeout — never does.
const observed = await observeArrivalRotationWithRetry({
async prepare() {
// Freshen both layers' rotation passes right before the probe, so
// the settle frame is measured against a pass that just ran rather
// than one left over from the previous probe — with the camera
// idle, a layer otherwise runs a pass only every
// ROTATION_REFRESH_MS.
v.camera.moveEnd.raiseEvent();
await nextFrames(1);
},
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
@@ -2790,7 +2808,6 @@ async function main() {
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2800,8 +2817,8 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+44
View File
@@ -1,3 +1,4 @@
import { isIPv6 } from 'node:net';
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local');
}
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
/**
* Whether a socket peer address is this machine: IPv4 loopback in any of its
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
* @param {string} address - A `clientKey` value.
* @returns {boolean}
*/
export function isLoopbackPeer(address) {
const raw = String(address || '');
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
return v4.startsWith('127.') || raw === '::1';
}
/**
* Rate-limit key that survives address rotation, for limiters whose global
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
* address at will inside its /64, so keying by the full address lets a single
* host mint per-peer quota until the global window closes for everyone else.
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
* address folds to its IPv4 form so one host cannot hold two buckets. Any
* other value (IPv4, or the `local` fallback) passes through unchanged.
* @param {import('node:http').IncomingMessage} req
* @returns {string}
*/
export function clientNetworkKey(req) {
const address = clientKey(req);
const mapped = IPV4_MAPPED_RE.exec(address);
if (mapped) return mapped[1];
const bare = address.replace(/%.*$/, ''); // link-local zone id
if (!isIPv6(bare)) return address;
const [head, tail = ''] = bare.split('::');
const headParts = head ? head.split(':') : [];
const tailParts = tail ? tail.split(':') : [];
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
return `${hextets
.slice(0, 4)
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
.join(':')}::/64`;
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
const req = (remoteAddress) => ({ socket: { remoteAddress } });
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
assert.equal(
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
'2001:db8:1:2::/64',
);
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req(undefined)), 'local');
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
});
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
assert.equal(isLoopbackPeer(address), true, address);
}
for (const address of [
'10.0.0.1',
'::ffff:10.0.0.1',
'2001:db8::1',
'::2',
'local',
'',
undefined,
]) {
assert.equal(isLoopbackPeer(address), false, String(address));
}
});
+1 -1
View File
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
+69 -13
View File
@@ -1,5 +1,12 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryIsUnspaced,
hudSummaryLocale,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
.trim();
}
function toFiveWordHudSummary(value) {
return String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
/** Parse the summary and normalize the canonical feed state. */
function parseHudSummaryReply(text, locale) {
let reply = null;
try {
reply = JSON.parse(text);
} catch {
/* not JSON: no summary */
}
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState
: null;
return {
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.slice(0, 5)
.join(' ');
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.filter(Boolean);
// Characters, not words: the model counts Rue de Rivoli as one word.
const max = hudSummaryIsUnspaced(locale)
? HUD_SUMMARY_MAX_UNSPACED_CHARS
: HUD_SUMMARY_MAX_CHARS;
return capAtWord(words.join(' '), max, locale);
}
/**
* Cut `text` to `max` characters at a word boundary; a first word longer
* than that is cut where the cap falls.
*/
function capAtWord(text, max, locale) {
const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
];
if (graphemes.length <= max) return text;
const cut = graphemes[max].index;
let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (index + segment.length > cut) break;
end = index + segment.length;
}
return text.slice(0, end).trim() || text.slice(0, cut);
}
async function handleHudSummary(req, res) {
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: HUD_SUMMARY_INSTRUCTIONS,
input: JSON.stringify(context),
instructions: hudSummaryInstructions(context?.locale),
input: hudSummaryInput(context),
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
reasoning: { effort: 'minimal' },
max_output_tokens: 100,
max_output_tokens: 200,
}),
});
const data = await response.json().catch(() => ({}));
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
const { summary, feedState } = parseHudSummaryReply(
extractOpenAiResponseText(data),
context?.locale,
);
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
res.end(
JSON.stringify({
summary: summary || null,
feedState,
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
}
}
export { handleHudSummary };
export { handleHudSummary, parseHudSummaryReply };
+58 -9
View File
@@ -1,6 +1,12 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
makeRateLimiter,
clientKey,
clientNetworkKey,
isLoopbackPeer,
} from './common/rate-limit.js';
import { hasProxySignals } from '../../src/localRequestGate.mjs';
import { readRequestBodyCapped } from './common/request.js';
import { admitSameSite } from './common/same-site.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
@@ -29,10 +35,21 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0;
// Bound body-reading and validation work, including malformed requests and
// cache hits. Keep the tighter upstream budget below independent of this gate.
const _overpassRequestLimiter = makeRateLimiter({
windowMs: 60_000,
max: 300,
// cache hits, before a byte is read. Remote peers share one global window so
// address rotation cannot mint admission without bound (IPv6 peers are keyed
// by /64 for the same reason). A direct loopback request — the owner's own
// browser and the repo QA harnesses, with no forwarding headers — has its own
// per-peer window outside that global, so busy LAN peers cannot lock the owner
// out of the map. A request relayed by a reverse proxy on this machine also
// arrives from loopback, but it is remote traffic: it is admitted from the
// remote pool under its own socket-keyed bucket, never from the owner's. The
// forwarded client address is not trusted as identity. Only admission is
// isolated: the upstream budget below (90/min per key, 300/min globally) is
// shared, so an owner request that misses cache can still be refused there.
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
const _overpassPeerAdmission = makeRateLimiter({
...OVERPASS_ADMISSION_WINDOW,
globalMax: 900,
});
@@ -42,6 +59,33 @@ const _overpassRateLimiter = makeRateLimiter({
globalMax: 300,
});
/**
* Classify a request for admission. Direct loopback — a loopback socket with
* no forwarding header — is the owner's own pool; everything else, including a
* loopback socket that carries forwarding headers (a local reverse proxy), is
* remote. A proxied request keeps its socket identity under a `proxied:`
* prefix, so it never shares a bucket with the owner's direct requests.
* @param {import('http').IncomingMessage} req
* @returns {{direct: boolean, key: string}}
*/
function overpassAdmissionClass(req) {
const peer = clientNetworkKey(req);
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
if (hasProxySignals(req.headers || {}))
return { direct: false, key: `proxied:${peer}` };
return { direct: true, key: peer };
}
/**
* Spend one admission slot for this request; false when its bucket is over its
* window (or, for remote traffic, when the shared global window is full).
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
* @returns {boolean}
*/
function admitOverpassRequest({ direct, key }) {
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
}
/**
* Write a completed Overpass payload to the HTTP response.
*
@@ -103,8 +147,13 @@ function overpassProxy({ routing = {} } = {}) {
return;
}
if (admitSameSite(req, res)) return;
if (!_overpassRequestLimiter(clientKey(req))) {
// Keyless gate: refuses cross-site browser requests but, unlike the
// cost-bearing routes, admits the forwarding headers a reverse proxy
// adds — the layers that depend on Overpass must keep working for an
// instance deployed the way the README documents.
if (admitSameSiteKeyless(req, res)) return;
const admission = overpassAdmissionClass(req);
if (!admitOverpassRequest(admission)) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
@@ -163,7 +212,7 @@ function overpassProxy({ routing = {} } = {}) {
// requests; boundary-class queries keep their month-long TTL.
readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
allowUpstream: () => _overpassRateLimiter(admission.key),
loadUpstream: async () => {
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
return {
+10 -6
View File
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
// The settings block (`[out:json][timeout:25]`) was blanked together with
// the tag filters, so it arrives here as an empty statement.
if (!stmt || /^out\b/.test(stmt)) continue;
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
// misread as a spatial bound. Bounds live in (...) / function calls / set
// refs, never inside [...], so the probe loses nothing real.
// Strip output-set assignments (NOT input bounds). With the tag filters
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
// be misread as a spatial bound; bounds live in (...) / function calls /
// set refs, never inside [...], so nothing real was lost.
const outSets = [];
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
if (bounded) for (const name of outSets) boundedSets.add(name);
}
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
// rather than passed through as written.
const clamped = data.replace(
/\[timeout:\s*(\d+)\s*\]/gi,
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
(_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
);
@@ -3,10 +3,24 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { spawnSync } from 'node:child_process';
import { overpassProxy } from '../overpass.js';
import { _overpassCache } from './cache.js';
import { sanitizeOverpassBody } from './query.js';
const form = (query) => `data=${encodeURIComponent(query)}`;
// The limiters are module-level, so every test that spends admission mocks a
// clock a full day past the previous one in execution order: no test can see
// another's window, whatever order (or randomization) they run in. A new
// test that reaches admission must call it too: earlier tests leave
// timestamps days ahead of the real clock, and those still count.
let clockBase = Date.now() + 86_400_000;
function freshClock(t) {
clockBase += 86_400_000;
const clock = { now: clockBase };
t.mock.method(Date, 'now', () => clock.now);
return clock;
}
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
for (const query of [
'node[',
@@ -37,6 +51,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
query.replace('[timeout:999]', '[timeout:30]'),
);
}
// Whitespace inside the directive is clamped too, not passed through.
const spaced = sanitizeOverpassBody(
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
);
assert.equal(spaced.ok, true);
assert.equal(
new URLSearchParams(spaced.body).get('data'),
'[out:json][timeout:30];node(around:20,1,1);out;',
);
});
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
@@ -126,7 +149,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
{ origin: 'null' },
{ 'x-forwarded-for': '127.0.0.1' },
// Forwarding headers do not launder a cross-site request.
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
{
'x-forwarded-proto': 'https',
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
},
]) {
const response = await call(route, { headers, forbidRead: true });
assert.equal(response.statusCode, 403);
@@ -136,12 +165,24 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
});
}
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
freshClock(t);
const route = handler();
for (const headers of [
{},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
// The README's reverse-proxy deployment: Overpass is keyless, so the
// forwarding headers the proxy adds are admitted (as for terrain heights).
{
host: 'globe.example.lan',
origin: 'https://globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'globe.example.lan',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) {
const response = await call(route, { headers });
assert.equal(response.statusCode, 400);
@@ -158,8 +199,7 @@ test('same-origin, LAN and non-browser requests still reach validation; GET stat
});
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
let now = Date.now() + 120000;
t.mock.method(Date, 'now', () => now);
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
@@ -173,6 +213,216 @@ test('invalid requests spend admission quota before parsing, including on an unc
assert.equal(denied.reads, 0);
assert.equal(denied.headers['retry-after'], '60');
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
now += 60001;
clock.now += 60_001;
assert.equal((await call(route, { peer })).statusCode, 400);
});
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
assert.equal(starved.reads, 0);
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const owner = await call(route, { peer });
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
assert.equal(owner.reads, 1);
}
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
});
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
.statusCode,
400,
);
}
const rotated = await call(route, {
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
forbidRead: true,
});
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
assert.equal(
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
400,
);
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
400,
);
}
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
});
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
// Not data: nothing is cached or written to disk by this test.
return new Response('upstream unavailable', { status: 503 });
});
const hit = 'node(around:20,1,1);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
const peer = '10.7.7.7';
// More cache hits than the upstream limiter would allow per peer.
for (let i = 0; i < 100; i++) {
const response = await call(route, { peer, body: form(hit) });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['x-overpass-cache'], 'HIT');
}
assert.equal(fetches, 0);
const miss = await call(route, {
peer,
body: form('node(around:20,2,2);out;'),
});
assert.equal(fetches, 1, 'the first miss still reaches upstream');
assert.notEqual(miss.statusCode, 429);
for (let i = 0; i < 199; i++) {
assert.equal(
(await call(route, { peer, body: form(hit) })).statusCode,
200,
);
}
const exhausted = await call(route, {
peer,
body: form(hit),
forbidRead: true,
});
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
assert.equal(exhausted.reads, 0);
});
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
const hit = 'node(around:20,3,3);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
// The proxy connects over loopback; a rotating forwarded client address
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const proxied = (i) => ({
peer: proxyPeer,
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
body: form(hit),
});
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
}
const refused = await call(route, { ...proxied(300), forbidRead: true });
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
assert.equal(refused.reads, 0);
for (const peer of ['127.0.0.1', '::1']) {
const owner = await call(route, { peer, body: form(hit) });
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
}
clock.now += 60_001;
assert.equal((await call(route, proxied(0))).statusCode, 200);
}
});
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
400,
);
}
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
});
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
// Not data: nothing is cached, so every request below is a miss.
t.mock.method(
globalThis,
'fetch',
async () => new Response('upstream unavailable', { status: 503 }),
);
const route = handler();
const miss = form('node(around:20,4,4);out;');
// The upstream limiter's own refusal is the only miss without this header.
const passedLimiter = (response) =>
response.headers['x-overpass-cache'] !== undefined;
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
for (let i = 0; i < 90; i++) {
const response = await call(route, { ...proxied, body: miss });
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
}
const spent = await call(route, { ...proxied, body: miss });
assert.equal(spent.statusCode, 429);
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
const owner = await call(route, { peer: '127.0.0.1', body: miss });
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
});
@@ -0,0 +1,59 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const DIR = fileURLToPath(
new URL('../../src/render/conformance/goldens/visual/', import.meta.url),
);
const NAME = /^[a-z0-9-]+\.(png|json)$/;
const MAX_BYTES = 8 * 1024 * 1024;
/**
* Dev server only: lets `/dev/conformance/` save the visual references it
* records into `src/render/conformance/goldens/visual/`. Takes a PUT of one
* PNG or the manifest, named in the path; nothing else is written.
*/
export function conformanceReferencesPlugin() {
return {
name: 'conformance-references',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/__conformance/references/', (req, res) => {
const name = decodeURIComponent((req.url ?? '').replace(/^\//, ''));
// Only the page on this machine: a loopback peer, sent from the
// dev server's own origin.
const peer = req.socket.remoteAddress ?? '';
const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer);
let sameOrigin = false;
try {
sameOrigin = new URL(req.headers.origin).host === req.headers.host;
} catch {
// No Origin, or not a URL.
}
if (!loopback || !sameOrigin) {
res.writeHead(403).end();
return;
}
if (req.method !== 'PUT' || !NAME.test(name)) {
res.writeHead(400).end();
return;
}
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BYTES) req.destroy();
else chunks.push(chunk);
});
req.on('end', async () => {
try {
await mkdir(DIR, { recursive: true });
await writeFile(DIR + name, Buffer.concat(chunks));
res.writeHead(204).end();
} catch (error) {
res.writeHead(500).end(String(error?.message ?? error));
}
});
});
},
};
}
+2
View File
@@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js';
import { conformanceReferencesPlugin } from './conformance-references.js';
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
import { standaloneVoiceTools } from './voiceTools.js';
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
@@ -30,6 +31,7 @@ export function standaloneConfig(
}),
localMcpPlugin(),
googleTokenUnavailablePlugin(),
conformanceReferencesPlugin(),
apiNotFoundPlugin(),
unwatchedRuntimeDirsPlugin(),
],
+5 -7
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium';
import { toLonLatH } from '../geo/index.js';
import {
feedProvenanceEnvelope,
layerSnapshot,
@@ -109,9 +109,7 @@ export function getWorldPacket(options = {}) {
if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
const eye = toLonLatH(viewer.camera.positionWC);
// Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll();
@@ -126,9 +124,9 @@ export function getWorldPacket(options = {}) {
ok: true,
action: 'get_current_view_state',
camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
heightM: cartographic.height,
latitude: eye.lat,
longitude: eye.lon,
heightM: eye.height,
},
style: styleManager?.activeStyle || 'normal',
context: contextState
+39 -31
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { cameraOf } from '../render/cesium/camera.js';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
@@ -1700,14 +1701,18 @@ export function createAnnotationEngine({
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
);
const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading,
Cesium.Math.toRadians(-35),
Math.max(900, sphere.radius * 2.6),
),
duration: 1.6,
});
const camera = cameraOf(viewer);
camera.flyToBounds(
{ center: sphere.center, radius: sphere.radius },
{
offset: {
heading: camera.pose().heading,
pitch: Cesium.Math.toRadians(-35),
range: Math.max(900, sphere.radius * 2.6),
},
durationS: 1.6,
},
);
} catch {
/* camera assist is best-effort */
}
@@ -1757,22 +1762,23 @@ export function createAnnotationEngine({
: anno.ring,
)
: viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(
target.lon,
target.lat,
anno.anchor.height || 0,
),
range,
),
const camera = cameraOf(viewer);
camera.flyToBounds(
{
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading,
Cesium.Math.toRadians(-35),
range * 2.4,
),
duration: 1.8,
center: {
lon: target.lon,
lat: target.lat,
height: anno.anchor.height || 0,
},
radius: range,
},
{
offset: {
heading: camera.pose().heading,
pitch: Cesium.Math.toRadians(-35),
range: range * 2.4,
},
durationS: 1.8,
},
);
} catch {
@@ -1978,15 +1984,17 @@ export function createAnnotationEngine({
}) {
if (destroyed) return;
try {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: 0,
cameraOf(viewer).flyTo(
{ lon, lat, height },
{
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: 0,
},
durationS: duration,
},
duration,
});
);
} catch {
/* camera flight best-effort */
}
+6 -4
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
//
@@ -25,6 +26,7 @@ import {
installRenderGovernor,
_resetRenderGovernorForTest,
} from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
// null = definitive miss, object = footprint) and counts its invocations.
@@ -1261,7 +1263,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }),
renderer,
});
let changes = 0;
@@ -1330,12 +1332,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: {
viewer: bindFakeCesiumCamera({
camera: {
heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere),
},
},
}),
renderer,
});
let changes = 0;
@@ -1547,7 +1549,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
let renders = 0;
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([
+32 -62
View File
@@ -18,9 +18,9 @@
* exceptions inside each.
* - Cesium's Viewer binds its OWN left click (select the entity under the
* pointer) and double click (track it) on the viewer's handler, outside every
* layer and therefore outside the shared claim. Both are borrowed for the
* layer and therefore outside the shared claim. Both are suppressed for the
* session and given back on the way out, and `destroy()` gives back every
* listener, the Cesium handler, the preview data source and the window
* listener, the pointer subscriptions, the preview data source and the window
* handle, so the tool can be turned off or the shell disposed without leaving
* anything behind.
*
@@ -37,6 +37,8 @@ import {
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { inputFor } from '../input/dispatcher.js';
import { sensorPickPosition } from '../input/sensorPick.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
@@ -88,10 +90,8 @@ export function initDrawTool({
let shape = 'area';
let color = 'primary';
let hint = '';
let handler = null;
let unbindScene = null;
let lease = null;
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
@@ -228,7 +228,9 @@ export function initDrawTool({
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
const p = worldAt(event.endPosition);
// The click's own position is sensor-corrected; the rubber band must
// follow the same point the click would place.
const p = worldAt(sensorPickPosition(viewer.scene, event.endPosition));
cursor = p
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
: null;
@@ -384,50 +386,29 @@ export function initDrawTool({
}
function bindSceneHandler() {
if (handler) return;
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(() => {
void finish();
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
// single click picks an entity into `viewer.selectedEntity`, the double
// click tracks it. Neither goes through a layer, so neither can be fixed by
// the shared pointer claim — they have to be borrowed outright for the
// session and given back on the way out. Borrowing the single click is what
// stops a vertex placed on a contact from also selecting it.
const stock = viewer.screenSpaceEventHandler;
savedSingleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
if (unbindScene) return;
const input = inputFor(viewer);
const held = { lease: () => lease };
const offs = [
input.onPointer('click', onClick, held),
input.onPointer('move', onMove, held),
input.onPointer('dblclick', () => void finish(), held),
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
// single click picks an entity into `viewer.selectedEntity`, the double
// click tracks it. Neither goes through a layer, so neither yields to the
// shared pointer claim — they are suppressed for the session and given
// back on the way out. Suppressing the single click is what stops a
// vertex placed on a contact from also selecting it.
input.suppressStockClicks(),
];
unbindScene = () => offs.forEach((off) => off());
listen(document, 'keydown', onKey, true);
}
function releaseSceneHandler() {
if (handler) {
handler.destroy();
handler = null;
}
if (savedSingleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedSingleClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedSingleClick = null;
}
if (savedDoubleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
savedDoubleClick = null;
}
// Drop the keydown listener bound alongside this handler.
unbindScene?.();
unbindScene = null;
// Drop the keydown listener bound alongside the pointer subscriptions.
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue;
@@ -523,26 +504,15 @@ export function initDrawTool({
return {
active,
destroyed,
sceneHandler: Boolean(handler),
sceneHandler: Boolean(unbindScene),
domListeners: domListeners.length,
previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
pointerOwner: pointerOwner(),
// The viewer's OWN click actions. While a session is open both are
// borrowed (absent here); after it closes both are back. A harness can
// compare the restored functions by identity with what it captured
// before, which is the only way to prove they were given back rather
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
// The viewer's own select and follow, which the input port runs: off
// while a session is open, back on after it closes.
stockSingleClick: inputFor(viewer).stockEnabled('select'),
stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
};
},
/** Resolves once every deferred teardown step has run. */
+25 -16
View File
@@ -13,6 +13,7 @@ import {
releasePointer,
resetPointerOwnership,
} from '../data/inputOwnership.js';
import { fakeInput } from '../input/testDoubles.mjs';
/* ── DOM doubles ───────────────────────────────────────────────────────── */
@@ -223,10 +224,13 @@ function fakeAnnotations() {
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) {
function harness({ navigation = null, beforeInput = () => {} } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
// The viewer's own actions are bound before its input port takes them.
beforeInput(viewer);
const input = fakeInput(viewer);
const annotations = fakeAnnotations();
const previousDocument = globalThis.document;
const previousWindow = globalThis.window;
@@ -256,7 +260,9 @@ function harness({ navigation = null } = {}) {
controls,
/** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args),
input,
restore() {
input.destroy();
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
@@ -335,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
}
});
test('both stock viewer click actions are borrowed for the session and given back', async () => {
const h = harness();
test('both stock viewer click actions are held off for the session and given back', async () => {
const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
const h = harness({
beforeInput(viewer) {
viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
},
});
try {
const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
assert.equal(h.input.stockEnabled('select'), true);
h.act('toggleDraw');
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
undefined,
h.input.stockEnabled('select'),
false,
"the viewer's own selecting click must not run while drawing",
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
undefined,
);
assert.equal(h.input.stockEnabled('dblclickFollow'), false);
h.act('toggleDraw');
assert.equal(h.input.stockEnabled('select'), true);
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
h.input.destroy();
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick,
'the exact function is restored, not a replacement',
'the exact function goes back to the viewer, not a replacement',
);
assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
@@ -468,7 +477,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
assert.equal(isPointerFree(), true, 'the pointer is free');
const after = h.tool.diagnostics();
assert.equal(after.destroyed, true);
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
@@ -110,7 +111,7 @@ function fakeViewer(lon, lat) {
return {
dataSources,
frames,
viewer: {
viewer: bindFakeCesiumCredits({
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
@@ -130,7 +131,7 @@ function fakeViewer(lon, lat) {
return index >= 0;
},
},
},
}),
};
}
@@ -540,8 +541,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
const detail = parent.detail;
assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
// The fourth probe finds nothing and the fifth throws: both are misses.
assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned');
trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) {
+3 -2
View File
@@ -7,6 +7,7 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
function viewerAt(lat, lon, height = 20_000) {
return {
@@ -180,10 +181,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
const credits = [];
const cancelled = await city.resolve(
'Austin',
{
bindFakeCesiumCredits({
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
},
}),
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
+11 -18
View File
@@ -45,6 +45,7 @@ import {
import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { heightsFor } from '../queries/heights.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
@@ -2351,26 +2352,18 @@ export function createAnnotationResolver({
function sampleGroundHeight(viewer, lon, lat) {
const scene = viewer?.scene;
if (!scene) return 0;
try {
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const surface = Cesium.Cartographic.toCartesian(carto);
const clamped = scene.clampToHeight(surface);
if (clamped) {
const h = Cesium.Cartographic.fromCartesian(clamped).height;
if (Number.isFinite(h)) return h;
}
const heights = heightsFor(scene);
// A miss is tiles not ready: fall through to the terrain, then 0.
if (heights.supported().clamp) {
const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
const clamped = heights.clampNow(surface);
if (clamped) {
const h = Cesium.Cartographic.fromCartesian(clamped)?.height;
if (Number.isFinite(h)) return h;
}
} catch {
/* tiles not ready — fall through */
}
const globeHeight = scene.globe?.getHeight?.(
Cesium.Cartographic.fromDegrees(lon, lat),
);
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
const globeHeight = heights.now({ lon, lat }, { against: 'terrain' });
return globeHeight !== null && globeHeight > 0 ? globeHeight : 0;
}
function shortLabel(formattedAddress) {
+16 -18
View File
@@ -1,3 +1,4 @@
import { followedOf } from '../data/followed.js';
import * as Cesium from 'cesium';
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
import {
@@ -8,6 +9,7 @@ import { t } from '../i18n/index.js';
import { nextTick } from 'vue';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { heightsFor } from '../queries/heights.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
@@ -141,13 +143,10 @@ export function createScreenAnnotationRenderer(
return cached.h;
}
try {
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
const heights = heightsFor(scene);
if (heights.supported().clamp) {
// A miss is tiles not ready.
const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -422,12 +421,10 @@ export function createScreenAnnotationRenderer(
let top = painted?.y;
let bottom = painted ? painted.y + painted.h : undefined;
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const followed = followedOf(viewer);
const displayPosition = followed?.displayPosition;
const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
typeof displayPosition === 'function' ? displayPosition() : null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
@@ -436,13 +433,14 @@ export function createScreenAnnotationRenderer(
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
const bb = ent?.billboard;
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseW = bb.width?.getValue?.(now) ?? 28;
const baseH = bb.height?.getValue?.(now) ?? 28;
// Billboard box — centered on the anchor, magnified by scaleByDistance, from
// the size the tracking layer recorded for its sprite.
const sprite = followed?.sprite;
if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseW = sprite.width ?? 28;
const baseH = sprite.height ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
const sbd = sprite.scaleByDistance;
if (sbd)
scale = nearFarValue(
sbd,
+4 -7
View File
@@ -2,6 +2,7 @@ import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { heightsFor } from '../queries/heights.js';
/**
* World-space annotation renderer (Direction A).
@@ -174,20 +175,16 @@ export function createWorldAnnotationRenderer(viewer) {
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
if (heightsFor(viewer.scene).supported().clamp) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c;
note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try {
c = viewer.scene.clampToHeight(position);
if (detail) detail.clampResult = 'returned';
c = heightsFor(viewer.scene).clampNow(position);
if (detail) detail.clampResult = c ? 'hit' : 'missed';
} finally {
note?.('clampEnd');
}
+1 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
+3 -2
View File
@@ -1,6 +1,5 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
@@ -30,6 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -73,7 +73,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock({ world: worldClock });
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
@@ -170,6 +170,7 @@ export function createApplicationCatalog({
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
+2 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 33);
assert.equal(first.layers.length, 35);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 33);
assert.equal(catalog.layers.length, 35);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+13 -14
View File
@@ -13,6 +13,7 @@
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { cameraOf } from '../render/cesium/camera.js';
import { annotationsFromParams, createView } from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
cameraOf(viewer).flyTo(
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
{
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
durationS: FLIGHT_SECONDS,
onComplete: () => resolve(true),
onCancel: () => resolve(false),
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
);
});
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
return bindFakeCesiumCamera({
flights,
camera: {
flyTo(options) {
@@ -20,7 +21,7 @@ const fakeViewer = () => {
options.complete();
},
},
};
});
};
const fakeLayers = (enabled) => ({
getAll: () =>
-4
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
@@ -29,8 +28,5 @@ export function createApplicationFirms({ surface, ...options }) {
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory:
options.screenSpaceEventHandlerFactory ??
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
});
}
-5
View File
@@ -1,8 +1,6 @@
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
export function createApplicationGhostMaps(options) {
@@ -14,10 +12,7 @@ export function createApplicationGhostMaps(options) {
hitTest: overlays.hitTestWorldOverlay,
},
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+1 -5
View File
@@ -1,10 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
return createGnssIntegrityLayer(options);
}
-5
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import {
createFirePerimetersLayer,
createInciwebIndexSource,
@@ -6,7 +5,6 @@ import {
} from '../../layers/perimeters/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */
export function createApplicationFirePerimeters(options) {
@@ -20,10 +18,7 @@ export function createApplicationFirePerimeters(options) {
inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
-4
View File
@@ -2,9 +2,6 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { worldTimeFor } from '../../time/worldClock.js';
const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -18,6 +15,5 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
now: () => new Date(imageryTime()),
});
}
+1 -2
View File
@@ -5,7 +5,6 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js';
@@ -37,6 +36,6 @@ export function createApplicationStreetLevel({
}) {
return createStreetLevelLayer({
providers,
services: { sprites, picking, input, scenePick, render },
services: { sprites, picking, scenePick, render },
});
}
-3
View File
@@ -1,12 +1,9 @@
import * as Cesium from 'cesium';
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire cable source geometry to the application overlay host. */
export function createApplicationCables(options) {
return createSubmarineCableLayer({
overlayHost,
screenSpaceEventHandlerFactory: (canvas) =>
new Cesium.ScreenSpaceEventHandler(canvas),
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
...options,
});
+4 -3
View File
@@ -8,6 +8,7 @@ import {
governorRequestRender,
getRenderGovernorDiagnostics,
} from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider;
@@ -44,11 +45,11 @@ test('uninstall only releases the current viewer and makes later render requests
},
},
};
installRenderGovernor(viewer);
installRenderGovernor(framePortFor(viewer.scene));
holdContinuousRender('fixture');
uninstallRenderGovernor({});
uninstallRenderGovernor(framePortFor({}));
assert.equal(getRenderGovernorDiagnostics().installed, true);
uninstallRenderGovernor(viewer);
uninstallRenderGovernor(framePortFor(viewer.scene));
const before = renders;
governorRequestRender('late');
assert.equal(renders, before);
+53 -24
View File
@@ -1,25 +1,32 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
cesiumRenderer,
cesiumViewerOf,
} from '../render/cesium/appRenderer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js';
import { inputFor } from '../input/dispatcher.js';
import {
holdContinuousRender,
installRenderGovernor,
uninstallRenderGovernor,
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
/**
* Construct the application globe using the caller's local configuration.
* `renderer` is the `RendererPlugin` that draws it (default: Cesium). The
* application still runs on the Cesium viewer until its layers are ported,
* so a plugin whose renderer has none is refused.
*/
export async function createApplicationScene({
renderer: rendererPlugin = cesiumRenderer,
requestServices,
googleApiKey,
googleTokens = null,
@@ -46,26 +53,41 @@ export async function createApplicationScene({
});
}
loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
// The scene still loads the tiles, so their credentials stay here.
const renderer = await rendererPlugin.create({
container: document.getElementById('cesiumContainer'),
signal,
});
const viewer = cesiumViewerOf(renderer);
if (!viewer) {
const refusal = new Error(
`The ${rendererPlugin.id} renderer cannot run the application yet`,
);
try {
renderer.destroy();
} catch (error) {
console.warn('[Init] Refused renderer failed to destroy:', error);
}
throw refusal;
}
// The governor keeps its holds on the renderer's frame port. The loop
// draws every frame until the application's tools are up (they release
// 'boot'), as it did before anything could hold it.
installRenderGovernor(renderer.frame);
holdContinuousRender('boot');
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
try {
uninstallRenderGovernor(renderer.frame);
} finally {
renderer.destroy();
}
});
defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
// Before any layer binds a click: every layer and tool shares this one.
// Deferred after the viewer, so it is released before the viewer goes.
const input = inputFor(viewer);
defer(() => input.destroy());
// Ctrl+wheel and trackpad pinches zoom the globe, not the page.
renderer.input.ctrlWheelZoom = true;
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
@@ -136,5 +158,12 @@ export async function createApplicationScene({
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController, operations };
return {
renderer,
viewer,
tileset,
mapStackController,
operations,
input,
};
}
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 33);
assert.equal(actual.layers.length, 35);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+55 -12
View File
@@ -1,25 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { flyToAustin } from '../camera.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
test('teardown before the initial camera delay prevents a late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let flights = 0;
let cancelled = 0;
const stop = flyToAustin({
isDestroyed: () => false,
camera: {
setView() {},
flyTo() {
flights++;
const stop = flyToAustin(
bindFakeCesiumCamera({
isDestroyed: () => false,
camera: {
setView() {},
flyTo() {
flights++;
},
cancelFlight() {
cancelled++;
},
},
cancelFlight() {
cancelled++;
},
},
});
}),
);
stop();
t.mock.timers.tick(1000);
assert.equal(flights, 0);
assert.equal(cancelled, 1);
assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
});
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const cancelled = [];
let current = null;
const viewer = bindFakeCesiumCamera({
isDestroyed: () => false,
camera: {
get _currentFlight() {
return current;
},
setView() {},
flyTo(options) {
current?.cancel();
current = {
name: options.duration === 4 ? 'fly-in' : 'user',
...options,
};
},
cancelFlight() {
cancelled.push(current?.name);
const flight = current;
current = null;
flight?.cancel();
},
},
});
const stop = flyToAustin(viewer);
t.mock.timers.tick(500);
stop();
assert.deepEqual(cancelled, ['fly-in']);
// A user flight replaces the fly-in; teardown leaves it alone.
const second = flyToAustin(viewer);
t.mock.timers.tick(500);
viewer.camera.flyTo({ duration: 2 });
second();
assert.deepEqual(cancelled, ['fly-in']);
assert.equal(current.name, 'user');
});
+15 -14
View File
@@ -1,3 +1,4 @@
import { onFollowChanged } from '../data/followed.js';
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
@@ -11,12 +12,13 @@ import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
import { overlayHost } from './layers/overlayHost.js';
/** Attach scene tools, rendering listeners and the application debug handle. */
export function createApplicationTools({
@@ -32,11 +34,11 @@ export function createApplicationTools({
signal,
defer,
}) {
const { viewer, tileset, mapStackController, operations } = scene;
const { viewer, renderer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks,
dataPacks: { overlayHost, ...sceneDataPacks },
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
@@ -125,10 +127,10 @@ export function createApplicationTools({
}
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Idle render governor: the loop draws on demand whenever nothing animates
// per frame. It goes idle only now, after every module above has had its
// chance to hold. (perf wave 2)
releaseContinuousRender('boot');
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable.
defer(
@@ -145,12 +147,10 @@ export function createApplicationTools({
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
},
);
const removeTrackingListener = onFollowChanged(viewer, (followed) => {
if (followed) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
});
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
@@ -161,7 +161,7 @@ export function createApplicationTools({
// A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden;
renderer.frame.suspend(hidden);
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
data.presentation.flushVisible();
@@ -194,6 +194,7 @@ export function createApplicationTools({
getRenderGovernorDiagnostics,
surfaceServices: operations.surface,
requestRender: governorRequestRender,
entityRecords: { read: readEntityRecord, write: entityRecord },
};
const debug = window.__godsEyeView;
defer(() => {
-101
View File
@@ -2,107 +2,6 @@ import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
function boundedPinchDelta(delta) {
if (!Number.isFinite(delta) || delta === 0) return delta;
return (
Math.sign(delta) *
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
);
}
/**
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
*/
export function installTrackpadPinchZoom(
viewer,
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
) {
const controller = viewer?.scene?.screenSpaceCameraController;
const container = viewer?.container;
const canvas = viewer?.canvas;
if (!controller || !container || !canvas)
throw new TypeError('A complete Cesium viewer is required');
const originalZoomEventTypes = controller.zoomEventTypes;
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
? originalZoomEventTypes
: originalZoomEventTypes === undefined
? []
: [originalZoomEventTypes];
const alreadyHandlesControlWheel = zoomEventTypes.some(
(binding) =>
binding?.eventType === Cesium.CameraEventType.WHEEL &&
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
);
const configuredZoomEventTypes = alreadyHandlesControlWheel
? originalZoomEventTypes
: [
...zoomEventTypes,
{
eventType: Cesium.CameraEventType.WHEEL,
modifier: Cesium.KeyboardEventModifier.CTRL,
},
];
if (!alreadyHandlesControlWheel)
controller.zoomEventTypes = configuredZoomEventTypes;
const relayedEvents = new WeakSet();
const relayPinch = (event) => {
if (
!event.ctrlKey ||
relayedEvents.has(event) ||
event.deltaMode !== 0 ||
!Number.isFinite(event.deltaY) ||
event.deltaY === 0
)
return;
let relayed;
try {
relayed = createWheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: boundedPinchDelta(event.deltaY),
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: true,
bubbles: true,
cancelable: true,
view: globalThis.window,
});
} catch {
// The registered Ctrl+wheel binding can still consume the original.
return;
}
relayedEvents.add(relayed);
event.preventDefault();
event.stopPropagation();
canvas.dispatchEvent(relayed);
};
container.addEventListener('wheel', relayPinch, {
capture: true,
passive: false,
});
let active = true;
return () => {
if (!active) return;
active = false;
container.removeEventListener('wheel', relayPinch, true);
if (
!alreadyHandlesControlWheel &&
controller.zoomEventTypes === configuredZoomEventTypes
)
controller.zoomEventTypes = originalZoomEventTypes;
};
}
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
+16 -18
View File
@@ -1,36 +1,34 @@
import * as Cesium from 'cesium';
import { toRadians } from './geo/index.js';
import { cameraOf } from './render/rendererOf.js';
/**
* Set camera to Austin on load with a cinematic fly-in.
* @returns {Function} Cancels the pending or active startup flight.
*/
export function flyToAustin(viewer) {
const camera = cameraOf(viewer);
// Start from a high altitude, then fly down
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-90),
roll: 0.0,
},
camera.setView({
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
});
// Cinematic fly-in after a brief pause
let flight = null;
const timer = setTimeout(() => {
if (viewer.isDestroyed()) return;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
orientation: {
heading: Cesium.Math.toRadians(15),
pitch: Cesium.Math.toRadians(-30),
roll: 0.0,
flight = camera.flyTo(
{ lon: -97.7431, lat: 30.2672, height: 600 },
{
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
durationS: 4.0,
easing: 'cubicInOut',
},
duration: 4.0,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
);
}, 500);
return () => {
clearTimeout(timer);
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
// Only the fly-in: a flight the user started since is theirs.
if (!viewer.isDestroyed()) flight?.cancel();
};
}
+9 -5
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { GLOBE_VIEW } from './locations.js';
import { projectEarthDiscToViewport } from './celestialRing.js';
import assert from 'node:assert/strict';
@@ -146,13 +147,13 @@ function harness(
roll: radians(roll),
},
});
const viewer = {
const viewer = bindFakeCesiumCamera({
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
});
const target = Cesium.Cartesian3.fromDegrees(
longitudeDeg,
latitude,
@@ -288,9 +289,12 @@ test('replaced viewer releases the old tracking listener without duplicate subsc
true,
);
const id = activeCameraMotionId();
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
// As Cesium's setter does: the value changes, then the event fires.
first.viewer.trackedEntity = { id: 'stale-aircraft' };
first.viewer.trackedEntityChanged.raiseEvent(first.viewer.trackedEntity);
assert.equal(activeCameraMotionId(), id);
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
next.viewer.trackedEntity = { id: 'new-aircraft' };
next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
});
@@ -597,7 +601,7 @@ for (const mode of ['once', 'continuous']) {
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
const { viewer, camera, target } = harness(t);
viewer.trackedEntity = { id: 'tracked-aircraft' };
viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' };
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
const before = pose(camera);
const transform = Cesium.Matrix4.clone(camera.transform);
+81 -49
View File
@@ -14,8 +14,12 @@
* lifts the eye if it sits below a usable clearance. Heading and pitch are
* held, so the correction reads as the shot settling.
*/
import { onFollowChanged } from './data/followed.js';
import * as Cesium from 'cesium';
import { ownCameraArrival } from './data/cameraArrival.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { cameraFlightActive } from './render/cesium/internals.js';
/** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120;
@@ -24,6 +28,11 @@ const GUARD_ATTEMPTS = 8;
const GUARD_INTERVAL_MS = 600;
/** Ignore sub-metre noise rather than nudging the camera forever. */
const GUARD_EPSILON_M = 2;
/** Eye travel (m) and turn (cosine) that mean another owner moved the camera. */
const MOVED_M = 0.01;
const TURNED_COS = 1 - 1e-9;
const groundScratch = new Cesium.Cartesian3();
const probeScratch = new Cesium.Cartographic();
@@ -37,22 +46,60 @@ const probeScratch = new Cesium.Cartographic();
function sampleSurfaceM(scene, cells) {
let heightM = Number.NaN;
let sampled = 0;
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
if (!scene) return { heightM, sampled };
const heights = heightsFor(scene);
for (const cell of cells) {
try {
const height = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
);
if (!Number.isFinite(height)) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
} catch {
/* tiles not ready for this cell */
}
// A miss is a cell whose tiles are not ready.
const height = heights.now(
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
);
if (height === null) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
}
return { heightM, sampled };
}
/** The eye's ground point (height 0), its height, and which way it looks. */
function poseOf(camera) {
const carto =
camera?.positionWC && Cesium.Cartographic.fromCartesian(camera.positionWC);
if (!carto || !camera.directionWC || !camera.upWC) return null;
return {
ground: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude),
height: carto.height,
direction: Cesium.Cartesian3.clone(camera.directionWC),
up: Cesium.Cartesian3.clone(camera.upWC),
};
}
/**
* Whether another owner has moved the camera off `pose`: across the ground,
* down, or around. A pure rise is not one: Cesium's terrain collision lifts a
* buried eye straight up as tiles load, which is the case the guard exists
* for, so `pose` follows the highest eye seen and the guard keeps measuring.
*/
function moved(camera, pose) {
const carto = Cesium.Cartographic.fromCartesian(camera.positionWC);
if (!carto) return true;
const ground = Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
0,
undefined,
groundScratch,
);
if (
Cesium.Cartesian3.distance(ground, pose.ground) > MOVED_M ||
carto.height < pose.height - MOVED_M ||
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < TURNED_COS ||
Cesium.Cartesian3.dot(camera.upWC, pose.up) < TURNED_COS
)
return true;
pose.height = Math.max(pose.height, carto.height);
return false;
}
/**
* How far the camera must rise to clear the ground, given a measured surface.
* Pure so the policy is testable without a scene.
@@ -76,8 +123,10 @@ export function groundClearanceDeficitM(
* Watch an arrival and lift the camera once the real surface can be measured.
*
* Stops quietly on a clear view, exhausted attempts, the user taking the
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
* newer arrival owns the camera.
* controls (pointer or wheel on the canvas), `isStale()` reporting that a
* newer arrival owns the camera, or any other caller moving the camera or
* starting a flight in the meantime: the guard only ever corrects the pose it
* was started on.
* @param {Cesium.Viewer} viewer Live viewer.
* @param {{lat: number, lon: number}} at Destination being framed.
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
@@ -95,6 +144,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
return () => {};
// Without a readable pose the guard could not tell a later owner's move.
const arrival = poseOf(viewer.camera);
if (!arrival) return () => {};
let timer = null;
let done = false;
const canvas = viewer.scene.canvas;
@@ -112,32 +165,7 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
release();
};
release = ownCameraArrival(viewer, stop);
// Direct camera API callers (including duration-zero flights) must yield
// before moving the eye. Restore the exact method ownership on completion.
for (const name of [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
]) {
const camera = viewer.camera;
const original = camera[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
const wrapped = function (...args) {
stop();
return original.apply(this, args);
};
camera[name] = wrapped;
removers.push(() => {
if (camera[name] !== wrapped) return;
if (descriptor) Object.defineProperty(camera, name, descriptor);
else delete camera[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
if (removeTracking) removers.push(removeTracking);
removers.push(onFollowChanged(viewer, stop));
documentRef?.addEventListener?.('keydown', stop, true);
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
for (const type of ['pointerdown', 'wheel'])
@@ -147,8 +175,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
const attempt = () => {
if (done) return;
if (isStale() || viewer.isDestroyed?.()) return stop();
remaining -= 1;
const camera = viewer.camera;
// Another caller owns the camera now; lifting would undo its move.
if (cameraFlightActive(camera) || moved(camera, arrival)) return stop();
remaining -= 1;
const carto = camera.positionCartographic;
// The eye must clear both the framed target and the ground beneath it.
const below = {
@@ -167,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
);
if (deficit > 0) {
stop();
camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cameraOf(viewer).flyTo(
Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
carto.height + deficit,
),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
{
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
durationS: 0.9,
easing: 'cubicInOut',
},
duration: 0.9,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
);
try {
onLift?.(deficit);
} catch {
+146 -22
View File
@@ -3,6 +3,7 @@
// Arrival framing is computed before the destination's tiles exist, so it
// works from a predicted ground height. The guard measures the real surface
// after arrival and lifts the eye; these tests pin when it acts.
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
function fakeViewer({ cameraHeightM, surfaceM }) {
const flights = [];
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
return {
const position = Cesium.Cartographic.fromDegrees(
-97.7603,
30.3141,
cameraHeightM,
);
return bindFakeCesiumCamera({
flights,
scene: {
canvas: null,
@@ -59,12 +64,19 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
},
camera: {
positionCartographic: position,
positionWC: Cesium.Cartographic.toCartesian(position),
directionWC: new Cesium.Cartesian3(0, 0, -1),
upWC: new Cesium.Cartesian3(0, 1, 0),
heading: 0.5,
pitch: -0.55,
roll: 0,
flyTo: (options) => flights.push(options),
// As Cesium does: a flight is under way until it completes.
flyTo(options) {
flights.push(options);
this._currentFlight = {};
},
},
};
});
}
test('the guard lifts a buried arrival once the surface answers', async () => {
@@ -104,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
test('the guard leaves a clear view untouched', async () => {
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 1 },
);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0);
});
@@ -122,33 +138,76 @@ function handoffFixture(t) {
viewer.trackedEntityChanged = new Cesium.Event();
viewer.camera.cancelFlight = () => {};
viewer.camera.lookAtTransform = () => {};
viewer.camera.flyToBoundingSphere = () => {};
viewer.camera.setView = () => {};
viewer.camera.flyToBoundingSphere = function () {
this._currentFlight = {};
};
viewer.camera.setView = function () {
this.positionWC = Cesium.Cartesian3.add(
this.positionWC,
new Cesium.Cartesian3(0, 0, 50),
new Cesium.Cartesian3(),
);
};
const originalFly = viewer.camera.flyTo;
const navigation = new NavigationController({
viewer,
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
'rocketLaunchesLayer'].map((key) => [key, {}])),
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
isCockpitActive: () => false, showToast() {},
tracking: Object.fromEntries(
[
'flightsLayer',
'militaryFlightsLayer',
'satellitesLayer',
'aisLiveVesselsLayer',
'militaryAwarenessLayer',
'rocketLaunchesLayer',
].map((key) => [key, {}]),
),
cancelOrientation() {},
clearLocation() {},
cancelShareSelection() {},
getDataManager() {},
interruptCameraMotion() {},
stopOrbit() {},
isCockpitActive: () => false,
showToast() {},
});
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
return { viewer, navigation, originalFly };
}
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
for (const handoff of [
'Director',
'tracking',
'follow',
'keyboard',
'UI',
'flyTo',
'flyToBoundingSphere',
'setView',
'layer-disable',
'layer-destroy',
'app-stop',
]) {
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
const { viewer, navigation, originalFly } = handoffFixture(t);
const methods = [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
].map((name) => viewer.camera[name]);
if (handoff === 'Director') {
SceneDirector.prototype._claimCameraOwnership.call({
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn) },
viewer,
styleManager: {
runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn),
},
});
} else if (handoff === 'tracking') {
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
// As Cesium's setter does: the value changes, then the event fires.
viewer.trackedEntity = { id: 'contact' };
viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity);
} else if (handoff === 'follow' || handoff === 'UI') {
navigation._stampNavigation({ cancelPendingSelection: false });
} else if (handoff === 'keyboard') {
@@ -156,16 +215,81 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
} else if (handoff === 'app-stop') navigation.stop();
else if (handoff.startsWith('layer-')) {
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
lifecycle.register({
id: 'test',
init() {},
enable() {},
disable() {},
destroy() {},
});
await lifecycle.setEnabled('test', true);
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
if (handoff === 'layer-disable')
await lifecycle.setEnabled('test', false);
else await lifecycle.destroyLayer('test');
} else viewer.camera[handoff]({});
// A direct flyTo is itself one recorded flight; the guard adds none.
const priorFlights = viewer.flights.length;
t.mock.timers.tick(10000);
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
assert.equal(
viewer.flights.length,
priorFlights,
'the old probe cannot fly',
);
assert.equal(viewer.camera.flyTo, originalFly);
assert.deepEqual(
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
(name) => viewer.camera[name],
),
methods,
'the guard never replaces camera methods',
);
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
navigation.destroy();
});
}
test('the guard yields to a camera moved without any camera method', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// A cockpit or follow frame writes the pose directly.
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0, 'the moved view is not corrected');
});
test('Cesium lifting a buried eye straight up is not another owner', async () => {
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// Terrain collision raises the eye to just above the loaded ground.
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
viewer.camera.positionCartographic = raised;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
const lifted = Cesium.Cartographic.fromCartesian(
viewer.flights[0].destination,
);
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
});
test('another owner lowering the eye is not corrected', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
viewer.camera.positionCartographic = lowered;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(viewer.flights.length, 0);
});
+9 -9
View File
@@ -44,8 +44,8 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
ordered(enter, [
'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();',
'this.viewer.trackedEntity = undefined;',
'cameraOf(this.viewer).cancelFlight();',
'stopFollowing(this.viewer);',
], 'Cockpit takeover');
assert.match(
ui,
@@ -97,7 +97,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
// The transaction has exactly two camera-owner mutations, and each one
// stamps:
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity
// -> viewer.trackedEntityChanged -> _stampNavigation()
// -> onFollowChanged(viewer) -> _stampNavigation()
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
const transaction = body(
cockpitTracking,
@@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
assert.match(
ui,
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
/onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
'tracker handoff must stamp',
);
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
@@ -190,10 +190,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()',
'this.viewer.trackedEntity = undefined;',
'stopFollowing(this.viewer);',
"interruptCameraMotion('explicit-navigation')",
'this.viewer.camera.cancelFlight();',
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
'camera?.cancelFlight();',
'camera?.release();',
], 'follow release');
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
@@ -389,7 +389,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
}
const vesselClick = body(
vessels,
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'vessel click',
);
ordered(vesselClick, [
@@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
assert.match(vesselFocus, /requestWorldFocus\(\{/);
const fireClick = body(
firms,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'fire click',
);
ordered(fireClick, [
+42 -46
View File
@@ -18,6 +18,7 @@
* globe motion, opposite to the camera's travel around Earth.
*/
import { followedOf, isFollowing, onFollowChanged } from './data/followed.js';
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
@@ -29,6 +30,9 @@ import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { findCamera } from './render/rendererOf.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
@@ -559,25 +563,18 @@ function acquireCorridorFloor(state, takeWhole) {
export function probeMeshFloorM(scene, cells) {
const requested = Array.isArray(cells) ? cells.length : 0;
const empty = { heightM: Number.NaN, sampled: 0, requested };
if (!scene || typeof scene.sampleHeight !== 'function' || !requested)
return empty;
if (!scene || !requested) return empty;
const heights = heightsFor(scene);
let heightM = Number.NaN;
let sampled = 0;
for (const cell of cells) {
try {
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_probeCarto,
);
const height = scene.sampleHeight(carto);
if (!Number.isFinite(height)) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
} catch {
/* tiles not ready for this cell */
}
// A miss is a cell whose tiles are not ready.
const height = heights.now(
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto),
);
if (height === null) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
}
return { heightM, sampled, requested };
}
@@ -1010,7 +1007,7 @@ export function interruptCameraMotionIfActive(
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
findCamera(_viewer)?.release();
} catch {
/* teardown race */
}
@@ -1028,13 +1025,11 @@ function clearLookAt() {
*/
function levelCameraRoll() {
try {
const cam = _viewer?.camera;
if (!cam || typeof cam.setView !== 'function') return false;
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
if (Math.abs(roll) < 1e-5) return false;
cam.setView({
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
});
const camera = findCamera(_viewer);
if (!camera) return false;
const { heading, pitch, roll } = camera.pose();
if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
camera.setView({ orientation: { heading, pitch, roll: 0 } });
return true;
} catch {
return false; // teardown race
@@ -1047,7 +1042,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
// While an entity is TRACKED, the follow camera owns the reference frame —
// resetting the lookAt transform here flings the camera into space (field
// finding). Leave the transform to the tracker; it re-asserts every frame.
const tracking = Boolean(_viewer?.trackedEntity);
const tracking = isFollowing(_viewer);
const wasRoute = wasActive && _active.kind === 'route';
if (
wasActive &&
@@ -1087,6 +1082,9 @@ export function getActiveCameraMotion() {
return info;
}
/** A pan's direction as the camera move it makes; any other is down. */
const PAN_MOVES = Object.freeze({ left: 'left', right: 'right', up: 'up' });
function onTick() {
if (!_active || !_viewer) return;
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
@@ -1097,6 +1095,8 @@ function onTick() {
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
);
_active.lastMs = nowMs;
// Reads stay on Cesium's camera until the query port has them; every
// write goes through the camera port.
const cam = _viewer.camera;
const m = _active;
@@ -1112,6 +1112,7 @@ function onTick() {
}
try {
const camera = cameraOf(_viewer);
if (m.kind === 'orbit' && m.pending) {
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
// it"): keep trying for a ground target until the camera settles.
@@ -1161,7 +1162,7 @@ function onTick() {
} else {
m.hpr.heading += rate * dt;
}
cam.lookAt(m.target, m.hpr);
camera.lookAt(m.target, m.hpr);
} else if (m.kind === 'pan') {
const heightM = Math.max(50, cam.positionCartographic.height);
const step =
@@ -1169,10 +1170,7 @@ function onTick() {
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
m.lastScale = scale;
if (m.direction === 'left') cam.moveLeft(step);
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
camera.move(PAN_MOVES[m.direction] ?? 'down', step);
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
@@ -1193,7 +1191,7 @@ function onTick() {
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
isFollowing(_viewer) ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
@@ -1217,7 +1215,7 @@ function onTick() {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
camera.rotateAround(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
@@ -1226,10 +1224,8 @@ function onTick() {
interruptCameraMotion('tilt-clamp');
return;
}
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
camera.look(up ? 'up' : 'down', stepRad);
} else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
@@ -1238,7 +1234,7 @@ function onTick() {
// user left it rather than teleporting to an altitude derived from
// nothing. An interrupt during the arm is still instant.
if (frame.arming) return;
cam.setView({
camera.setView({
destination: frame.eye,
orientation: { direction: frame.direction, up: frame.up },
});
@@ -1418,8 +1414,8 @@ export function initCameraVerbs(
if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
_trackingRemover = onFollowChanged(viewer, (followed) => {
if (followed && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
@@ -1527,15 +1523,14 @@ export function moveCamera(args = {}, runNavigation = null) {
if (
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity
isFollowing(_viewer)
) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal
// until tracked-orbit rides the follow camera natively (roadmap).
const followed = followedOf(_viewer);
const label =
_viewer.trackedEntity?.name ||
_viewer.trackedEntity?.id ||
'the tracked target';
followed?.name || followed?.labelModel?.title || 'the tracked target';
return {
ok: false,
action: 'move_camera',
@@ -1618,9 +1613,10 @@ export function moveCamera(args = {}, runNavigation = null) {
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const camera = cameraOf(_viewer);
camera.cancelFlight();
camera.release();
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
@@ -1705,7 +1701,7 @@ export function moveCamera(args = {}, runNavigation = null) {
const preserveCameraFlight =
motion === 'orbit' &&
_viewer.scene.tweens.length > 0 &&
!_viewer.trackedEntity;
!isFollowing(_viewer);
return typeof runNavigation === 'function'
? runNavigation(start, { preserveCameraFlight })
: start();
+9 -8
View File
@@ -1,22 +1,23 @@
/**
* Activates the nearest CCTV record on layer enable and flies only when no
* aircraft tracker or cockpit camera owns the view.
* followed target or cockpit camera owns the view.
* @param {Object} options Layer-enable focus inputs.
* @param {*} options.trackedEntity Current Cesium tracked entity, if any.
* @param {*} options.followed What the camera follows, if anything
* (`followedOf`).
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
* @param {() => (string|null)} options.activate Nearest-camera activation.
* @param {(cameraId: string) => *} options.fly Optional camera flight.
* @returns {*} Activated camera ID for a held view, or the flight result.
*/
export function runCctvLayerEnableFocus({
trackedEntity = null,
followed = null,
cockpitActive = false,
activate,
fly,
} = {}) {
const cameraId = activate?.();
if (!cameraId) return false;
if (trackedEntity || cockpitActive) return cameraId;
if (followed || cockpitActive) return cameraId;
return fly?.(cameraId);
}
@@ -25,11 +26,11 @@ export function runCctvLayerEnableFocus({
* chain. Either observation suppresses the optional CCTV focus flight.
* @param {Object} [before={}] Ownership immediately before the await.
* @param {Object} [after={}] Ownership immediately after the await.
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
* @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership.
*/
function mergeCctvEnableOwnership(before = {}, after = {}) {
return {
trackedEntity: before.trackedEntity || after.trackedEntity || null,
followed: before.followed || after.followed || null,
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
};
}
@@ -60,7 +61,7 @@ export async function runCctvLayerEnableTransition({
const ownershipBefore = readOwnership?.() || {};
if (target === true) {
debug?.('[UI:CCTV] ownership before setEnabled await', {
trackedId: ownershipBefore.trackedEntity?.id ?? null,
trackedId: ownershipBefore.followed?.key ?? null,
cockpitActive: !!ownershipBefore.cockpitActive,
});
}
@@ -68,7 +69,7 @@ export async function runCctvLayerEnableTransition({
const ownershipAfter = readOwnership?.() || {};
if (target === true) {
debug?.('[UI:CCTV] ownership after setEnabled await', {
trackedId: ownershipAfter.trackedEntity?.id ?? null,
trackedId: ownershipAfter.followed?.key ?? null,
cockpitActive: !!ownershipAfter.cockpitActive,
});
}
+6 -6
View File
@@ -8,8 +8,8 @@ import {
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
for (const ownership of [
{ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false },
{ trackedEntity: null, cockpitActive: true },
{ followed: { key: 'tracked-plane' }, cockpitActive: false },
{ followed: null, cockpitActive: true },
]) {
const calls = [];
const result = runCctvLayerEnableFocus({
@@ -48,15 +48,15 @@ test('CCTV layer enable activates nearest without stealing a tracked or cockpit
});
test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => {
let trackedEntity = { id: 'tracked-plane' };
let followed = { key: 'tracked-plane' };
let flyCalls = 0;
const diagnostics = [];
const result = await runCctvLayerEnableTransition({
target: true,
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
readOwnership: () => ({ followed, cockpitActive: false }),
setEnabled: async () => {
await Promise.resolve();
trackedEntity = null;
followed = null;
},
shouldFocus: () => true,
activate: () => 'cam-near',
@@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
const result = await runCctvLayerEnableTransition({
target: false,
readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }),
readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }),
setEnabled: async (target) => transitions.push(target),
shouldFocus: () => true,
activate: () => 'cam-near',
+19 -28
View File
@@ -1,9 +1,6 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
import { worldClock, worldTimeFor } from './time/worldClock.js';
// The ring shows the sun and moon at the time the sky is drawn.
const skyTime = worldTimeFor('sky');
import { cameraOf } from './render/cesium/camera.js';
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -449,12 +446,6 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
// A jump in the world time moves the sun and moon at once.
this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate();
this._unsubscribeWorldTime = worldClock.subscribe(() => {
this._ephemerisDirty = true;
});
this.setEnabled(this.enabled);
}
@@ -586,30 +577,33 @@ export class CelestialRing {
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
const camera = cameraOf(this.viewer);
this._focusInProgress = true;
const finishFocus = () => {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
camera.flyTo(
Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude,
),
orientation: {
heading: this.viewer.camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
{
orientation: {
heading: camera.pose().heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
durationS: duration,
easing: 'cubicInOut',
onComplete: finishFocus,
onCancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: finishFocus,
cancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
);
return true;
}
@@ -811,8 +805,7 @@ export class CelestialRing {
return;
}
this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
@@ -901,8 +894,6 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
+8 -8
View File
@@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match(
ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
);
});
@@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
});
@@ -295,14 +295,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
});
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
test('the cockpit reads its aircraft from the layer the camera follows', () => {
const read = readAircraftInfo.toString();
assert.ok(read, 'readAircraftInfo is missing');
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there.
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
assert.match(read, /trackedId: followed\?\.key/);
// In cockpit mode the controller stops the camera following, so its own
// handle is the tracked identity there.
assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/);
});
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
@@ -1171,7 +1171,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = _adoptTrackedEntity.toString();
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
assert.match(match, /stopFollowing\(this\.viewer\);/);
assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match(
match,
+1 -1
View File
@@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) {
* Resolve which flight layer owns the cockpit's tracked aircraft.
*
* Both layers can briefly describe a tracked aircraft during a cross-layer
* handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the
* handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
* uses. Layer precedence remains the fallback when no normalized identity is
* available, so tracking paths that never stamp one keep working.
+1 -1
View File
@@ -365,7 +365,7 @@ test('tracked-identity resolution falls back to layer precedence', () => {
const civilian = { icao24: 'aaa077' };
const military = { icao24: 'ae01ce' };
// No stamped identity (or one that matches neither layer) keeps the historic
// civilian-first order so tracking paths without gevTrackedId still work.
// civilian-first order so tracking paths without an entity record trackedId still work.
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
+1 -1
View File
@@ -1,7 +1,7 @@
/*
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer
* precedence as the fallback when no normalized `gevTrackedId` is available,
* precedence as the fallback when the entity record has no normalized `trackedId`,
* so tracking paths that never stamp one keep working; `readAircraftInfo` is
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
* multi-step ENTRY TRANSACTION (adopt → enter → roll back).
+3 -2
View File
@@ -5,6 +5,7 @@ import {
enterCockpitWithTracking,
restoreAircraftTrackingOwner,
} from './cockpitTracking.js';
import { readEntityRecord, withEntityRecord } from './data/entityRecords.js';
/**
* Model the two production stamping seams that sit under this transaction:
@@ -22,7 +23,7 @@ function stampingWorld({ entryAllowed = true } = {}) {
layerId,
trackById(id) {
if (!trackable) return false;
world.trackedEntity = { gevTrackedId: `${layerId}:${id}` };
world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` });
stamp('trackedEntityChanged'); // the ui.js listener
return true;
},
@@ -80,7 +81,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', ()
// Adoption stamped, and the rollback re-track stamped again.
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
assert.equal(world.generation, 2);
assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce');
assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce');
});
test('a Cockpit entry that mutates nothing takes no camera authority', () => {
+17 -41
View File
@@ -229,30 +229,13 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a
// conservative floor rather than a guess. The world timeline's lift raises
// the dock, its trays and the credit together, so it moves no gap either.
// conservative floor rather than a guess.
const VETTED_VARS = new Set([
'--dock-pinned-stack-height',
'--dock-lower-pinned-height',
'--dock-location-pinned-height',
'--dock-presets-pinned-height',
'--world-timeline-lift',
]);
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 50, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
liftPx = lift;
try {
check(lift);
} finally {
liftPx = 0;
}
}
}
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() {
@@ -332,7 +315,6 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor
}
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -462,8 +444,7 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -536,18 +517,16 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = [];
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
}
}
}
});
}
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
});
@@ -587,24 +566,21 @@ test('the full-width context rail clears the required credit at every modelled v
'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
);
eachLift((lift) => {
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
});
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
}
}
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
});
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
`calc(2vh + 5rem + ${lift})`,
'calc(2vh + 5rem)',
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
);
});
+14 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or
@@ -64,9 +65,15 @@ export function sonarVertexSource(source, kind) {
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
// Icon sets move rows on the GPU, so their shader publishes the anchor itself.
const anchorSource =
kind === 'icon'
? 'iconAnchorClip'
: `czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz)`;
if (
!text.includes(attribute) ||
!text.includes(lowAttribute) ||
(kind === 'icon'
? !text.includes('iconAnchorClip')
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
!text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text)
)
@@ -83,7 +90,7 @@ void main() {
gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
vec4 anchor = ${anchorSource};
if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z;
@@ -108,7 +115,7 @@ void main() {
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main().
v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
}
`,
];
@@ -208,7 +215,9 @@ export function createCyberSonarGpu(scene, readFrame) {
? 'point'
: native.owner instanceof Cesium.BillboardCollection
? 'billboard'
: null;
: native.owner instanceof IconSetPrimitive
? 'icon'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested
+42 -14
View File
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
import { createIconSet } from './iconSet.js';
import {
aircraftMotionRegime,
createMotionRegime,
} from './aircraftMotionRegime.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_icons: createIconSet(),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
_cockpitNearContacts: new Set(),
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
floorAltitudeM: (value) => value,
});
flightState.records = records;
const parts = {
rendering: {
...rendering,
_iconKind: () => 'jet',
_syncModelToClass() {},
},
motion: {
_motionRegime: (history, info, renderTime) =>
aircraftMotionRegime(
history,
info,
renderTime,
{ speed: 'speedMps', track: 'track' },
createMotionRegime(),
),
},
};
const icons = createMilitaryIcons({
flightState,
services: { focus: { focusPassIsNeeded: () => false } },
parts,
});
const row = flightState._icons.add('abc123', {
layer: 'military',
id: 'abc123',
});
// The focus fade the fleet tick composes lives in its own column.
flightState._icons.setFocus(row, 0.65);
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
rendering: parts.rendering,
icons,
tracking: {},
queries: {},
});
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
assert.equal(flightState._icons.color(row).green < 0.5, red);
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
}
});
+451
View File
@@ -0,0 +1,451 @@
import * as Cesium from 'cesium';
import {
COURSE_HOLD_SPEED_MPS,
courseSlewCapDps,
norm180,
} from './motionModel.js';
import {
createIconSet,
ICON_CULL_LIFT,
ICON_RECEDES,
ICON_ROTATES,
} from './iconSet.js';
import {
segmentCourse,
writeArcSegment,
writeCourseGlide,
writeHoldSegment,
writeLerpSegment,
} from './iconSegment.js';
import { IconSetPrimitive } from './iconSetPrimitive.js';
import { iconAtlas } from './iconAtlas.js';
import {
aircraftIcon,
FLEET_ICON_PX,
TRACKED_ICON_PX,
} from './aircraftIcons.js';
import { cockpitContactDotImage } from './cockpitContactDot.js';
import { horizonOccluder } from './iconOrientation.js';
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
const COCKPIT_DOT_PX = 16;
/** Fleet glyphs keep the established close-range 3× scale. */
const NORMAL_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
1000,
3.0,
8000000,
0.5,
);
const COCKPIT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
1000,
1.15,
8000000,
0.65,
);
/** Contacts drawn below this height (m) are judged for the horizon a little
* above the ellipsoid (ICON_CULL_LIFT). */
const CULL_LIFT_BELOW_M = 10;
/**
* An aircraft layer's contacts as one icon set: each row's motion segment
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
* moves every untracked contact between polls with no per-frame CPU pass.
*
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
*
* @param {object} options
* @param {object} options.state The layer's mutable state.
* @param {object} options.services The layer's services (`focus`).
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
* @param {string} options.layerId Pick and focus owner id.
* @param {number} options.renderDelaySec How far behind real time contacts draw.
* @param {number} options.courseMaxDps The fleet's course slew cap.
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
* else writes this row's segment (it is not planned).
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
* planned afresh.
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
* per-contact state when a contact goes.
*/
export function createAircraftIcons({
state,
services,
parts,
layerId,
renderDelaySec,
courseMaxDps,
cockpitContactSizePx,
holdsPlan = () => false,
beforeSettle = () => {},
afterSettle = () => {},
onForget = () => {},
}) {
const { focusPassIsNeeded } = services.focus;
const scratchTime = new Cesium.JulianDate();
const scratchPosition = new Cesium.Cartesian3();
const scratchCull = new Cesium.Cartesian3();
const scratchScaleByDistance = new Cesium.NearFarScalar();
/** The delayed instant every untracked contact is drawn at. */
function _displayTime(result = scratchTime) {
return Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(result),
-renderDelaySec,
result,
);
}
function _displayTau() {
return state._icons.seconds(_displayTime());
}
/** Create the set and its primitive for one layer lifetime. */
function _createIcons(viewer) {
state._icons = createIconSet({ epoch: _displayTime() });
state._iconPrimitive = new IconSetPrimitive({
set: state._icons,
renderTime: () => _displayTime(state._scratchIconRenderTime),
});
viewer.scene.primitives.add(state._iconPrimitive);
return state._iconPrimitive;
}
/** Where a contact is drawn right now, or null when it has no row. */
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row < 0 ? null : set.position(row, _displayTau(), result);
}
/** The nose direction a contact is drawn with right now. */
function _iconCourse(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row < 0 ? null : set.course(row, _displayTau());
}
/** Whether the layer draws this contact's icon (not tracked, not model-owned). */
function _iconShown(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row >= 0 && set.shown(row);
}
/** The camera's horizon test, or null when there is no placed camera. */
function _occluder() {
const camera = state._viewer?.camera;
return camera?.positionWC ? horizonOccluder(camera) : null;
}
/** Whether a drawn row at `position` clears the camera's horizon, judged
* exactly as the icon shader judges it. No camera, no horizon. */
function _aboveHorizon(row, position, occluder) {
return (
!occluder ||
occluder.isPointVisible(
state._icons.cullPoint(row, position, scratchCull),
)
);
}
/**
* Write the segment the layer's dead reckoning would follow from now on:
* interpolation between the bracketing fixes, the warm-up arc back from the
* oldest fix, or the coast arc past the newest. The displayed nose glides
* from where it is onto the new course at the fleet's slew cap; a hovering
* contact keeps its nose. The row expires when the regime would change.
* @param {string} icao24
* @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when
* the row's own segment is not what was on screen (tracking handoff).
*/
function _planIcon(icao24, { fromCourseDeg } = {}) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0 || holdsPlan(icao24)) return;
const history = state._positionHistory.get(icao24);
if (!history?.length) return;
const info = state.records.data.get(icao24);
const renderTime = _displayTime();
const tau = set.seconds(renderTime);
const seg = set.segments;
const shownCourse =
fromCourseDeg ??
(set.expires(row) === -Infinity ? null : set.course(row, tau));
const regime = parts.motion._motionRegime(history, info, renderTime);
let speed;
let expiresAt = Infinity;
if (regime.kind === 'between') {
const { a, b, span, trackFrom, trackTo, chordCourse } = regime;
speed = regime.speed;
const blend = chordCourse == null ? 0 : regime.blend;
if (span > 0) {
writeLerpSegment(seg, row, {
from: a.position,
to: b.position,
t0: set.seconds(a.time),
t1: set.seconds(b.time),
course0Deg:
trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom),
courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend),
});
} else {
writeHoldSegment(
seg,
row,
b.position,
trackTo + blend * norm180((chordCourse ?? 0) - trackTo),
);
}
expiresAt = set.seconds(b.time);
} else {
const { fix, heading } = regime;
speed = regime.speed;
const t0 = set.seconds(fix.time);
if (speed === 0) {
writeHoldSegment(seg, row, fix.position, heading);
} else {
writeArcSegment(seg, row, {
fix: fix.position,
t0,
minDt: regime.minDt,
maxDt: regime.maxDt,
speedMps: speed,
trackDeg: heading,
turnRateDps: regime.turnRateDps,
});
}
// A warm-up's arc ends at its fix; a coast's runs to its limit.
if (regime.maxDt === 0) expiresAt = t0;
}
// Below hover speed the nose holds what is on screen (or, for a new row,
// where this segment starts); above it, it glides on at the slew cap.
const hold = speed < COURSE_HOLD_SPEED_MPS;
if (hold || shownCourse != null) {
writeCourseGlide(
seg,
row,
shownCourse ?? segmentCourse(seg, row, tau),
tau,
hold ? 0 : courseSlewCapDps(speed, courseMaxDps),
);
}
set.segmentWritten(row, expiresAt);
}
/** Re-plan every row whose regime ended before `tau`. */
function _replanExpired(tau) {
const set = state._icons;
const due = [];
for (let row = 0; row < set.count; row++)
if (set.expires(row) <= tau) due.push(set.idAt(row));
for (const icao24 of due) _planIcon(icao24);
}
/**
* Advance focus de-emphasis for every drawn row (and every row a fleet model
* draws for, whose alpha reads it), only while a focus target exists or some
* row is still relaxing back to full emphasis.
*/
function _focusIcons(tau, scene, camera, nowMs, target) {
if (!focusPassIsNeeded(target, state._iconFocusActive)) return;
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
services.focus;
const set = state._icons;
let active = 0;
for (let row = 0; row < set.count; row++) {
if (!set.shown(row) && !state._models.has(set.idAt(row))) continue;
const position = set.position(row, tau, scratchPosition);
const distanceScale = nearFarScalarValueAtDistance(
set.scaleByDistance(row, scratchScaleByDistance),
Cesium.Cartesian3.distance(camera.positionWC, position),
);
const half = set.scale(row) * distanceScale * 0.5;
const focus = advanceProjectedSpriteFocus(
set.metaAt(row),
position,
scene,
camera,
nowMs,
target,
undefined,
set.width(row) * half,
set.height(row) * half,
);
set.setFocus(row, focus.factor);
if (focus.active || focus.transitioning) active++;
}
state._iconFocusActive = active;
}
/** Image, size, scale, tint and freshness for one row under the current
* normal/cockpit contract. Motion and rotation stay with the segment. */
function _applyIconPresentation(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
const rendering = parts.rendering;
const info = state.records.data.get(icao24);
const tracked = icao24 === state._trackedIcao;
const cockpitDot =
state._cockpitContactMode &&
!tracked &&
!state._cockpitNearContacts.has(icao24);
if (cockpitDot) {
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
set.setImage(row, dot, dot);
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
set.setScale(row, 1);
set.setScaleByDistance(row, COCKPIT_SCALE_BY_DISTANCE);
set.setColor(row, rendering._cockpitContactColor(icao24, info));
set.setFlag(row, ICON_ROTATES, false);
} else {
set.setImage(
row,
iconAtlas.slot(
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
FLEET_ICON_PX,
),
iconAtlas.slot(
aircraftIcon(
rendering._iconKind(icao24, info?.klass),
TRACKED_ICON_PX,
),
TRACKED_ICON_PX,
),
);
const size = tracked ? 24 : 20;
set.setSize(row, size, size);
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
set.setScaleByDistance(row, NORMAL_SCALE_BY_DISTANCE);
set.setColor(row, rendering._fleetBillboardColor(icao24, info));
set.setFlag(row, ICON_ROTATES, true);
}
set.setFlag(row, ICON_RECEDES, true);
set.setFlag(
row,
ICON_CULL_LIFT,
Number.isFinite(info?.renderAltitudeM) &&
info.renderAltitudeM < CULL_LIFT_BELOW_M,
);
set.setAlpha(row, state.records.missingPolls.get(icao24) ? 0.45 : 1);
}
/** Add a row for a new contact, hidden while it is the tracked one. */
function _addIcon(icao24) {
const set = state._icons;
const row = set.add(icao24, { layer: layerId, id: icao24 });
_applyIconPresentation(icao24);
_settleIcon(icao24);
set.setShown(row, icao24 !== state._trackedIcao);
return row;
}
/**
* Plan a row afresh, with the layer's own settle work on either side, and
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
* back dimmed after the pass went idle, so it is counted to run the pass.
* @param {string} icao24
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
*/
function _settleIcon(icao24, options) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
beforeSettle(icao24);
if (set.focus(row) < 1) state._iconFocusActive += 1;
_planIcon(icao24, options);
afterSettle(icao24);
}
/** Drop a contact and everything the layer keeps for it. */
function _forgetContact(icao24) {
state._icons?.remove(icao24);
parts.rendering._releaseModel(icao24);
state.records.forget(icao24);
state._positionHistory.delete(icao24);
state._displayCourse.delete(icao24);
state._groundSnap.forget(icao24);
onForget(icao24);
}
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
function _iconIds() {
const set = state._icons;
const ids = new Array(set?.count ?? 0);
for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row);
return ids;
}
return {
_displayTime,
_displayTau,
_createIcons,
_iconPosition,
_iconCourse,
_iconShown,
_occluder,
_aboveHorizon,
_planIcon,
_settleIcon,
_replanExpired,
_focusIcons,
_applyIconPresentation,
_addIcon,
_forgetContact,
_iconIds,
};
}
/**
* Test seams over an aircraft layer's icon rows, shared by the flights and
* military `testing` parts.
* @param {object} state The layer state holding `_icons`.
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
* @param {string} layerId
*/
export function aircraftIconTestSeams(state, parts, layerId) {
/** Give a test contact an icon row held at `icon.position`, shown as
* `icon.show` says (hidden by default while tracked). */
function _seedIconForTest(icao24, icon, tracked) {
const set = state._icons;
const row = set.add(icao24, { layer: layerId, id: icao24 });
if (icon?.position) writeHoldSegment(set.segments, row, icon.position, 0);
set.segmentWritten(row);
set.setShown(row, icon?.show ?? !tracked);
return row;
}
/** Where a contact's icon is drawn now and whether the layer shows it. */
function _iconForTest(icao24) {
const position = parts.icons._iconPosition(icao24);
return position
? {
position,
shown: parts.icons._iconShown(icao24),
course: parts.icons._iconCourse(icao24),
}
: null;
}
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
function _setIconForTest(icao24, { shown, position } = {}) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
if (shown !== undefined) set.setShown(row, shown);
if (position) {
writeHoldSegment(set.segments, row, position, 0);
set.segmentWritten(row);
}
}
return { _seedIconForTest, _iconForTest, _setIconForTest };
}
+2
View File
@@ -252,6 +252,8 @@ const _b64 = (s) =>
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
const FLEET_RASTER_PX = 64;
/** Raster size of the default fleet glyph. */
export const FLEET_ICON_PX = FLEET_RASTER_PX;
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
* it stays crisp at its biggest on-screen sizes. */
+888
View File
@@ -0,0 +1,888 @@
import * as Cesium from 'cesium';
import { selectModelEligible } from './modelEligibility.js';
import { horizonOccluder } from './iconOrientation.js';
import { aircraftTreatmentAlpha } from './aircraftRecession.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
import {
createModelRecord,
modelIsRendering,
placeModel,
showModel,
} from './modelRecord.js';
/**
* An aircraft layer's 3D models: the fleet regime and its gap-proof handoff
* from the icon set (`createAircraftIcons`), the standalone tracked model, and
* the ~12 Hz fleet tick that drives both. Shared by the flights and military
* layers, which differ only in the options below.
* @param {object} options
* @param {object} options.state The layer state.
* @param {object} options.services
* @param {object} options.parts The layer's parts; `icons`, `motion` and
* `tracking` are read at call time.
* @param {(url: string) => string} options.resolveAsset
* @param {string} options.logLabel Console prefix, e.g. `Data:Flights`.
* @param {object} options.policy The layer's model budgets, radii, pixel
* bounds, heading offset, IR reload batch and fleet tick interval.
* @param {(klass: string) => object} options.modelSpec The class's GLB, scale,
* geometry and tint blend; `headingOffsetDeg` overrides the policy's.
* @param {(icao24: string) => Cesium.Color} options.modelColor Model tint,
* including the tracked contact's.
* @param {(icao24: string) => void} [options.onModelEligible] Runs when a
* contact is about to render in 3D.
* @param {(renderTime: Cesium.JulianDate, tau: number, nowMs: number,
* camera: Cesium.Camera) => void} [options.afterReplan] The layer's own pass
* in the fleet tick, after expired rows are re-planned.
*/
export function createAircraftModels({
state,
services,
parts,
resolveAsset,
logLabel,
policy,
modelSpec: _modelSpec,
modelColor: _modelColor,
onModelEligible = () => {},
afterReplan = () => {},
}) {
const { isTr3b } = services.aircraftPresentation;
const { trackedModelScaleForPixelCap } = services.camera;
const { focusNowMs, getFocusTarget } = services.focus;
const { applyAircraftModelTreatment } = services.recession;
/** The model heading offset a class's GLB needs. */
const _headingOffsetDeg = (klass) =>
_modelSpec(klass).headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG;
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
* contact does not route through here: it is one model, it is what the camera is aimed at,
* and it takes its own default-on, hysteretic zoom regime
* (`_trackedModelRegimeActive`). */
function _modelRegimeActive() {
if (!state._models3dEnabled) return false;
const h = state._viewer?.camera?.positionCartographic?.height ?? Infinity;
return h < policy.MODEL_ALT_CEIL_M;
}
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
* SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses
* at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
function _modelCap() {
const mapCap =
state._models3dMode === 'all' ? policy.MODEL_MAX_ALL : policy.MODEL_MAX;
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is
// already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal
// retraversal, the cloud pass) and every model is its own draw call.
return state._cockpitContactMode
? Math.min(policy.COCKPIT_MODEL_MAX, mapCap)
: mapCap;
}
/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */
function _modelAddDistM() {
return state._models3dMode === 'all'
? policy.MODEL_ALL_ADD_M
: policy.MODEL_PROX_ADD_M;
}
/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */
function _modelKeepDistM() {
return state._models3dMode === 'all'
? policy.MODEL_ALL_KEEP_M
: policy.MODEL_PROX_KEEP_M;
}
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
* `result` (a shared scratch by default) and returns it. Push it with `placeModel`, which copies
* it into the model's record and its own matrix, so models never share one mutable matrix
* object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
function _modelMatrix(
pos,
headingDeg,
result = state._scratchModelMtx,
offsetDeg = policy.MODEL_HEADING_OFFSET_DEG,
) {
state._scratchModelHpr.heading = Cesium.Math.toRadians(
(headingDeg || 0) + offsetDeg,
);
state._scratchModelHpr.pitch = 0;
state._scratchModelHpr.roll = 0;
return Cesium.Transforms.headingPitchRollToFixedFrame(
pos,
state._scratchModelHpr,
Cesium.Ellipsoid.WGS84,
undefined,
result,
);
}
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
* matches picked-object IDs, and every icon AND model in this layer carries its
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
* object itself. Built lazily — only when a sample actually fires (one-shot). */
function _groundSampleExclusions() {
const out = parts.icons._iconIds();
if (state._trackedEntity) out.push(state._trackedEntity);
return out;
}
/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position
* verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near
* the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports,
* hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested
* model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides
* a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js,
* CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a
* bounded last-known and resamples), plus
* the belly offset so it sits on its gear rather than sinking to the model-origin
* fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss)
* there is no safe depth-tested placement at all: this returns null, the caller
* keeps the 2D billboard visible and the model hidden, and it retries later. Once
* a contact has resolved once, a later outage holds that measurement inside
* groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D
* because a resample is mid-backoff. */
function _modelDisplayPosition(icao24, pos, result) {
const meta = state.records.data.get(icao24);
if (!meta || !meta.onGround) return pos;
const h = state._groundSnap.heightFor(
state._viewer,
icao24,
pos,
_groundSampleExclusions,
);
if (h == null) return null;
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
state._scratchGroundCarto,
);
carto.height = h + _modelSpec(meta.klass).bellyM;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
carto.height,
Cesium.Ellipsoid.WGS84,
result,
);
}
/** Atomically hand one fleet contact from its icon to a safely placed,
* ready 3D model. Missing/loading models and unresolved terrain always leave
* the icon owning the visual, so no render frame can hide both.
* `beforeShow` runs only on the committing path — the per-tick model treatment
* belongs to a model that is about to draw, not to one still waiting. */
function _driveFleetModelHandoff(
icao24,
record,
row,
pos,
course,
beforeShow,
) {
const icons = state._icons;
if (!record) {
icons.setShown(row, true);
return false;
}
const displayPos = _modelDisplayPosition(
icao24,
pos,
state._scratchGroundPos,
);
if (!displayPos) {
showModel(record, false); // no ground evidence → nothing safe to place a depth-tested model at
icons.setShown(row, true);
return false;
}
// The matrix is written BEFORE the readiness test on purpose: a model can flip
// `ready` during scene update after this tick, and a first rendered frame on a
// stale load-start matrix is the one-frame jump this ordering prevents.
placeModel(
record,
_modelMatrix(
displayPos,
course,
undefined,
_headingOffsetDeg(state.records.data.get(icao24)?.klass),
),
);
if (!record.model.ready) {
showModel(record, false); // not loaded yet → keep the 2D icon, no half-model flash
icons.setShown(row, true);
return false;
}
beforeShow?.();
if (!record.show) showModel(record, true);
icons.setShown(row, false); // hand off ONLY once the model renders
return true;
}
/**
* Whether a 3D model — not the billboard — is what the user actually SEES for
* this contact, and therefore whether the display floor should stand aside.
*
* Model EXISTENCE is not ownership. A fleet model that is still loading, or has
* no resolved ground to stand on, stays hidden while `bb.show` stays true (the
* gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model
* is retained but hidden whenever the model regime is off — 3D disabled, camera
* zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore
* suppressed the clamp in exactly the states where the BILLBOARD is the visual,
* putting the burial straight back.
*
* Cesium's own default `show === true` is the reason admission clears it
* explicitly: a fleet model registered in `_models` the instant its glTF resolves
* would otherwise claim ownership — at the identity matrix — for the frames
* between admission and the next fleet tick, while the BILLBOARD was still the
* visual. Ownership means actually rendering, and every site that sets `show`
* true does so only after committing a matrix.
*
* The two halves:
* - Fleet: the rendering test alone. Safe for the ground snap either way,
* because a fleet model is positioned from the RAW dead-reckon, not from
* the billboard.
* - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked
* model is fed from `_trackedDisplayPosition`, so once it is live the clamp
* must stand aside: two different chains would otherwise be deciding one
* contact's ground, and the billboard's is the one the operator is not
* looking at (T7). The regime check is what makes a regime
* flip take effect on the same frame rather than waiting for
* `_updateTrackedModel` to clear `show`; the rendering test is what keeps a
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {import('./modelRecord.js').ModelRecord|null} [record] Resolved
* fleet model record; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24, record) {
if (icao24 === state._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
modelIsRendering(state._trackedModel)
);
}
return modelIsRendering(
record === undefined ? state._models.get(icao24) : record,
);
}
// A model is the visual only once it actually draws, loaded AND shown
// (`modelIsRendering`). `show` is sufficient evidence of a safe placement
// because only ONE site ever sets it true for a fleet model
// (`_driveFleetModelHandoff`, after the matrix is committed) and one for the
// tracked model (`_updateTrackedModel`, likewise); everything else —
// admission, an unresolved ground, a not-yet-ready glTF, the limb cull, a
// regime exit — only ever clears it.
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
* differ by scale — airliner vs quadjet both ship airplane.glb). */
const _specKeyFor = (klass) => {
const spec = _modelSpec(klass);
return `${spec.url}@${spec.scale}`;
};
/** Class-change model sync (enrichment AND poll-path klass updates): when the
* aircraft's live model or in-flight load no longer matches its class's spec,
* drop it so the eligibility pass reloads the right asset at the right scale.
* Gap-proof: the fleet billboard is re-shown BEFORE the release so the
* contact never goes invisible for the tick gap; _releaseModel's generation
* bump also invalidates any pending load. The tracked standalone model gets
* the same rule (its billboard entity is always the fallback visual). */
function _syncModelToClass(icao24) {
const key = _specKeyFor(state.records.data.get(icao24)?.klass);
const current = state._models.get(icao24);
if (
(current && current.specKey !== key) ||
(!current && state._modelPending.has(icao24))
) {
_releaseModel(icao24);
}
if (
icao24 === state._trackedIcao &&
state._trackedModel &&
state._trackedModel.specKey !== key
) {
_releaseTrackedModel();
}
}
function _reloadModelsForIrBoost() {
state._irReloadQueue = [...state._models.keys()];
for (const icao of state._modelPending) {
if (!state._models.has(icao))
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
}
_releaseTrackedModel();
}
function _drainIrReloadQueue() {
if (!state._irReloadQueue) return;
const batch = state._irReloadQueue.splice(0, policy.IR_RELOAD_BATCH);
for (const icao of batch) {
const record = state._models.get(icao);
if (!record || record.irBoost === state._irBoost) continue; // already right state
_releaseModel(icao);
}
if (state._irReloadQueue.length === 0) state._irReloadQueue = null;
}
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
async function _ensureModel(icao24) {
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
// tick skips it, so a model here would be orphaned + double-rendered.
if (icao24 === state._trackedIcao) return;
onModelEligible(icao24);
if (state._models.has(icao24) || state._modelPending.has(icao24)) return;
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
// (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL.
if (state._models.size + state._modelPending.size >= _modelCap()) return;
const epoch = state._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
const gen = state._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
state._modelPending.add(icao24);
let model = null;
// Spec identity captured at load START — if enrichment reclassifies the
// aircraft mid-load, the post-await admission below rejects the stale asset.
// Boost state likewise: the creation options bake it in, so a mid-load
// toggle must reject too (the reload queue only covers ADMITTED models).
const specKey = _specKeyFor(state.records.data.get(icao24)?.klass);
const loadIrBoost = state._irBoost;
try {
const spec = _modelSpec(state.records.data.get(icao24)?.klass);
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: policy.MODEL_MIN_PX,
scale: spec.scale,
color: state._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// Launch presentation keeps the code-side tint dominant for every approved
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: state._irBoost ? 1.0 : spec.blendAmount,
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
id: icao24, // so scene.pick returns the icao for click-to-track
});
} catch {
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
// (a destroy/re-init may have swapped the globals while this load was in flight).
if (epoch === state._modelEpoch) {
state._modelPending.delete(icao24);
_cleanupModelGen(icao24);
}
return;
}
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
if (epoch !== state._modelEpoch) {
try {
model.destroy();
} catch {
/* gone */
}
return;
}
state._modelPending.delete(icao24);
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
// Recheck the shared Display 3D toggle and altitude ceiling after the async
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
const stale =
(state._modelGen.get(icao24) || 0) !== gen ||
!_modelRegimeActive() ||
!state._modelCollection ||
state._modelCollection.isDestroyed() ||
!state.records.data.has(icao24) ||
icao24 === state._trackedIcao ||
state._models.has(icao24) ||
state._models.size >= _modelCap() ||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
_specKeyFor(state.records.data.get(icao24)?.klass) !== specKey ||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
state._irBoost !== loadIrBoost;
if (stale) {
try {
model.destroy();
} catch {
/* already gone */
}
_cleanupModelGen(icao24); // bound the map
return;
}
// Keep the pick identity explicit on the resolved primitive. This also
// protects injected/custom loaders that do not copy the creation option.
model.id = icao24;
const record = createModelRecord(model, {
specKey, // class-change sync compares against this
irBoost: loadIrBoost, // boost-flip reload queue compares against this
});
// Admitted, not yet the visual. Cesium's default is show=true, which would let
// an unplaced primitive claim ownership from the icon for the frames
// between admission and the next fleet tick (and draw at the identity matrix,
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
showModel(record, false);
state._modelCollection.add(model);
state._models.set(icao24, record);
state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
function _releaseModel(icao24) {
const record = state._models.get(icao24);
const pending = state._modelPending.has(icao24);
// Only bump the generation when there's something to invalidate (an in-flight load or a
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
if (record || pending) {
state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1);
}
if (record) {
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
try {
state._modelCollection.remove(record.model);
} catch {
/* gone */
}
}
state._models.delete(icao24);
_showReleasedIcon(icao24);
}
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
// the resolving load's gen-check still rejects it.
_cleanupModelGen(icao24);
}
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
function _cleanupModelGen(icao24) {
if (!state._modelPending.has(icao24) && !state._models.has(icao24))
state._modelGen.delete(icao24);
}
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
function _releaseModels() {
state._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
for (const icao of state._modelPending)
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
for (const record of state._models.values()) {
try {
state._modelCollection.remove(record.model);
} catch {
/* gone */
}
}
}
for (const icao24 of state._models.keys()) _showReleasedIcon(icao24);
state._models.clear();
}
/** A released fleet model hands the visual straight back to its icon (the
* tracked contact's entity keeps its own). */
function _showReleasedIcon(icao24) {
const icons = state._icons;
const row = icons?.row(icao24) ?? -1;
if (row >= 0 && icao24 !== state._trackedIcao) icons.setShown(row, true);
}
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
function _releaseTrackedModel() {
state._trackedModelGen++;
state._trackedModelLoading = false;
if (state._trackedModel) {
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
try {
state._modelCollection.remove(state._trackedModel.model);
} catch {
/* gone */
}
}
state._trackedModel = null;
}
}
/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet
* cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
function _updateTrackedModel() {
const active =
state._trackedIcao &&
parts.tracking._trackedModelRegimeActive() &&
state._modelCollection &&
!state._modelCollection.isDestroyed();
if (!active) {
if (state._trackedModel) showModel(state._trackedModel, false);
return;
}
// Ask the frame-cached source directly. If the entity callback already ran,
// this is a no-op; if model loading completed between phases, it establishes
// this frame's single sample before the model renders. Camera, detection, and
// readout consumers then reuse that exact cached position.
const pos =
parts.motion._trackedDisplayPosition(state._trackedIcao) ||
parts.icons._iconPosition(state._trackedIcao);
if (!pos) {
if (state._trackedModel) showModel(state._trackedModel, false);
return;
}
if (
!state._trackedModel &&
!state._trackedModelLoading &&
parts.tracking._trackedModelLoadAllowed()
) {
state._trackedModelLoading = true;
const gen = state._trackedModelGen;
const trackedSpec = _modelSpec(
state.records.data.get(state._trackedIcao)?.klass,
);
const trackedKey = _specKeyFor(
state.records.data.get(state._trackedIcao)?.klass,
);
const trackedIrBoost = state._irBoost;
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: policy.TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: state._irBoost
? Cesium.Color.WHITE
: _modelColor(state._trackedIcao),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// The tracked aircraft uses the same dominant light tint as the fleet;
// IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: state._irBoost ? 1.0 : trackedSpec.blendAmount,
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
// Pick id (H1): without it, clicking the very plane being tracked read as
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
// deselect. With the icao, the click handler recognizes it as ours.
id: state._trackedIcao,
})
.then((m) => {
// Untracked / re-tracked / torn down during the load → drop it.
if (
gen !== state._trackedModelGen ||
!state._modelCollection ||
state._modelCollection.isDestroyed()
) {
try {
m.destroy();
} catch {
/* gone */
}
return;
}
// Class reclassified OR boost flipped mid-load (no release ran —
// _trackedModel was still null): drop the stale asset; driver reloads.
if (
_specKeyFor(state.records.data.get(state._trackedIcao)?.klass) !==
trackedKey ||
state._irBoost !== trackedIrBoost
) {
try {
m.destroy();
} catch {
/* gone */
}
state._trackedModelLoading = false;
return;
}
// Assign after resolution as well as in the creation options so the
// standalone primitive always exposes the tracked aircraft pick id.
m.id = state._trackedIcao;
const record = createModelRecord(m, {
specKey: trackedKey, // class-change sync compares against this
irBoost: trackedIrBoost,
});
showModel(record, false); // admitted, not yet the visual — the driver shows it once placed
// Seed the world transform before the primitive enters the scene. A model
// can become ready+shown between render phases; leaving Cesium's identity
// default here produces a one-frame jump to the Earth's center.
const currentPos =
parts.motion._trackedDisplayCached() ||
parts.icons._iconPosition(state._trackedIcao);
if (currentPos) {
const displayPos = _modelDisplayPosition(
state._trackedIcao,
currentPos,
state._scratchGroundPos,
);
if (displayPos)
placeModel(
record,
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
undefined,
trackedSpec.headingOffsetDeg ??
policy.MODEL_HEADING_OFFSET_DEG,
),
);
}
state._trackedModel = record;
state._trackedModelLoading = false;
// A good load retires this selection's failure budget — a contact that
// recovers after a transient blip is not one attempt from giving up.
state._trackedModelFailIcao = null;
state._trackedModelFailCount = 0;
state._trackedModelRetryAtMs = 0;
state._modelCollection.add(m);
state._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
})
.catch((err) => {
if (gen !== state._trackedModelGen) return; // superseded load — not this selection's failure
state._trackedModelLoading = false;
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
});
return; // billboard carries the visual until the model is ready
}
if (state._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
// here would let that first rendered frame use a stale load-start matrix.
const displayPos = _modelDisplayPosition(
state._trackedIcao,
pos,
state._scratchGroundPos,
);
if (!displayPos) {
showModel(state._trackedModel, false); // no ground evidence → the icon carries it
return;
}
const spec = _modelSpec(
state.records.data.get(state._trackedIcao)?.klass,
);
placeModel(
state._trackedModel,
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
undefined,
spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG,
),
);
if (!state._trackedModel.model.ready) return;
state._trackedModel.model.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
nativeRadiusM: spec.nativeRadiusM,
rangeM: Cesium.Cartesian3.distance(
state._viewer.camera.positionWC,
displayPos,
),
viewportHeightPx: state._viewer.scene.canvas.clientHeight,
fovyRad: state._viewer.camera.frustum.fovy,
maximumPixelSize: policy.TRACKED_MODEL_MAX_PX,
});
showModel(state._trackedModel, true);
}
}
/**
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
* the two-tier glyph swap all run on the GPU from each row's segment, so this
* only re-plans rows whose regime ended, runs the layer's own pass after
* that (flights floors grounded contacts), advances focus de-emphasis while
* a target exists, and drives the 3D-model handoff inside the model regime.
* Driven by scene.preRender.
* @returns {void}
*/
function _fleetTick() {
const icons = state._icons;
if (!state._viewer || !icons || !icons.show) return;
const scene = state._viewer.scene;
const camera = state._viewer.camera;
const nowMs = focusNowMs(Date.now());
if (nowMs - state._lastFleetTickMs < policy.FLEET_DR_INTERVAL_MS) return;
state._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = parts.icons._displayTime(state._scratchFleetRenderTime);
const tau = icons.seconds(renderTime);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (state._cockpitContactMode) parts.tracking._refreshCockpitNearContacts();
parts.icons._replanExpired(tau);
afterReplan(renderTime, tau, nowMs, camera);
const focusTarget = getFocusTarget();
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
const useModels = _modelRegimeActive();
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
if (!useModels && (state._models.size || state._modelPending.size))
_releaseModels();
if (!useModels) return;
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
// filtering dropped near off-screen planes entirely. This does both right.
const cap = _modelCap();
const camPos = camera.positionWC;
const addM = _modelAddDistM();
const addDistSq = addM * addM;
const keepM = _modelKeepDistM();
const keepDistSq = keepM * keepM;
const cull = camera.frustum.computeCullingVolume(
camPos,
camera.directionWC,
camera.upWC,
);
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
const cand = [];
for (let row = 0; row < icons.count; row++) {
const icao = icons.idAt(row);
if (icao === state._trackedIcao) continue;
// A converted TR-3B renders as an icon and can never take a model, so
// it must not occupy a CAP SLOT either — excluded here at selection time,
// not just at the handoff below, or accumulated conversions would starve
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
if (isTr3b(icao)) continue;
// Ground planes compete for model slots like everyone else (owner decision
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
// ground or in the air — no distinction"). The cap + nearest-first ordering
// below already bound airport clusters; grounded placement is handled by the
// one-shot ground snap in _modelDisplayPosition.
const position = icons.position(row, tau, state._scratchModelBS.center);
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
cand.push([
icao,
d2,
cull.computeVisibility(state._scratchModelBS) !==
Cesium.Intersect.OUTSIDE,
]);
}
cand.sort((a, b) => a[1] - b[1]); // nearest first
if (cand.length > cap && nowMs - state._lastModelCapWarnMs > 5000) {
console.warn(
`[${logLabel}] ${cand.length} planes in 3D range; capped at ${cap} (${state._models3dMode}). On-screen planes are prioritized.`,
);
state._lastModelCapWarnMs = nowMs;
}
const modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => state._models.has(icao),
});
const toRelease = [];
for (const icao of state._models.keys()) {
if (icao !== state._trackedIcao && !modelEligible.has(icao))
toRelease.push(icao);
}
for (const icao of toRelease) _releaseModel(icao);
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
// until the model is actually READY to render, so a plane is never both iconless AND
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
const occluder = horizonOccluder(camera);
for (const icao24 of modelEligible) {
if (isTr3b(icao24)) continue;
const row = icons.row(icao24);
if (row < 0) continue;
const position = icons.position(row, tau, state._scratchFleetPos);
if (!parts.icons._aboveHorizon(row, position, occluder)) {
// A model that crossed the limb would keep rendering through the
// hidden globe at its last matrix; the icon's own cull is on the GPU.
const hidden = state._models.get(icao24);
if (hidden?.show) showModel(hidden, false);
continue;
}
_ensureModel(icao24);
const record = state._models.get(icao24);
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
_driveFleetModelHandoff(
icao24,
record,
row,
position,
icons.course(row, tau),
() => {
applyAircraftModelTreatment({
model: record.model,
// IR boost must survive the per-tick treatment write — otherwise
// any alpha change would repaint the ordinary tint over the hot
// white. Boosted models also skip the recession fade: hot targets
// stay full-strength at any range (icons keep their normal
// fade — full-opacity glyph walls read as overwhelming).
baseColor: state._irBoost
? Cesium.Color.WHITE
: _modelColor(icao24),
alpha: state._irBoost
? 1
: aircraftTreatmentAlpha({
baseAlpha: icons.alpha(row),
focusFactor: icons.focus(row),
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
}),
});
},
);
}
}
return {
_modelRegimeActive,
_modelCap,
_modelAddDistM,
_modelKeepDistM,
_modelMatrix,
_groundSampleExclusions,
_modelDisplayPosition,
_driveFleetModelHandoff,
_modelOwnsVisual,
_specKeyFor,
_syncModelToClass,
_reloadModelsForIrBoost,
_drainIrReloadQueue,
_ensureModel,
_releaseModel,
_cleanupModelGen,
_releaseModels,
_showReleasedIcon,
_releaseTrackedModel,
_updateTrackedModel,
_fleetTick,
};
}
+138
View File
@@ -0,0 +1,138 @@
import * as Cesium from 'cesium';
import {
courseBetweenCartesians,
speedRamp,
staleCoastLimitSeconds,
} from './motionModel.js';
/**
* Which record fields carry an aircraft layer's reported ground speed (m/s)
* and track (degrees clockwise from north).
* @typedef {{speed: string, track: string}} AircraftRecordFields
*/
/** A fix's own ground speed, else the record's. */
export function fixSpeedMps(fix, info, fields) {
return Number.isFinite(fix.velocity)
? fix.velocity
: (info && info[fields.speed]) || 0;
}
/** A fix's own track, else the record's. */
export function fixTrackDeg(fix, info, fields) {
return Number.isFinite(fix.track)
? fix.track
: (info && info[fields.track]) || 0;
}
/** A reusable result for `aircraftMotionRegime`. */
export function createMotionRegime() {
return {
kind: 'fix',
a: null,
b: null,
span: 0,
speed: 0,
trackFrom: 0,
trackTo: 0,
chordCourse: null,
blend: 0,
fix: null,
heading: 0,
turnRateDps: 0,
minDt: 0,
maxDt: 0,
};
}
/**
* Which motion an aircraft's history gives at `renderTime` — the one choice
* the CPU dead reckoning and the icon planner (GPU segment) both draw from.
*
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
* error and no snap-back. The course of the DISPLAYED motion blends the
* time-interpolated reported track toward the chord by `blend`: the chord
* is only trustworthy when the segment covers real ground (at hover its
* direction is GPS jitter; on a slow tight turn it STEPS the whole
* per-segment turn at each boundary), and helicopters never use it
* (rotorcraft chords are noise-dominated at their typical speeds).
* - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt].
* Warm-up (renderTime predates ALL history: freshly seen or just tracked,
* before a poll interval of history exists) runs the OLDEST fix BACKWARD,
* preserving the behind-real-time invariant, so as history fills the icon
* glides FORWARD into interpolation with no freeze and no backward snap
* (holding the oldest fix froze the icon; extrapolating the newest to
* wall-clock made it jump back a poll interval when interpolation took
* over). Otherwise the newest fix coasts. A feed can still be receiving
* fresh contact messages for that aircraft; freezing 60 s after the
* position produced a stop → catch-up → stop cadence, so it coasts through
* the latest real contact plus a bounded grace, with an absolute cap so a
* stale cached feed cannot drift forever.
*
* @param {Array<object>} history Non-empty, oldest first.
* @param {object|undefined} info The contact's record.
* @param {Cesium.JulianDate} renderTime
* @param {AircraftRecordFields} fields
* @param {object} result From `createMotionRegime`; returned filled in.
*/
export function aircraftMotionRegime(
history,
info,
renderTime,
fields,
result,
) {
for (let i = history.length - 1; i >= 1; i--) {
const a = history[i - 1];
const b = history[i];
if (
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
)
continue;
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
const speed =
span > 0 ? chordLenM / span : (info && info[fields.speed]) || 0;
const fallbackTrack = (info && info[fields.track]) || 0;
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
result.kind = 'between';
result.a = a;
result.b = b;
result.span = span;
result.speed = speed;
result.trackFrom = trackFrom;
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
result.chordCourse =
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
result.blend = blend;
return result;
}
const newest = history[history.length - 1];
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
result.kind = 'fix';
result.fix = warming ? history[0] : newest;
result.speed = fixSpeedMps(result.fix, info, fields);
result.heading = fixTrackDeg(result.fix, info, fields);
result.turnRateDps = (info && info.turnRateDps) || 0;
result.minDt = -60;
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
// coasting forward on the same arc.
result.maxDt =
warming && history.length > 1
? 0
: staleCoastLimitSeconds({
// `epochMs` is captured once when the poll is normalized. Avoid
// allocating a Date per aircraft on every 12 Hz fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Permit one minute of contact grace but cap any cached-feed drift
// at five minutes. Source backoff is exposed separately as a STALE cue.
minimumSec: 60,
maximumSec: 300,
});
return result;
}
+15 -51
View File
@@ -1,9 +1,10 @@
/**
* Limb-relative recession for distant aircraft billboards.
* Limb-relative recession for distant aircraft: the factors icon sets apply in
* their shader (ICON_RECEDES) and models apply through their colour alpha.
*
* This multiplies the billboard's ordinary scale, which Cesium then composes
* with the locked NearFarScalar. It never edits that scalar, culls a contact,
* or allows alpha to reach zero.
* It multiplies an icon's ordinary scale, which composes with its locked
* NearFarScalar. It never edits that scalar, culls a contact, or allows alpha
* to reach zero.
*/
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
@@ -19,13 +20,6 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
const _treatmentResult = {
scale: 1,
alpha: 1,
scaleWrites: 0,
alphaWrites: 0,
factors: _scratchFactors,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
@@ -178,28 +172,18 @@ export function aircraftRecessionFactors(
}
/**
* Production wire helper: compose base scale/alpha, focus emphasis, and limb
* recession into one deadband-gated billboard write site.
* The alpha an aircraft draws with: base × max(combined floor, focus × limb
* haze). Icon sets compose the same thing in their shader; models need it here.
* @param {object} input
* @param {object} input.billboard
* @param {number} input.baseScale
* @param {number} input.baseAlpha
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
* @param {number} input.focusFactor
* @param {number} input.cameraDistanceM
* @param {number} input.cameraHeightM
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
*
* IMPORTANT: the returned object (including `factors`) is a module-owned
* mutable singleton. Consume every needed property before the next call;
* never retain it as a snapshot.
* @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}}
* @returns {number}
*/
export function applyAircraftBillboardTreatment({
billboard,
baseScale,
export function aircraftTreatmentAlpha({
baseAlpha,
baseColor,
focusFactor,
cameraDistanceM,
cameraHeightM,
@@ -212,33 +196,13 @@ export function applyAircraftBillboardTreatment({
tuning,
_scratchFactors,
);
const scale = baseScale * factors.scale;
const composedTreatment = Math.max(
tuning.combinedAlphaFloor,
clamp(focusFactor, 0, 1) * factors.alpha,
return (
baseAlpha *
Math.max(
tuning.combinedAlphaFloor,
clamp(focusFactor, 0, 1) * factors.alpha,
)
);
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
_treatmentResult.scale = scale;
_treatmentResult.alpha = alpha;
_treatmentResult.scaleWrites = scaleWrites;
_treatmentResult.alphaWrites = alphaWrites;
return _treatmentResult;
}
/**
+3 -60
View File
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict';
import {
DEFAULT_AIRCRAFT_RECESSION_PARAMS,
aircraftRecessionFactors,
applyAircraftBillboardTreatment,
applyAircraftModelTreatment,
cameraLimbDistanceM,
aircraftTreatmentAlpha,
} from './aircraftRecession.js';
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
@@ -66,72 +66,15 @@ test('globe-view transition eases to identity without a threshold pop', () => {
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
});
test('aircraft billboard wire writes scale and alpha only for the far treatment', () => {
const color = { withAlpha: (alpha) => ({ alpha }) };
const near = { scale: 1.2, color: { alpha: 0.8 } };
const nearResult = applyAircraftBillboardTreatment({
billboard: near,
baseScale: 1.2,
baseAlpha: 0.8,
baseColor: color,
focusFactor: 1,
cameraDistanceM: limb * 0.4,
cameraHeightM,
params,
});
assert.equal(nearResult.scaleWrites, 0);
assert.equal(nearResult.alphaWrites, 0);
const far = { scale: 1.2, color: { alpha: 0.8 } };
const farResult = applyAircraftBillboardTreatment({
billboard: far,
baseScale: 1.2,
baseAlpha: 0.8,
baseColor: color,
focusFactor: 0.5,
cameraDistanceM: limb,
cameraHeightM,
params,
});
assert.equal(farResult.scaleWrites, 1);
assert.equal(farResult.alphaWrites, 1);
assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12);
assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12);
});
test('combined focus and haze product is clamped at the composed alpha floor', () => {
const color = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 0.45, color: { alpha: 1 } };
const result = applyAircraftBillboardTreatment({
billboard,
baseScale: 1,
const alpha = aircraftTreatmentAlpha({
baseAlpha: 1,
baseColor: color,
focusFactor: 0.25,
cameraDistanceM: limb,
cameraHeightM,
params,
});
assert.equal(result.alpha, 0.20);
assert.equal(billboard.color.alpha, 0.20);
});
test('applyAircraftBillboardTreatment returns its documented module singleton', () => {
const color = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 1, color: { alpha: 1 } };
const input = {
billboard,
baseAlpha: 1,
baseColor: color,
focusFactor: 1,
cameraDistanceM: 0,
cameraHeightM,
params,
};
const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 });
const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 });
assert.strictEqual(first, second);
assert.equal(first.scale, 0.5, 'the prior reference reflects the next call');
assert.equal(alpha, 0.2);
});
test('ambient model presentation receives composed alpha without changing blend semantics', () => {
Loaded 100 of 597 files, more files were not shown because too many files have changed in this diff. Show more