Files
gods-eye-view/src/layers/flights/policy.js
T

446 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export {
GROUND_FLOOR_CLAMP_RADIUS_KM,
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
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');
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
export const EMERGENCY_TINT =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- 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);
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). */
// --- 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). */
// 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. */
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
// ---------------------------------------------------------------------------
// 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 successful and 404 responses per key for 24 hours, so later sessions
// may refresh them). 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;