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 // 24-hour 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 24-hour 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;