Extract instance-owned civil-flight components

This commit is contained in:
Sameh Khamis committed 2026-09-13 09:50:14 -07:00
1 parent cf149ecfc0
commit 7c7e83c9e3
33 files changed
+6190 -5284

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
import * as Cesium from 'cesium';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
// taxiing and landing") -----------------------------------------------------------
// Present-but-grounded planes are RENDERED instead of being skipped: same class
// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata
// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles
// the existing billboard in place, never a removal. Ground planes draw no trails
// and are excluded from the ambient enrichment sweep (click-to-enrich still
// works). In 3D mode they take model slots like airborne planes (owner decision
// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap
// (see _modelDisplayPosition).
//
// TINT: full-strength, same pipeline as airborne (white / amber-military /
// cyan-tracked). Day 1 shipped a slate-gray 50%-alpha "muted" ground tint; the
// owner killed it the same day ("just leave them as white, dude … in NYC I can
// barely see them, extremely grayed out"). "On the ground" reads from the ×0.8
// scale + missing trail; "feed-dropped, coasting" stays the 45%-alpha stale
// fade — a full-alpha ground icon can never be confused with it.
/** Ground billboards render slightly smaller so airport clutter stays visually minor. */
export const GROUND_SCALE = 0.8;
// --- 3D model rendering (B3) ---------------------------------------------------------
// When enabled, aircraft render as 3D glTF models once the camera is below MODEL_ALT_CEIL_M
// (zoomed in); higher up they stay flat billboards. Eligibility is FRUSTUM-based (on-screen), and
// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each
// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet).
export const PLANE_MODEL_URL = '/models/airplane.glb';
export const MODEL_ALT_CEIL_M = 800000;
// m: below this camera altitude, draw 3D models (raised so it's easy to trigger)
export const MODEL_MIN_PX = 24;
// floor so distant models stay visible WITHOUT ballooning into a giant
// min-pixel blob (was 54 — far planes at the All radius became white
// star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent
export const TRACKED_MODEL_MIN_PX = 40;
// keep the glTF silhouette comparable to the selected 2D glyph at handoff
export const TRACKED_MODEL_MAX_PX = 200;
// owner-selected close-range tracked-target feel
export const MODEL_NATIVE_RADIUS_M = 34.41;
export const MODEL_SCALE = 1;
// airplane.glb is transform-applied and baked to real-world meters
// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost.
export const MODEL_MAX = 150;
// 'proximity' cap (the planes immediately around you)
export const MODEL_MAX_ALL = 350;
// 'all' cap (everything out to ~the horizon)
// Per-mode ADD / KEEP radii. The two modes differ by RADIUS, not just cap — otherwise they look
// IDENTICAL whenever fewer than a cap's worth of planes are in range (field bug: Proximity and All
// rendered the same). 'proximity' = a tight ring; 'all' = roughly to the horizon (state-scale). Each
// band has hysteresis (KEEP > ADD) so a plane doesn't release+reload its model when it straddles the
// add edge (the zoom/pan flicker). Beyond ADD a model would force-clamp to minimumPixelSize into a
// giant floating blob (the old 422 km airport-cluster bug), so far planes stay 2D dots; the cap +
// on-screen priority then spend the model slots on planes you can actually see.
export const MODEL_PROX_ADD_M = 150000;
// proximity: model NEW planes within 150 km
export const MODEL_PROX_KEEP_M = 185000;
// proximity: KEEP modeled planes out to 185 km
export const COCKPIT_MODEL_MAX = 60;
// max concurrent GLBs in cockpit (never raises the map cap)
export const MODEL_ALL_ADD_M = 400000;
// all: model NEW planes within 400 km (~to the horizon)
export const MODEL_ALL_KEEP_M = 450000;
// all: KEEP modeled planes out to 450 km
export const MODEL_HEADING_OFFSET_DEG = 180;
// airplane.glb nose is opposite Cesium heading-0
// Owner launch-polish direction: models should read as clean light silhouettes,
// with only a weak diffuse contribution from the existing approved textures.
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its
// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as
// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m
// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled
// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB
// by modelScale.test.mjs.
export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */
export const ERROR_BACKOFF_INTERVAL = 20000;
// transient error retry
/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */
export const POSITION_HISTORY_LIMIT = 5;
export const COCKPIT_CONTACT_SIZE_PX = 6;
export const COCKPIT_CIVILIAN_COLOR = Cesium.Color.fromCssColorString('#DCEEFF');
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
1000, 3.0, 8000000, 0.5,
);
// ---------------------------------------------------------------------------
// Track-history trail state (PRD WS-F F1/F4): a fading polyline behind the
// tracked aircraft. The accumulation array is intentionally SEPARATE from
// _positionHistory (capped at POSITION_HISTORY_LIMIT=5 for dead reckoning)
// so the visible trail can grow to TRAIL_MAX_POINTS fixes.
// ---------------------------------------------------------------------------
/** @constant {string} Civilian trail hue (PRD F4, pinned). */
export const TRAIL_COLOR = '#00d4ff';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
// ---------------------------------------------------------------------------
// Render-behind smoothing (PRD WS-C C2 — approved product decision):
// the fleet renders at now - RENDER_DELAY_SEC so positions interpolate
// BETWEEN two known fixes instead of extrapolating ahead and snapping back
// when the next poll lands. All consumers (labels, HUD, detection,
// frame_overhead) share this delayed clock; removing the delay reintroduces
// the back/forward oscillation and is a regression.
// ---------------------------------------------------------------------------
/** @constant {number} Display latency in seconds (= one poll interval). */
export const RENDER_DELAY_SEC = 30;
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
export const MISSING_POLL_LIMIT = 3;
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS
// the actual landing, so a landed plane's last airborne-classified fixes show
// it low + slow on the runway; when such a plane then drops out of the poll,
// it has landed (the feed reclassified it to ground traffic we filter out),
// not hit a transient gap — evict after ONE missed poll instead of the full
// grace. Thresholds: below ~150 m baro (MSL, so this only fires near
// sea-level-ish fields — deliberately conservative; a high-elevation airport
// ghost just falls back to the normal grace) AND below ~45 kts ground speed
// (≈23 m/s — rollout/taxi; nothing in normal FLIGHT is this slow, so cruise
// planes always keep the full grace that absorbs real feed gaps).
/** @constant {number} Max baro altitude (m, MSL) for the landed fast cull. */
export const LANDED_ALT_MAX_M = 150;
/** @constant {number} Max ground speed (m/s) for the landed fast cull (~45 kts). */
export const LANDED_SPEED_MAX_MPS = 23;
/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */
export const LANDED_MISSING_POLL_LIMIT = 1;
// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only
// contacts rendering below the alt ceiling are ever clamped/warmed (terrain
// tops out well under it outside the extreme Himalaya; cruise traffic can't
// be below ground and costs zero lookups). The radius bounds the FLEET clamp
// to viewer-visible traffic — clamping thousands of global contacts would
// need unbounded terrain resolution (the tracked contact clamps regardless).
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500;
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150;
// ---------------------------------------------------------------------------
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
// projecting each aircraft's course vector into WINDOW coordinates
// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera
// pitch/heading, including tracked-entity orbit mode. Rotation passes run on
// the fleet tick only when the camera pose changed (or 1s drift catch-up).
// The same tick horizon-culls billboards: with the Cesium globe hidden there
// is no far-side depth, so planes otherwise show through the planet.
// ---------------------------------------------------------------------------
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
export const FLEET_DR_INTERVAL_MS = 80;
/** @constant {number} Max ms between rotation passes while the camera is idle. */
export const ROTATION_REFRESH_MS = 1000;
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
export const COURSE_MAX_DPS = 60;
/** Never spend a long render stall's full elapsed time in one visible course step. */
export const COURSE_SLEW_DT_MAX_SEC = 0.25;
// ---------------------------------------------------------------------------
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
// caches per-key on disk forever, negative results included, so repeat
// sessions never re-hit adsbdb). Each key is requested at most once per
// session. Priority jobs (tracked plane, model-eligible planes) jump the
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
// ---------------------------------------------------------------------------
export const ENRICH_MAX_INFLIGHT = 4;
/** Min ms between request dispatches — the drip that bounds the fan-out to ≤5/s. */
export const ENRICH_DISPATCH_GAP_MS = 200;
// ---------------------------------------------------------------------------
// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live
// category field is 0/"no info" for ~94% of planes, so ambient classification
// defaulted nearly the whole fleet to the airliner silhouette). Each poll,
// ON-SCREEN planes — same horizon-occluder + frustum tests the fleet tick's
// model-eligibility pass uses; no new per-plane raycast — that haven't been
// requested this session are enqueued NEAREST-TO-CAMERA FIRST, bounded by:
// - the shared queue's 4-concurrent / 200 ms-drip dispatch (above),
// - ≤ ENRICH_AMBIENT_PER_SWEEP new enqueues per poll (= one poll interval
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
// request rate (repeat sessions resolve instantly from the proxy's
// permanent disk cache).
// Fail-silent by contract: the sweep never throws into the poll loop, never
// blocks rendering, and never touches tracking state. When a type answer
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
// in place (bb.scale composes multiplicatively with scaleByDistance).
// ---------------------------------------------------------------------------
// Rolling ambient budget (2026-07-03 field fix). The old ONE-SHOT session cap
// (300, refilled only in init) burned out in the first two polls of a busy
// region and never recovered — an hours-long session showed airliner
// monoculture in every NEW region until planes were clicked (the tracked path
// is uncapped). Token bucket instead: starts full at the ceiling, refills
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
// burst so a fresh region still classifies quickly.
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
export const ENRICH_AMBIENT_BUDGET_CEIL = 300;
/** Tokens added back per refill window. */
export const ENRICH_AMBIENT_REFILL_TOKENS = 150;
/** Refill window length (ms). */
export const ENRICH_AMBIENT_REFILL_WINDOW_MS = 5 * 60 * 1000;
/** Max new ambient enqueues per poll sweep (≈ rate × poll interval). */
export const ENRICH_AMBIENT_PER_SWEEP = 150;
// ---------------------------------------------------------------------------
// Tracked-display reconciliation. _deadReckon gives the RAW position from real
// fixes; at the warm-up→interpolation handoff (and on feed glitches / backfill
// splices) that raw value can step discontinuously. We absorb a step into a
// correction offset that decays to zero over DR_CORRECTION_MS, so the tracked
// icon, camera, and trail head never visibly jump — with ZERO steady-state lag
// (the correction stays ~0 whenever motion is already continuous).
// ---------------------------------------------------------------------------
export const DR_CORRECTION_MS = 900;
/** Bounded on-demand loading for the tracked model. The tracked regime is
* DEFAULT-ON and its driver runs every `scene.preUpdate`, so a missing or
* corrupt GLB — or a dead network — would otherwise spin load→reject at frame
* rate for as long as the contact stays selected. Failures are counted PER
* SELECTION: a short backoff absorbs a transient blip, then the layer stops
* asking until the operator selects something else. The billboard is the
* visual throughout (the handoff only fades it once a model actually renders),
* so a latched failure degrades to exactly the pre-3D presentation. */
export const TRACKED_MODEL_MAX_LOAD_FAILS = 3;
export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500;
/** @constant {number} Cap on NEW cells the display corridors may add to one
* poll's warm/sample batch — a view full of ground traffic must not balloon
* it. Cells the poll already collected are free (deduped before budgeting). */
export const DISPLAY_CORRIDOR_CELL_BUDGET = 64;
/** @constant {number} Cells any single contact may claim in the first pass, so
* one long corridor cannot spend the whole budget while other contacts get
* nothing. Leftovers are handed out in a second pass. */
export const DISPLAY_CORRIDOR_FAIR_SHARE = 4;
/** @constant {number} How far ahead a COASTING contact's corridor reaches:
* two poll intervals, so the ground it covers before the next batch lands is
* already warm. */
export const DISPLAY_CORRIDOR_LOOKAHEAD_SEC = RENDER_DELAY_SEC * 2;
/** @constant {number} Corridors are only collected this close to the viewer:
* the mesh sampler ignores anything past 15 km, and a far contact's exact
* datum is subpixel. */
export const DISPLAY_CORRIDOR_RADIUS_KM = 25;
/** @constant {number} How far a contact may travel from the cell that supplied
* its held floor before that floor stops describing the ground under it.
*
* Sized to the same worst-case ground segment `CORRIDOR_MAX_CELLS` is sized
* for — a 27 m/s rollout covers ~810 m in one poll interval — with headroom
* for a couple of polls of coasting. That is ~9 cells: aprons, taxiways and
* runways really are flat at that scale. An early draft used 5 km, which is
* ~45 cells and can leave the airfield entirely — the KAUS note in this file
* records a 21 m spread across the field alone — so the bound is the distance
* the contact can actually have travelled since the measurement rather than a
* comfortable-looking number. */
export const HELD_FLOOR_MAX_DRIFT_KM = 1;
/** @constant {number} Minimum gap between adjacent-cell probes for one contact
* whose held floor is missing or came from a borrowed tier. A contact standing
* on its own resolved floor never probes at all.
*
* This per-contact throttle is the ONLY rationing on the probe path, and that
* is deliberate. A probe is eight synchronous `Map` reads against the shared
* floor cache — no fetch, no `sampleHeight`, nothing async — so it cannot
* queue work anywhere; every DEM request is driven by `warmGroundFloor` from
* the poll loop, already bounded by DISPLAY_CORRIDOR_CELL_BUDGET and the
* resolver's single-flight chain. Measured worst case (`scripts/qa-floorhold-
* probe-cost.mjs`): 200 synchronized all-cold contacts probing on the SAME
* tick cost 2.1 ms, 2.6% of one 80 ms fleet tick, and 2.1 ms per second of
* wall clock sustained under this throttle. An earlier draft added a global
* per-tick budget with a fairness queue on top of that; it protected ~2 ms and
* cost two starvation defects, so it was deleted. Nothing can starve here
* because there is no shared resource to be starved of. */
export const NEIGHBOR_FLOOR_PROBE_MS = 500;
/** @constant {number} Time constant of the downward floor ease. A floor that
* drops UNDER a contact standing on a borrowed one is approached
* exponentially: each tick closes `1 - e^(-dt/TAU)` of the remaining gap, so
* at the 80 ms fleet cadence a tick moves ~20% of what is left and the value
* is within a few centimetres inside ~1.6 s. FLOOR_EASE_MAX_STEP caps that
* fraction so a delayed tick cannot close more.
*
* Exponential rather than a fixed-duration interpolation because the target
* MOVES: a second, lower neighbour can warm mid-ease. A from/duration ease
* re-evaluated against a new target jumps by the eased fraction of the change
* (measured: a 100 m single-tick drop late in an ease). Approaching from the
* CURRENTLY DISPLAYED value has no such seam — retargeting is just a different
* destination for the same continuous follow, and the per-tick bound holds
* however often the target moves.
*
* Rises are never eased: up is the safe direction, and easing up would park
* the contact under the mesh for the duration — the exact failure this whole
* path exists to prevent.
*
* Reachability note (2026-08-21, after neighborFloorM moved to a low lean):
* the ordinary arrival flows no longer produce a downward move at all — a
* borrowed floor is now the apron rather than a roof, so the contact rises
* once and stays (`scripts/qa-floorhold-staircase.mjs`: one step, zero float,
* in every scenario that settles). This machinery still guards the re-latch
* paths — a lower neighbour warming later, or an own-cell resolve below a
* hold — which the pins exercise directly. Whether those paths are worth the
* code is a follow-up judgement, deliberately not made in this change. */
export const FLOOR_EASE_TAU_MS = 360;
/** @constant {number} Hard ceiling on the fraction of the remaining gap ONE
* tick may close, whatever its dt. The exponential alone is timing-dependent:
* it closes 19.9% at the 80 ms fleet cadence but 28.4% at 120 ms and 75% after
* a 500 ms stall (a hidden tab, a long frame, a GC pause), which would turn a
* delayed tick back into the snap this approach exists to prevent. Clamping
* the fraction rather than dt keeps the never-snap property a property of the
* code instead of a property of the schedule; a stalled tick simply resumes
* the approach at full rate rather than jumping most of the way. */
export const FLOOR_EASE_MAX_STEP = 0.22;
/** @constant {number} Distance from the target at which the ease finishes
* exactly, so a parked contact stops rebuilding its position. Two orders of
* magnitude under GROUND_FLOOR_LIFT_M — invisible. */
export const FLOOR_EASE_EPSILON_M = 0.02;
/** @constant {number} How long a retired hold stays usable as a rehydration
* seed — three poll intervals.
*
* Deleting the state outright was the first cut, and an owner sighting found
* what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good
* ground, then suddenly popped below the ground, then popped back up".
* OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and
* the fix's own height source switches at the same moment, from the resolved
* surface to baro + geoid N, which at a sea-level field IS the geoid and sits
* below the runway. A single airborne poll therefore wiped the only thing that
* was hiding that: the contact came back grounded with no prior, outrunning
* its own floor cells at 23 m/s, and rendered at the geoid until something
* ahead of it warmed (`scripts/qa-floorhold-staircase.mjs` §F1: 12 of 22
* grounded ticks below the runway, and not recovering).
*
* So a retired hold is PARKED, not destroyed, and a contact that re-grounds
* soon after picks it back up. What makes that safe is the bound already in
* `_heldDisplayFloorM`: a seed only answers within HELD_FLOOR_MAX_DRIFT_KM of
* where it was measured AND only while no fresh neighbour contradicts it, so a
* genuine departure-and-landing-elsewhere still starts clean — the grace window
* is belt to those braces, retiring the seed outright once a contact has been
* airborne long enough to have gone anywhere. */
export const FLOOR_SEED_GRACE_MS = 90_000;
/** Flip the whole 3D fleet's boost state by dropping models so the eligibility
* pass reloads them with creation-time boost options (both directions — a
* boosted model must not stay flat white back in Normal). Destroying 350
* GPU-backed models synchronously inside the style handler stalls the render
* thread (review P1; same failure the cockpit path documents), so the release
* is BATCHED through the fleet tick: each tick drops a bounded slice, showing
* each plane's billboard first (gap-proof per plane, no double-image window).
* Models are tagged with the boost state they loaded under, so queue entries
* whose model already matches the current state (rapid style cycling, or a
* reload that already happened) are skipped. In-flight loads are invalidated
* immediately (cheap gen bumps); the tracked model is a single primitive and
* reloads synchronously. */
export const IR_RELOAD_BATCH = 40;