mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
The vessels layer hid the detection callout for every vessel whose card it published to the world overlay. The overlay paints only part of the published cards (shared budget, collisions, UI chrome), so vessels whose card was dropped lost all their text: with 60 vessels and Detection on, 21 showed only an arrow. Back to main's behaviour: the callout always draws. Hiding it only under a painted card is HAL-628. The translated speed unit in the callout stays. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1664 lines
59 KiB
JavaScript
1664 lines
59 KiB
JavaScript
import { paintTransitBracket } from './detectionDraw.js';
|
|
import * as Cesium from 'cesium';
|
|
import { governorRequestRender } from '../renderGovernor.js';
|
|
import {
|
|
DETECTION_ENABLE_FADE_MS,
|
|
countAnimatingRenderEntries,
|
|
detectionNeedsFollowUpFrame,
|
|
detectionPaintSkipDecision,
|
|
scanlineOffsetPx,
|
|
} from './detectionRenderDemand.js';
|
|
import {
|
|
acquireAlpha,
|
|
appendCornerBracket,
|
|
appendTransitBracket,
|
|
resolveTier,
|
|
measureTrackLabel,
|
|
nearFarScale,
|
|
rectIntersectsAny,
|
|
} from './detectionDraw.js';
|
|
import {
|
|
getKeyholeFadeTuning,
|
|
keyholeLabelAlphaFromGeometry,
|
|
} from '../celestialRing.js';
|
|
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
|
import {
|
|
DETECTION_STYLE,
|
|
DETECTION_THEME_MAP,
|
|
SKY_PLATE_SCALE,
|
|
} from '../overlays/worldOverlayTokens.js';
|
|
import { skyBackdropFactor } from './iconOrientation.js';
|
|
import { paintDetectionCallout } from '../overlays/worldOverlayDraw.js';
|
|
import { allocateLayerQuotas, LabelArbiter } from './labelArbiter.js';
|
|
import {
|
|
BoundedCohort,
|
|
cohortCapForQuota,
|
|
stableIdentityHash,
|
|
} from './detectionCohort.js';
|
|
import {
|
|
ALLOCATION_ELASTIC,
|
|
canonicalizeDensity,
|
|
defaultDensityForProfile,
|
|
detectionBracketAlpha,
|
|
detectionHorizontalSector,
|
|
labelBudgetFor,
|
|
normalizeAllocationStrategy,
|
|
normalizeProfile,
|
|
profileForDensity,
|
|
viewScaleForAltitude,
|
|
} from './detectionPolicy.js';
|
|
import { detectionBracketOpacity } from './detectionPresentation.js';
|
|
import {
|
|
createCyberSonarSampler,
|
|
cyberSonarFrameDelay,
|
|
isCyberContactSonarActive as isCyberSonarActive,
|
|
isCyberContactThemeActive as isCyberMapThemeActive,
|
|
} from '../cyberSonar.js';
|
|
|
|
/**
|
|
* @module detection
|
|
* @description Universal Detection Overlay — renders styled screen-space bounding boxes
|
|
* over tracked objects (vehicles, flights, satellites) through the shared world-overlay
|
|
* host. Supports five-stop density-derived profiles:
|
|
*
|
|
* - OFF — overlay disabled, canvas hidden
|
|
* - SPARSE — stable minimum label cohort at 0/25
|
|
* - BALANCED — stable mixed-layer label cohort at 50
|
|
* - DENSE — broad stable mixed-layer label cohort at 75/100
|
|
*
|
|
* Theming is driven by THEME_MAP presets (retro, surveillance, thermal, default).
|
|
* Density tuning and suspension allow external callers (scene transitions, UI)
|
|
* to throttle or pause rendering without tearing down the overlay.
|
|
*/
|
|
|
|
/** @constant {number} MODE_OFF - Detection disabled */
|
|
const MODE_OFF = 0;
|
|
/** @constant {number} MODE_SPARSE - Curated sparse sampling mode */
|
|
const MODE_SPARSE = 1;
|
|
/** @constant {number} MODE_BALANCED - Balanced mixed-layer mode */
|
|
const MODE_BALANCED = 2;
|
|
/** @constant {number} MODE_DENSE - Broad dense mode (legacy Panoptic) */
|
|
const MODE_DENSE = 3;
|
|
/** @constant {string[]} MODE_LABELS - Human-readable labels indexed by mode value */
|
|
const MODE_LABELS = ['OFF', 'SPARSE', 'BALANCED', 'DENSE'];
|
|
|
|
/** @constant {Object<string,string>} LEGACY_MODE_ALIAS - Maps deprecated mode names to current labels */
|
|
const LEGACY_MODE_ALIAS = {
|
|
SURVEY: 'SPARSE',
|
|
GOD: 'DENSE',
|
|
PANOPTIC: 'DENSE',
|
|
NORMAL: 'BALANCED',
|
|
ON: 'DENSE',
|
|
};
|
|
|
|
/**
|
|
* @constant {Object<string, {line: string, label: string, labelBg: string, glow: string, blend: string, filter: string, scanline: number}>}
|
|
* THEME_MAP - Visual theme presets controlling box stroke color, label styling,
|
|
* glow intensity, CSS blend mode, filter chain, and scanline overlay opacity.
|
|
*/
|
|
const THEME_MAP = DETECTION_THEME_MAP;
|
|
|
|
/** @constant {number} SPARSE_ZONE_FRACTION - Fraction of viewport half-extent used as sparse focus ring radius */
|
|
const SPARSE_ZONE_FRACTION = 0.5;
|
|
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
|
const LABEL_SOLVE_INTERVAL_MS = 125;
|
|
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
|
const BRACKET_ALPHA_STEPS = 4;
|
|
/** Stable per-layer candidate safety cap; independent of density and camera bearing. */
|
|
const LAYER_CANDIDATE_CAP = 2600;
|
|
const LAYER_WEIGHTS = Object.freeze({
|
|
military: 1.4,
|
|
traffic: 1.15,
|
|
cctv: 1.1,
|
|
flights: 1,
|
|
satellites: 1,
|
|
bikeshare: 0.9,
|
|
'ais-live-vessels': 1,
|
|
});
|
|
|
|
/** @constant {string} FONT - Monospace font used for callsign/overlay text. */
|
|
const FONT = DETECTION_STYLE.font;
|
|
/** @constant {string} MICRO_FONT - Slightly smaller font for the dim altitude micro-field. */
|
|
const MICRO_FONT = DETECTION_STYLE.microFont;
|
|
/** Billboard scale-by-distance curve (mirrors the flight billboards' NearFarScalar) — grows the AIR reticle with the plane as you zoom in. */
|
|
const BILL_NEAR = 1000;
|
|
const BILL_NEAR_SCALE = 3.0;
|
|
const BILL_FAR = 8000000;
|
|
const BILL_FAR_SCALE = 0.5;
|
|
/**
|
|
* Query-string gate for the detection mode banner, mirroring `trafficDebug` in
|
|
* `traffic.js` — the app's existing convention for a developer-only affordance.
|
|
*
|
|
* The banner ("DENSE VIS:15 SRC:1036 DENS:100% ELASTIC 0.4ms") is engine
|
|
* telemetry, but it painted for every user: CRT/NVG/FLIR auto-enable detection,
|
|
* so an orange debug readout was the first thing a visitor saw, colliding with
|
|
* the cockpit callsign block. It is kept — the same numbers also ship
|
|
* programmatically via `getDetectionDiagnostics()` — but now defaults OFF and
|
|
* paints only under `?detectDebug=1`.
|
|
*
|
|
* Deliberately NOT also gated on `import.meta.env.DEV`, unlike `trafficDebug`:
|
|
* that gate exists there so Vite can strip per-road timing instrumentation from
|
|
* production builds. This banner installs no machinery, and being able to read
|
|
* detection telemetry against a production build is the point of keeping it.
|
|
*
|
|
* @param {string} search A `location.search` string.
|
|
* @returns {boolean}
|
|
*/
|
|
export function detectionDebugRequested(search) {
|
|
try {
|
|
return new URLSearchParams(String(search ?? '')).get('detectDebug') === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Resolved once per `initDetection` — never re-read per frame. */
|
|
let _debugBanner = false;
|
|
|
|
/** @constant {number} FADE_MS - Duration of the subtle fade-in when detection (re)activates.
|
|
* Shared with the render-demand policy, which owes the scene a frame for exactly
|
|
* as long as this window is open (`detectionRenderDemand.js`). */
|
|
const FADE_MS = DETECTION_ENABLE_FADE_MS;
|
|
/** @constant {number} GLOW_PX - Soft glow radius, applied once via a CSS drop-shadow (not per-primitive). */
|
|
const GLOW_PX = DETECTION_STYLE.glowPx;
|
|
|
|
/**
|
|
* The clock the render-demand model runs on: monotonic, and the SAME source the
|
|
* world-overlay host samples once per frame into `frame.timestamp`.
|
|
*
|
|
* Detection used to mix this with `Date.now()`, which meant the paint and the
|
|
* "do I need another frame" question could land on different milliseconds inside
|
|
* a single frame and disagree about whether an animation had finished. Every
|
|
* timestamp that feeds a fade — this module's and the label arbiter's — now
|
|
* comes from here or from `frame.timestamp`, which is the same clock.
|
|
* @returns {number}
|
|
*/
|
|
function _nowMs() {
|
|
return globalThis.performance?.now?.() ?? Date.now();
|
|
}
|
|
|
|
/**
|
|
* Count rendered entries that are in their fade-out tail.
|
|
* @param {Array<{selected?: boolean}>} renderEntries - Arbiter render rows.
|
|
* @returns {number} Number of rendered rows no longer selected.
|
|
*/
|
|
export function countFadingRenderEntries(renderEntries) {
|
|
let count = 0;
|
|
for (let i = 0; i < renderEntries.length; i++) {
|
|
if (!renderEntries[i].selected) count++;
|
|
}
|
|
return count;
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level state — singleton lifecycle tied to the Cesium viewer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** @type {Cesium.Viewer|null} */
|
|
let _viewer = null;
|
|
/** @type {number} Current mode index into MODE_LABELS */
|
|
let _mode = MODE_OFF;
|
|
/** @type {HTMLCanvasElement|null} Shared host canvas used for QA diagnostics. */
|
|
let _hostCanvas = null;
|
|
/** @type {HTMLCanvasElement|null} Host-owned scene-blend isolation surface. */
|
|
let _hostSurface = null;
|
|
/** @type {CanvasRenderingContext2D|null} Current host-owned paint target. */
|
|
let _ctx = null;
|
|
/** @type {Array} Registered data layers that may expose detectable objects */
|
|
let _layers = [];
|
|
/** @type {number} Monotonic frame counter for frame-skip gating and scanline animation */
|
|
let _frameCount = 0;
|
|
/** @type {number} Wall-clock ms spent in the last _drawOverlay call (used for adaptive throttling) */
|
|
let _lastRenderMs = 0;
|
|
let _lastPaintMs = 0;
|
|
let _lastSolveMs = 0;
|
|
let _throttleSkipCount = 0;
|
|
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
|
let _cyberSonarRenderTimer = null;
|
|
/** @type {Function|null} External callback invoked when the detection mode changes */
|
|
let _onModeChange = null;
|
|
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
|
|
let _hostLane = null;
|
|
/**
|
|
* Second host lane for the SAME logical z-slot, targeting the shared
|
|
* normal-blend canvas. Callouts live here so their dark backing plate can
|
|
* actually darken bright ground; brackets, scanlines, the focus ring and the
|
|
* debug banner stay on `_hostLane`'s screen-blended sensor surface, where
|
|
* additive blending is the whole point. See `paintDetectionCallout`.
|
|
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
|
|
*/
|
|
let _calloutLane = null;
|
|
let _transitBracketPaths = new Map();
|
|
let _transitBracketAlpha = 1;
|
|
/**
|
|
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
|
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
|
* dense field costs no per-frame allocation. Retaining the previous frame's
|
|
* rows is deliberate: `_shouldPaintDetectionLane` can skip the sensor lane
|
|
* under load while the host clears the shared canvas every frame, so replaying
|
|
* the last solve is what keeps plates from strobing against persistent
|
|
* brackets.
|
|
* @type {Array<Object>}
|
|
*/
|
|
const _calloutPool = [];
|
|
let _calloutCount = 0;
|
|
/** Cached per-theme plate fills, resolved on style change (never per label). */
|
|
let _platePaint = DETECTION_THEME_MAP._default.calloutPlate;
|
|
let _platePaintSpace = DETECTION_THEME_MAP._default.calloutPlateSpace;
|
|
/** @type {Object} Active theme palette from THEME_MAP */
|
|
let _theme = THEME_MAP._default;
|
|
/** @type {string} Name of the active theme */
|
|
let _themeName = 'normal';
|
|
/** @type {number} Canonical density stop (0, 25, 50, 75, or 100). */
|
|
let _densityPct = 50;
|
|
/** @type {'ELASTIC'|'WEIGHTED'} Active layer-capacity splitter. */
|
|
let _allocationStrategy = ALLOCATION_ELASTIC;
|
|
/** @type {number} Last enabled profile, restored by the Off/On toggle. */
|
|
let _lastNonOffMode = MODE_BALANCED;
|
|
/** @type {LabelArbiter} Shared stable callout selector. */
|
|
const _labelArbiter = new LabelArbiter();
|
|
let _lastLabelSolveAt = 0;
|
|
let _labelSolveDirty = true;
|
|
let _lastDiagnostics = null;
|
|
let _lastSolveSnapshot = {
|
|
demandByLayer: {},
|
|
cohortByLayer: {},
|
|
cohortCount: 0,
|
|
};
|
|
let _hostLayoutRevision = -1;
|
|
/** @type {boolean} Whether overlay rendering is temporarily suspended */
|
|
let _suspended = false;
|
|
/** @type {string} Human-readable reason the overlay is suspended */
|
|
let _suspendReason = '';
|
|
/** @type {number} Timestamp (ms) detection last activated — drives the fade-in ramp. */
|
|
let _enableTime = 0;
|
|
/** @type {number} Cached monospace glyph advance width (px); measured once on first draw. */
|
|
let _charWidth = 0;
|
|
/** @type {boolean} Whether Cockpit view currently owns the viewport. */
|
|
let _cockpitActive = false;
|
|
/** @type {((event: CustomEvent) => void)|null} */
|
|
let _cockpitModeListener = null;
|
|
|
|
/**
|
|
* Initializes detection inside the shared world-overlay host and stores
|
|
* references to data layers. The host owns the canvas and render listener.
|
|
* @param {Cesium.Viewer} viewer - The active Cesium viewer instance.
|
|
* @param {Array} layers - Data layer modules that may implement getDetectableObjects().
|
|
* @param {Function} onModeChange - Callback invoked with the new mode label string on mode changes.
|
|
*/
|
|
export function initDetection(viewer, layers, onModeChange) {
|
|
if (_cockpitModeListener && typeof window !== 'undefined') {
|
|
window.removeEventListener(
|
|
'gev:cockpit-mode-changed',
|
|
_cockpitModeListener,
|
|
);
|
|
}
|
|
_hostLane?.unregister?.();
|
|
_calloutLane?.unregister?.();
|
|
_viewer = viewer;
|
|
_layers = layers;
|
|
_onModeChange = onModeChange;
|
|
_debugBanner = detectionDebugRequested(
|
|
typeof window !== 'undefined' ? window.location?.search : '',
|
|
);
|
|
_hostLayoutRevision = -1;
|
|
_calloutCount = 0;
|
|
_hostLane = registerWorldOverlayPaintLane('detection', _paintDetectionLane, {
|
|
id: 'detection',
|
|
active: _mode !== MODE_OFF && !_suspended,
|
|
target: 'detection',
|
|
shouldPaint: _shouldPaintDetectionLane,
|
|
});
|
|
// Registered AFTER the sensor lane so it replays the callouts that lane just
|
|
// solved. Both sit in the 'detection' lane — the host's bottom slot — so
|
|
// callouts still paint under every ordinary overlay card and under the
|
|
// tracked readout, exactly where they sat before.
|
|
_calloutLane = registerWorldOverlayPaintLane('detection', _paintCalloutLane, {
|
|
id: 'detection-callouts',
|
|
active: _mode !== MODE_OFF && !_suspended,
|
|
target: 'shared',
|
|
});
|
|
_hostSurface = _hostLane.surface;
|
|
_cockpitModeListener = (event) => {
|
|
_cockpitActive = event?.detail?.active === true;
|
|
_hostLane?.requestPaint();
|
|
};
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
|
}
|
|
|
|
setDetectionStyle('normal');
|
|
_syncSurfaceVisibility();
|
|
console.log('[Detection] Initialized');
|
|
}
|
|
|
|
/** Release the host lane and all retained detection runtime state. */
|
|
export function destroyDetection() {
|
|
_clearCyberSonarRenderTimer();
|
|
if (_cockpitModeListener && typeof window !== 'undefined') {
|
|
window.removeEventListener(
|
|
'gev:cockpit-mode-changed',
|
|
_cockpitModeListener,
|
|
);
|
|
}
|
|
_cockpitModeListener = null;
|
|
_cockpitActive = false;
|
|
_debugBanner = false;
|
|
if (_hostSurface) _hostSurface.style.display = 'none';
|
|
_hostLane?.unregister?.();
|
|
_hostLane = null;
|
|
_calloutLane?.unregister?.();
|
|
_calloutLane = null;
|
|
_transitBracketPaths.clear();
|
|
_calloutCount = 0;
|
|
_viewer = null;
|
|
_layers = [];
|
|
_onModeChange = null;
|
|
_hostCanvas = null;
|
|
_hostSurface = null;
|
|
_ctx = null;
|
|
_mode = MODE_OFF;
|
|
_suspended = false;
|
|
_suspendReason = '';
|
|
_labelArbiter.clear();
|
|
_lastLabelSolveAt = 0;
|
|
_labelSolveDirty = true;
|
|
_lastDiagnostics = null;
|
|
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
|
|
_hostLayoutRevision = -1;
|
|
_charWidth = 0;
|
|
_frameCount = 0;
|
|
_lastRenderMs = 0;
|
|
_lastPaintMs = 0;
|
|
_lastSolveMs = 0;
|
|
_throttleSkipCount = 0;
|
|
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
|
}
|
|
|
|
function _clearCyberSonarRenderTimer() {
|
|
if (_cyberSonarRenderTimer != null) {
|
|
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
|
|
_cyberSonarRenderTimer = null;
|
|
}
|
|
}
|
|
|
|
function _scheduleCyberSonarRender(nowMs) {
|
|
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
|
|
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
|
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
|
|
_cyberSonarRenderTimer = null;
|
|
if (isCyberSonarActive()) {
|
|
governorRequestRender('cyber-sonar-cadence');
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* Clamps a numeric value between min and max (inclusive).
|
|
* @param {number} value - The value to clamp.
|
|
* @param {number} min - Lower bound.
|
|
* @param {number} max - Upper bound.
|
|
* @returns {number} The clamped value.
|
|
*/
|
|
function _clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function _modeForDensity(densityPct = _densityPct) {
|
|
return MODE_LABELS.indexOf(profileForDensity(densityPct));
|
|
}
|
|
|
|
/** Toggles detection Off/restore without cycling an independent mode. */
|
|
export function cycleMode() {
|
|
if (_mode === MODE_OFF) {
|
|
_mode = _lastNonOffMode || _modeForDensity();
|
|
} else {
|
|
_lastNonOffMode = _mode;
|
|
_mode = MODE_OFF;
|
|
}
|
|
_applyModeState();
|
|
}
|
|
|
|
/**
|
|
* Sets the detection mode by label string. Accepts current names and legacy aliases.
|
|
* @param {string} modeLabel - Mode name (e.g. 'SPARSE', 'PANOPTIC', 'OFF', or legacy 'SURVEY'/'GOD').
|
|
*/
|
|
export function setMode(modeLabel) {
|
|
if (!modeLabel) return;
|
|
const target = _normalizeModeLabel(modeLabel);
|
|
if (!target) return;
|
|
if (target === 'OFF') {
|
|
if (_mode !== MODE_OFF) _lastNonOffMode = _mode;
|
|
_mode = MODE_OFF;
|
|
} else {
|
|
const requestedDensity = defaultDensityForProfile(target);
|
|
if (profileForDensity(_densityPct) !== target)
|
|
_densityPct = requestedDensity;
|
|
_mode = _modeForDensity();
|
|
_lastNonOffMode = _mode;
|
|
}
|
|
_labelSolveDirty = true;
|
|
_applyModeState();
|
|
}
|
|
|
|
/**
|
|
* Returns the current detection mode as a human-readable label.
|
|
* @returns {string} One of 'OFF', 'SPARSE', 'BALANCED', or 'DENSE'.
|
|
*/
|
|
export function getMode() {
|
|
return MODE_LABELS[_mode];
|
|
}
|
|
|
|
/**
|
|
* Temporarily suspends the detection lane (e.g. during scene transitions).
|
|
* @param {string} [reason='transition'] - Human-readable suspension reason shown in the mode banner.
|
|
*/
|
|
export function suspendDetection(reason = 'transition') {
|
|
_suspended = true;
|
|
_suspendReason = String(reason || 'transition');
|
|
_syncSurfaceVisibility();
|
|
_setLanesActive(false);
|
|
}
|
|
|
|
/** Resumes detection-lane rendering after a suspension. */
|
|
export function resumeDetection() {
|
|
_suspended = false;
|
|
_suspendReason = '';
|
|
_syncSurfaceVisibility();
|
|
_setLanesActive(_mode !== MODE_OFF);
|
|
}
|
|
|
|
/**
|
|
* Detection owns two host lanes that must arm and disarm together: the
|
|
* screen-blended sensor surface and the shared normal-blend callout lane.
|
|
* Letting them diverge would leave callouts painted over a dead sensor field
|
|
* (or brackets with no callsigns).
|
|
* @param {boolean} active
|
|
*/
|
|
function _setLanesActive(active) {
|
|
const next = active === true;
|
|
_hostLane?.setActive(next);
|
|
_calloutLane?.setActive(next);
|
|
if (!next) _calloutCount = 0;
|
|
}
|
|
|
|
/**
|
|
* Checks whether overlay rendering is currently suspended.
|
|
* @returns {boolean} True if suspended.
|
|
*/
|
|
export function isDetectionSuspended() {
|
|
return _suspended;
|
|
}
|
|
|
|
/**
|
|
* Adjusts runtime tuning parameters for the detection overlay.
|
|
* @param {Object} [options={}] - Tuning options.
|
|
* @param {number} [options.densityPct] - Density percentage canonicalized to 0/25/50/75/100.
|
|
* @param {'ELASTIC'|'WEIGHTED'} [options.allocationStrategy] - Layer-capacity split policy.
|
|
*/
|
|
export function setDetectionTuning(options = {}) {
|
|
let modeChanged = false;
|
|
if (
|
|
typeof options.densityPct === 'number' &&
|
|
Number.isFinite(options.densityPct)
|
|
) {
|
|
const nextDensity = canonicalizeDensity(options.densityPct);
|
|
if (nextDensity !== _densityPct) {
|
|
_densityPct = nextDensity;
|
|
if (_mode !== MODE_OFF) {
|
|
_mode = _modeForDensity();
|
|
_lastNonOffMode = _mode;
|
|
modeChanged = true;
|
|
} else {
|
|
_lastNonOffMode = _modeForDensity();
|
|
}
|
|
_labelSolveDirty = true;
|
|
}
|
|
}
|
|
if (options.allocationStrategy != null) {
|
|
const nextStrategy = normalizeAllocationStrategy(
|
|
options.allocationStrategy,
|
|
_allocationStrategy,
|
|
);
|
|
if (nextStrategy !== _allocationStrategy) {
|
|
_allocationStrategy = nextStrategy;
|
|
_labelSolveDirty = true;
|
|
}
|
|
}
|
|
if (modeChanged && _onModeChange) _onModeChange(MODE_LABELS[_mode]);
|
|
_hostLane?.requestPaint();
|
|
}
|
|
|
|
/**
|
|
* Returns the current runtime tuning state.
|
|
* @returns {{densityPct: number, allocationStrategy: 'ELASTIC'|'WEIGHTED'}} Current tuning.
|
|
*/
|
|
export function getDetectionTuning() {
|
|
return { densityPct: _densityPct, allocationStrategy: _allocationStrategy };
|
|
}
|
|
|
|
/**
|
|
* Tell detection that the set of detectable objects may have changed.
|
|
*
|
|
* Detection PULLS its candidates (`getDetectableObjects()`) on each paint, so a
|
|
* layer that swaps its contents does not announce itself — and the label solve
|
|
* behind those candidates runs on a private 125 ms throttle. Between the two, a
|
|
* layer tick that requested exactly ONE frame in idle mode could be consumed by
|
|
* a paint that declined to re-solve, leaving the departed contact labelled and
|
|
* the arriving one unlabelled with nothing left to ask for another frame.
|
|
*
|
|
* Marking the solve dirty makes that single requested frame do the work it was
|
|
* requested for. It is called from discrete layer events only — a poll tick or a
|
|
* visibility change, seconds apart — never per frame, so it cannot reintroduce a
|
|
* hot loop. It deliberately does NOT request a render itself: the caller already
|
|
* does that, and detection asking again would double the request.
|
|
*
|
|
* @param {string} [reason] - Diagnostic label for the caller's benefit.
|
|
* @returns {void}
|
|
*/
|
|
export function markDetectionSourcesChanged(reason = 'sources-changed') {
|
|
if (_mode === MODE_OFF) return;
|
|
_labelSolveDirty = true;
|
|
if (_lastDiagnostics) _lastDiagnostics.lastSourceChangeReason = reason;
|
|
}
|
|
|
|
/** Read-only diagnostics for unit/browser QA. */
|
|
export function getDetectionDiagnostics() {
|
|
if (!_lastDiagnostics) return null;
|
|
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
|
// Readback only: expose painted plates so pixel QA can exclude occluded
|
|
// sprites/strokes. Nothing is copied on the production paint path.
|
|
result.calloutRects = _calloutPool
|
|
.slice(0, _calloutCount)
|
|
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
|
return result;
|
|
}
|
|
|
|
function _publishDiagnostics() {
|
|
if (!_hostCanvas?.dataset || !_lastDiagnostics) return;
|
|
const dataset = _hostCanvas.dataset;
|
|
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
|
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
|
dataset.allocationStrategy =
|
|
_lastDiagnostics.allocationStrategy || _allocationStrategy;
|
|
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
|
dataset.collectiveLabelBudget = String(
|
|
_lastDiagnostics.collectiveLabelBudget ?? 0,
|
|
);
|
|
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
|
dataset.entitlementByLayer = JSON.stringify(
|
|
_lastDiagnostics.entitlementByLayer || {},
|
|
);
|
|
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
|
dataset.labelAlphaByKey = JSON.stringify(
|
|
_lastDiagnostics.labelAlphaByKey || {},
|
|
);
|
|
dataset.bracketOpacityCounts = JSON.stringify(
|
|
_lastDiagnostics.bracketOpacityCounts || {},
|
|
);
|
|
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
|
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
|
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
|
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
|
dataset.placementBuildCount = String(
|
|
_lastDiagnostics.placementBuildCount ?? 0,
|
|
);
|
|
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
|
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
|
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
|
dataset.paintMs = String(_lastDiagnostics.paintMs ?? 0);
|
|
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
|
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
|
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Switches the visual theme for detection's host paint lane.
|
|
* @param {string} styleName - Theme key from THEME_MAP (e.g. 'retro', 'surveillance', 'thermal').
|
|
* Falls back to '_default' for unrecognized names.
|
|
*/
|
|
export function setDetectionStyle(styleName) {
|
|
_themeName = styleName || 'normal';
|
|
_theme = THEME_MAP[_themeName] || THEME_MAP._default;
|
|
// Resolve the plate fills once per style change. The callout painter reads
|
|
// these strings directly, so the hot path never builds a colour.
|
|
_platePaint = _theme.calloutPlate || THEME_MAP._default.calloutPlate;
|
|
_platePaintSpace = _theme.calloutPlateSpace || _platePaint;
|
|
_applySurfaceTheme();
|
|
// Host invalidation is frame-global, so one request repaints both lanes.
|
|
_hostLane?.requestPaint();
|
|
}
|
|
|
|
function _applySurfaceTheme() {
|
|
if (!_hostSurface) return;
|
|
_hostSurface.style.mixBlendMode = _theme.blend;
|
|
// This exact CSS chain is one compositor pass over the finished layer.
|
|
_hostSurface.style.filter = `${_theme.filter} drop-shadow(0 0 ${GLOW_PX}px ${_theme.glow})`;
|
|
}
|
|
|
|
function _syncSurfaceVisibility() {
|
|
// Detection does NOT hold continuous render. It repaints on CHANGE — every
|
|
// mode/suspend/tuning transition routes through this chokepoint or through
|
|
// `_hostLane.requestPaint()`, both of which ask the governor for a frame — and
|
|
// owes itself follow-up frames only while a bounded animation is in flight
|
|
// (see `detectionRenderDemand.js` for why a hold was wrong here, and why
|
|
// removing it is safe).
|
|
governorRequestRender('detection-visibility');
|
|
if (!_hostSurface) return;
|
|
_hostSurface.style.display = _mode === MODE_OFF ? 'none' : 'block';
|
|
_hostSurface.style.opacity = _suspended ? '0' : '1';
|
|
}
|
|
|
|
/**
|
|
* Returns the active theme's key colors so other overlays (e.g. the tracked-target
|
|
* readout) can match the current visual mode (cyan/green/amber/white-hot).
|
|
* @returns {{label: string, line: string, dim: string, glow: string, labelBg: string}}
|
|
*/
|
|
export function getDetectionTheme() {
|
|
return {
|
|
label: _theme.label,
|
|
line: _theme.line,
|
|
dim: _theme.dim || _theme.label,
|
|
glow: _theme.glow,
|
|
labelBg: _theme.labelBg,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalizes a mode label string, resolving legacy aliases to current names.
|
|
* @param {string} label - Raw mode label (case-insensitive).
|
|
* @returns {string|null} Canonical mode label or null if unrecognized.
|
|
*/
|
|
function _normalizeModeLabel(label) {
|
|
const upper = String(label).toUpperCase();
|
|
if (LEGACY_MODE_ALIAS[upper]) return LEGACY_MODE_ALIAS[upper];
|
|
if (MODE_LABELS.includes(upper)) return upper;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Applies the current mode to the registered host lane.
|
|
*/
|
|
function _applyModeState() {
|
|
const label = MODE_LABELS[_mode];
|
|
if (_mode === MODE_OFF) {
|
|
_syncSurfaceVisibility();
|
|
_setLanesActive(false);
|
|
_labelArbiter.clear();
|
|
_lastLabelSolveAt = 0;
|
|
_labelSolveDirty = true;
|
|
_lastSolveSnapshot = {
|
|
demandByLayer: {},
|
|
cohortByLayer: {},
|
|
cohortCount: 0,
|
|
};
|
|
} else {
|
|
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
|
_syncSurfaceVisibility();
|
|
_setLanesActive(!_suspended);
|
|
_labelSolveDirty = true;
|
|
}
|
|
|
|
if (_onModeChange) _onModeChange(label);
|
|
console.log(`[Detection] Mode: ${label}`);
|
|
}
|
|
|
|
/**
|
|
* Host gate for the shipped pathological-load relief valve. Returning false
|
|
* preserves the dedicated surface's previous pixels while every shared lane
|
|
* continues through the same host frame.
|
|
*/
|
|
function _shouldPaintDetectionLane(frame) {
|
|
if (_mode === MODE_OFF || _suspended) return false;
|
|
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
|
|
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
|
_frameCount++;
|
|
if (isCyberSonarActive()) {
|
|
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
|
if (!layoutChanged && sonarDelay > 0) {
|
|
_scheduleCyberSonarRender(nowMs);
|
|
return false;
|
|
}
|
|
} else {
|
|
_clearCyberSonarRenderTimer();
|
|
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
|
}
|
|
// The skip and the follow-up request come from ONE decision, so the valve
|
|
// cannot drop a frame without handing its request forward — see
|
|
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
|
|
// without a render hold, the frame this declines may be the only one anybody
|
|
// asked for.)
|
|
const decision = detectionPaintSkipDecision({
|
|
layoutChanged,
|
|
lastPaintMs: _lastPaintMs,
|
|
frameCount: _frameCount,
|
|
});
|
|
if (decision.skip) {
|
|
_throttleSkipCount++;
|
|
if (_lastDiagnostics)
|
|
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
|
if (decision.requestFollowUp)
|
|
governorRequestRender('detection-paint-skipped');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Paint detection into the host-owned blend-isolation target. */
|
|
function _paintDetectionLane(frame) {
|
|
if (_mode === MODE_OFF || _suspended) return;
|
|
_ctx = frame.ctx;
|
|
_hostCanvas = frame.canvas;
|
|
if (_hostSurface !== frame.surface) {
|
|
_hostSurface = frame.surface;
|
|
_applySurfaceTheme();
|
|
_syncSurfaceVisibility();
|
|
}
|
|
if (frame.layoutRevision !== _hostLayoutRevision) {
|
|
_hostLayoutRevision = frame.layoutRevision;
|
|
_labelSolveDirty = true;
|
|
}
|
|
const start = performance.now();
|
|
const result = _drawOverlay(frame) || {
|
|
didSolve: false,
|
|
solveMs: 0,
|
|
fadingCount: 0,
|
|
animatingCount: 0,
|
|
solvePending: false,
|
|
};
|
|
_lastRenderMs = performance.now() - start;
|
|
_lastSolveMs = result.solveMs || 0;
|
|
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
|
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
|
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
|
|
_lastCyberSonarPaintAt = nowMs;
|
|
_scheduleCyberSonarRender(nowMs);
|
|
} else {
|
|
_clearCyberSonarRenderTimer();
|
|
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
|
}
|
|
if (_lastDiagnostics) {
|
|
_lastDiagnostics.frameTotalMs = _lastRenderMs;
|
|
_lastDiagnostics.paintMs = _lastPaintMs;
|
|
_lastDiagnostics.solveMs = _lastSolveMs;
|
|
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
|
}
|
|
if (result.didSolve) _publishDiagnostics();
|
|
// Detection holds nothing, so a time-based animation has to ask for its own
|
|
// next frame. This chain is self-terminating BY CONSTRUCTION — every animation
|
|
// it covers ends (the enable fade closes, labels finish fading out) — which is
|
|
// exactly what makes it safe where the old blanket hold was not. On a parked
|
|
// scene with nothing animating it asks for nothing, and the governor stays
|
|
// idle. See `detectionRenderDemand.js`.
|
|
if (
|
|
detectionNeedsFollowUpFrame({
|
|
active: _mode !== MODE_OFF && !_suspended,
|
|
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
|
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
|
// 220 ms, and the settled alpha never painted).
|
|
nowMs,
|
|
enabledAtMs: _enableTime,
|
|
fadeMs: FADE_MS,
|
|
animatingLabelCount: result.animatingCount || 0,
|
|
solvePending: result.solvePending === true,
|
|
})
|
|
) {
|
|
governorRequestRender('detection-animation');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queries all registered layers for detectable objects. Candidate collection is
|
|
* deliberately independent of density and camera bearing; the central arbiter
|
|
* is the only owner of callout selection.
|
|
* @returns {Array<Object>} Aggregated detectable objects across all layers.
|
|
* Each object is annotated with a `_layerId` property.
|
|
*/
|
|
function _collectDetectableObjects() {
|
|
const label = MODE_LABELS[_mode];
|
|
const objects = [];
|
|
|
|
for (let i = 0; i < _layers.length; i++) {
|
|
const layer = _layers[i];
|
|
if (typeof layer.getDetectableObjects !== 'function') continue;
|
|
try {
|
|
const maxCount = ['flights', 'military'].includes(layer.id)
|
|
? Number.POSITIVE_INFINITY
|
|
: LAYER_CANDIDATE_CAP;
|
|
const items = layer.getDetectableObjects({
|
|
mode: label,
|
|
maxCount,
|
|
seed: 0,
|
|
});
|
|
if (items && items.length > 0) {
|
|
for (const item of items) {
|
|
item._layerId = layer.id;
|
|
objects.push(item);
|
|
}
|
|
}
|
|
} catch {
|
|
// layer skipped — may not have data or threw an error
|
|
}
|
|
}
|
|
|
|
return objects;
|
|
}
|
|
|
|
/**
|
|
* Draws a CRT-style scanline overlay across the entire canvas.
|
|
*
|
|
* The scroll offset comes from the CLOCK, not from `_frameCount`. Those look
|
|
* identical while the scene is rendering — `SCANLINE_STEP_MS` is one 60 fps
|
|
* frame — but they differ in what they DEMAND. A frame counter only advances if
|
|
* something keeps rendering, so tying decorative texture to it meant detection
|
|
* had to hold the scene in continuous mode forever to keep its own shimmer
|
|
* alive. Off the clock, the pattern scrolls whenever the scene is rendering for
|
|
* any real reason and simply rests on a parked view. The lines are always
|
|
* painted; only the shimmer pauses.
|
|
*
|
|
* Opacity is controlled by the theme's scanline property.
|
|
* @param {number} width - Canvas width in pixels.
|
|
* @param {number} height - Canvas height in pixels.
|
|
* @param {number} nowMs - Wall-clock time of this frame.
|
|
*/
|
|
function _drawScanlines(width, height, nowMs) {
|
|
if (!_theme.scanline || !_ctx) return;
|
|
_ctx.save();
|
|
_ctx.globalAlpha = _theme.scanline;
|
|
_ctx.fillStyle = _theme.labelBg;
|
|
for (let y = scanlineOffsetPx(nowMs); y < height; y += 4) {
|
|
_ctx.fillRect(0, y, width, 1);
|
|
}
|
|
_ctx.restore();
|
|
}
|
|
|
|
// Corner-bracket geometry (appendCornerBracket) and label composition
|
|
// (composeLabel) now live in detectionDraw.js. _drawOverlay batches brackets by
|
|
// tier color + radial-opacity band and draws labels as one background fill plus
|
|
// two text passes, avoiding per-object strokes while preserving keyhole fading.
|
|
|
|
/**
|
|
* Draws the top-left status banner showing the current mode, visible/source counts,
|
|
* density tuning value, frame render time, and suspension state.
|
|
*
|
|
* Developer telemetry: paints only under `?detectDebug=1` (see
|
|
* `detectionDebugRequested`). The same numbers are always available through
|
|
* `getDetectionDiagnostics()`.
|
|
* @param {number} visibleCount - Number of objects that passed all visibility checks this frame.
|
|
* @param {number} sampledCount - Total number of objects collected from layers before filtering.
|
|
*/
|
|
function _drawModeBanner(visibleCount, sampledCount) {
|
|
if (!_debugBanner) return;
|
|
const mode = MODE_LABELS[_mode];
|
|
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
|
const allocation =
|
|
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
|
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
|
_ctx.fillStyle = _theme.labelBg;
|
|
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
|
_ctx.fillStyle = _theme.label;
|
|
_ctx.fillText(text, 18, 23);
|
|
}
|
|
|
|
/**
|
|
* Draws a faint circular focus ring in the center of the viewport in sparse mode.
|
|
* Objects outside this ring are culled during sparse selection.
|
|
* @param {number} width - Canvas width in pixels.
|
|
* @param {number} height - Canvas height in pixels.
|
|
*/
|
|
function _drawSparseFocusRing(width, height) {
|
|
if (_mode !== MODE_SPARSE) return;
|
|
const cx = width * 0.5;
|
|
const cy = height * 0.5;
|
|
const r = Math.min(width, height) * SPARSE_ZONE_FRACTION;
|
|
_ctx.save();
|
|
_ctx.strokeStyle = _theme.glow;
|
|
_ctx.globalAlpha = 0.35;
|
|
_ctx.lineWidth = 1;
|
|
_ctx.beginPath();
|
|
_ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
_ctx.stroke();
|
|
_ctx.restore();
|
|
}
|
|
|
|
function _detectionKey(layerId, sourceId) {
|
|
return `${layerId || 'unknown'}:${String(sourceId)}`;
|
|
}
|
|
|
|
function _semanticPriority(obj) {
|
|
if (obj.skipLabel) return 1000;
|
|
if (obj.tier === 'military') return 120;
|
|
if (obj.type === 'CAM') return 50;
|
|
if (obj.type === 'VEH') return 35;
|
|
if (obj.type === 'AIR') return 30;
|
|
if (obj.type === 'SAT') return 25;
|
|
if (obj.type === 'SEA') return 25;
|
|
return 10;
|
|
}
|
|
|
|
function _buildLabelPlacements(
|
|
sx,
|
|
sy,
|
|
halfW,
|
|
halfH,
|
|
card,
|
|
width,
|
|
height,
|
|
keyhole,
|
|
occlusionRects = [],
|
|
) {
|
|
const gapX = 8;
|
|
const gapY = 12;
|
|
const margin = 4;
|
|
const raw = [
|
|
{
|
|
corner: 'NE',
|
|
cardX: sx + halfW + gapX,
|
|
cardY: sy - halfH - gapY - card.h,
|
|
leadFromX: sx + halfW,
|
|
leadFromY: sy - halfH,
|
|
leadToSide: 'SW',
|
|
},
|
|
{
|
|
corner: 'NW',
|
|
cardX: sx - halfW - gapX - card.w,
|
|
cardY: sy - halfH - gapY - card.h,
|
|
leadFromX: sx - halfW,
|
|
leadFromY: sy - halfH,
|
|
leadToSide: 'SE',
|
|
},
|
|
{
|
|
corner: 'SE',
|
|
cardX: sx + halfW + gapX,
|
|
cardY: sy + halfH + gapY,
|
|
leadFromX: sx + halfW,
|
|
leadFromY: sy + halfH,
|
|
leadToSide: 'NW',
|
|
},
|
|
{
|
|
corner: 'SW',
|
|
cardX: sx - halfW - gapX - card.w,
|
|
cardY: sy + halfH + gapY,
|
|
leadFromX: sx - halfW,
|
|
leadFromY: sy + halfH,
|
|
leadToSide: 'NE',
|
|
},
|
|
];
|
|
|
|
const placements = [];
|
|
for (const placement of raw) {
|
|
const { cardX, cardY } = placement;
|
|
if (
|
|
cardX < margin ||
|
|
cardY < margin ||
|
|
cardX + card.w > width - margin ||
|
|
cardY + card.h > height - margin
|
|
) {
|
|
continue;
|
|
}
|
|
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
|
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
|
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
|
const leadToY = placement.leadToSide.startsWith('S')
|
|
? cardY + card.h
|
|
: cardY;
|
|
const centerX = cardX + card.w * 0.5;
|
|
const centerY = cardY + card.h * 0.5;
|
|
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
|
centerX,
|
|
centerY,
|
|
keyhole,
|
|
);
|
|
if (radialAlpha <= 0) continue;
|
|
placements.push({
|
|
...placement,
|
|
leadToX,
|
|
leadToY,
|
|
centerX,
|
|
centerY,
|
|
keyholeAlpha: radialAlpha,
|
|
rect: cardRect,
|
|
primaryX: cardX + card.primaryX,
|
|
microX: cardX + card.microX,
|
|
baseline: cardY + card.baseline,
|
|
});
|
|
}
|
|
return placements;
|
|
}
|
|
|
|
/**
|
|
* Record one solved callout into the pooled replay buffer consumed by
|
|
* `_paintCalloutLane`. Nothing is painted here: the sensor lane owns the
|
|
* screen-blended surface, and a dark plate cannot darken through `screen`.
|
|
*
|
|
* Row objects are created once and then only mutated, so a dense field costs
|
|
* no allocation after the first frames that reach a given population.
|
|
* @param {Object} entry Arbiter render row.
|
|
* @param {number} acquireFade Detection's activation fade.
|
|
* @param {Object} keyhole Frame keyhole geometry.
|
|
*/
|
|
function _stashCallout(entry, acquireFade, keyhole) {
|
|
const { candidate, placement, temporalAlpha } = entry;
|
|
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
|
placement.centerX,
|
|
placement.centerY,
|
|
keyhole,
|
|
);
|
|
const alpha =
|
|
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
|
|
if (alpha <= 0.001) return;
|
|
|
|
let row = _calloutPool[_calloutCount];
|
|
if (!row) {
|
|
row = {
|
|
x: 0,
|
|
y: 0,
|
|
w: 0,
|
|
h: 0,
|
|
primaryX: 0,
|
|
microX: 0,
|
|
baseline: 0,
|
|
leadFromX: 0,
|
|
leadFromY: 0,
|
|
leadToX: 0,
|
|
leadToY: 0,
|
|
plate: '',
|
|
plateScale: 1,
|
|
accent: '',
|
|
label: '',
|
|
primary: '',
|
|
micro: '',
|
|
font: FONT,
|
|
microFont: MICRO_FONT,
|
|
alpha: 1,
|
|
};
|
|
_calloutPool[_calloutCount] = row;
|
|
}
|
|
row.x = placement.cardX;
|
|
row.y = placement.cardY;
|
|
row.w = placement.rect.w;
|
|
row.h = placement.rect.h;
|
|
row.primaryX = placement.primaryX;
|
|
row.microX = placement.microX;
|
|
row.baseline = placement.baseline;
|
|
row.leadFromX = placement.leadFromX;
|
|
row.leadFromY = placement.leadFromY;
|
|
row.leadToX = placement.leadToX;
|
|
row.leadToY = placement.leadToY;
|
|
// Satellites sit over the high-albedo lit Earth disc far more often than
|
|
// aircraft do, so the space tier carries the heavier plate.
|
|
row.plate = candidate.type === 'SAT' ? _platePaintSpace : _platePaint;
|
|
// Solved once per candidate, replayed on every frame between solves.
|
|
row.plateScale = candidate.plateScale ?? 1;
|
|
row.accent = candidate.color;
|
|
row.label = _theme.label;
|
|
row.primary = candidate.primary;
|
|
row.micro = candidate.hasMicro ? candidate.micro : '';
|
|
row.font = FONT;
|
|
row.microFont = MICRO_FONT;
|
|
row.alpha = alpha;
|
|
_calloutCount++;
|
|
}
|
|
|
|
/**
|
|
* Replay this frame's callouts onto the shared normal-blend canvas.
|
|
*
|
|
* Runs as its own host lane so the plate composites normally instead of
|
|
* through the sensor surface's `screen` blend. Registered in the same
|
|
* 'detection' lane slot, so callouts keep their shipped z-position beneath
|
|
* every ordinary overlay card and the tracked readout.
|
|
* @param {Object} frame Host paint frame.
|
|
*/
|
|
function _paintCalloutLane(frame) {
|
|
if (_mode === MODE_OFF || _suspended) return;
|
|
const ctx = frame.ctx;
|
|
if (!ctx) return;
|
|
for (const bands of _transitBracketPaths.values()) {
|
|
for (const entry of bands) {
|
|
if (entry)
|
|
paintTransitBracket(
|
|
ctx,
|
|
entry.path,
|
|
entry.color,
|
|
entry.alpha * _transitBracketAlpha,
|
|
);
|
|
}
|
|
}
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'alphabetic';
|
|
for (let i = 0; i < _calloutCount; i++) {
|
|
const row = _calloutPool[i];
|
|
paintDetectionCallout(ctx, row, row.alpha);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
/**
|
|
* Scale a callout's plate for what sits behind it. Ground keeps the full
|
|
* per-theme plate; sky feathers down to `SKY_PLATE_SCALE` of it, because the
|
|
* plate's whole job — holding small mono text off busy sunlit imagery — does
|
|
* not exist against an empty horizon. See `SKY_PLATE_SCALE` for the taste call.
|
|
* @param {number} skyFactor 0 (ground behind) … 1 (sky behind).
|
|
* @returns {number} Multiplier applied to the plate fill only.
|
|
*/
|
|
function _plateScaleForBackdrop(skyFactor) {
|
|
return 1 + (SKY_PLATE_SCALE - 1) * skyFactor;
|
|
}
|
|
|
|
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
|
function _materializeCandidate(
|
|
obj,
|
|
width,
|
|
height,
|
|
keyhole,
|
|
occlusionRects,
|
|
cameraPosition,
|
|
) {
|
|
const primary = obj._candidatePrimary;
|
|
const micro = obj._candidateMicro;
|
|
const card = measureTrackLabel(primary, micro, _charWidth);
|
|
const placements = _buildLabelPlacements(
|
|
obj._candidateScreenX,
|
|
obj._candidateScreenY,
|
|
obj._candidateHalfW,
|
|
obj._candidateHalfH,
|
|
card,
|
|
width,
|
|
height,
|
|
keyhole,
|
|
occlusionRects,
|
|
);
|
|
if (placements.length === 0) return null;
|
|
let centerDistance = Number.POSITIVE_INFINITY;
|
|
let keyholeAlpha = 0;
|
|
for (const placement of placements) {
|
|
centerDistance = Math.min(
|
|
centerDistance,
|
|
Math.hypot(
|
|
placement.centerX - keyhole.centerX,
|
|
placement.centerY - keyhole.centerY,
|
|
),
|
|
);
|
|
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
|
}
|
|
const layerId = obj._layerId || 'unknown';
|
|
const sourceId = obj._cohortSourceId;
|
|
return {
|
|
key: _detectionKey(layerId, sourceId),
|
|
layerId,
|
|
sourceId,
|
|
type: obj.type || 'UNK',
|
|
priority: obj._cohortPriority,
|
|
screenX: obj._candidateScreenX,
|
|
screenY: obj._candidateScreenY,
|
|
centerDistance,
|
|
keyholeAlpha,
|
|
placements,
|
|
color: obj._candidateColor,
|
|
sonarFactor: obj._candidateSonarFactor ?? 1,
|
|
// Costed here rather than in the object sweep: the sweep walks every
|
|
// observation in view, while this runs only for callouts that actually
|
|
// placed — a budgeted handful per frame.
|
|
plateScale: _plateScaleForBackdrop(
|
|
skyBackdropFactor(cameraPosition, obj.position),
|
|
),
|
|
primary,
|
|
micro,
|
|
hasMicro: card.hasMicro,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Paint detection against the shared host frame. Brackets remain broad and
|
|
* per-frame; rich placements rebuild only for selected/fading identities and
|
|
* for a bounded cohort on solve ticks.
|
|
*/
|
|
function _drawOverlay(frame) {
|
|
_transitBracketPaths.clear();
|
|
const { width, height } = frame;
|
|
if (!_ctx || width <= 0 || height <= 0) {
|
|
return {
|
|
didSolve: false,
|
|
solveMs: 0,
|
|
fadingCount: 0,
|
|
animatingCount: 0,
|
|
solvePending: false,
|
|
};
|
|
}
|
|
|
|
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
|
// with every arbiter call in this function — so the frame that paints an
|
|
// animation's final state is the same frame that ends its demand.
|
|
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
|
const sonarActive = isCyberSonarActive();
|
|
const cyberMapActive = isCyberMapThemeActive();
|
|
const sonarSampler = createCyberSonarSampler(width, height, now);
|
|
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
|
const shouldSolve =
|
|
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
|
const calloutOcclusionRects = frame.uiRects;
|
|
const selectedIdentities = shouldSolve
|
|
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
|
: null;
|
|
const renderIdentities = _labelArbiter.liveIdentities({
|
|
includeFading: true,
|
|
now,
|
|
});
|
|
const objects = _collectDetectableObjects();
|
|
const sampledCount = objects.length;
|
|
if (objects.length === 0) {
|
|
// No detectable objects (e.g. DETECT on with no data layers enabled). Still
|
|
// paint so the overlay reads "armed, nothing in view" instead of a dead blank
|
|
// canvas (H3 silent-failure theme). The scanlines and focus ring carry that
|
|
// signal for everyone; the mode banner adds counts only under ?detectDebug=1.
|
|
// The frame is already cleared above.
|
|
//
|
|
// Drop the replay buffer with it. Every other exit from this function
|
|
// rebuilds the buffer, but this one returns early — and because the
|
|
// callout lane repaints the last solve on frames the sensor lane skips,
|
|
// leaving rows here would strand the final callsigns on screen after the
|
|
// last data layer is switched off.
|
|
_calloutCount = 0;
|
|
_ctx.font = FONT;
|
|
_drawScanlines(width, height, now);
|
|
_drawSparseFocusRing(width, height);
|
|
_drawModeBanner(0, 0);
|
|
_lastDiagnostics = {
|
|
profile: MODE_LABELS[_mode],
|
|
densityPct: _densityPct,
|
|
allocationStrategy: _allocationStrategy,
|
|
viewScale: viewScaleForAltitude(
|
|
_viewer?.camera?.positionCartographic?.height,
|
|
),
|
|
candidateCount: 0,
|
|
observationCount: 0,
|
|
visibleCount: 0,
|
|
bracketPresentationOpacity,
|
|
labelBudget: 0,
|
|
labelsByLayer: {},
|
|
labeledKeys: [],
|
|
solveRevision: _labelArbiter.solveRevision,
|
|
demandByLayer: {},
|
|
cohortByLayer: {},
|
|
cohortCount: 0,
|
|
placementBuildCount: 0,
|
|
selectedCount: 0,
|
|
fadingCount: 0,
|
|
didSolve: false,
|
|
};
|
|
// With nothing detectable there is nothing to place, so the solve is
|
|
// VACUOUSLY complete — settle it here rather than carrying the dirty flag
|
|
// out of a branch that can never clear it.
|
|
//
|
|
// This is the one exit that skips the solve block entirely, so leaving the
|
|
// flag set reports "a solve is still owed" on every future frame. Paired
|
|
// with the follow-up request that owed solves now earn, that is a permanent
|
|
// frame chain on the emptiest possible scene — precisely the hot loop this
|
|
// whole mechanism exists to remove.
|
|
_lastLabelSolveAt = now;
|
|
_labelSolveDirty = false;
|
|
return {
|
|
didSolve: false,
|
|
solveMs: 0,
|
|
fadingCount: 0,
|
|
animatingCount: 0,
|
|
solvePending: false,
|
|
};
|
|
}
|
|
|
|
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
|
// every host lane. Detection retains the manual scalar projection below.
|
|
const occluder = frame.occluder;
|
|
const camPos = frame.cameraPosition;
|
|
const keyhole = frame.keyhole;
|
|
// Read the OUTSIDE setting ONCE per paint, from the same module state the
|
|
// host's own keyhole alpha comes from, so a bracket and its callout can never
|
|
// disagree about the operator's setting within a frame.
|
|
const keyholeOutsideOpacity = getKeyholeFadeTuning().outsideOpacity;
|
|
const viewProjection = frame.viewProjectionMatrix;
|
|
const vp0 = viewProjection[0];
|
|
const vp1 = viewProjection[1];
|
|
const vp3 = viewProjection[3];
|
|
const vp4 = viewProjection[4];
|
|
const vp5 = viewProjection[5];
|
|
const vp7 = viewProjection[7];
|
|
const vp8 = viewProjection[8];
|
|
const vp9 = viewProjection[9];
|
|
const vp11 = viewProjection[11];
|
|
const vp12 = viewProjection[12];
|
|
const vp13 = viewProjection[13];
|
|
const vp15 = viewProjection[15];
|
|
|
|
// Brackets stay broad inside the keyhole, fade outside it, and batch by tier
|
|
// color plus a 32-step alpha band. Text candidates are projected once and
|
|
// handed to the shared stable arbiter.
|
|
_ctx.font = FONT;
|
|
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
|
const tiers = _theme.tiers || null;
|
|
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
|
const bracketPaths = new Map();
|
|
const pathFor = (map, color, alpha) => {
|
|
const band = Math.max(
|
|
1,
|
|
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
|
|
);
|
|
let bands = map.get(color);
|
|
if (!bands) {
|
|
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
|
map.set(color, bands);
|
|
}
|
|
let entry = bands[band];
|
|
if (!entry) {
|
|
entry = { color, alpha: band / BRACKET_ALPHA_STEPS, path: new Path2D() };
|
|
bands[band] = entry;
|
|
}
|
|
return entry.path;
|
|
};
|
|
|
|
let visibleCount = 0;
|
|
const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
|
const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
|
let protectedVisibleCount = 0;
|
|
const candidateMap = new Map();
|
|
const cohortBuilders = shouldSolve ? new Map() : null;
|
|
const demandByLayer = shouldSolve ? new Map() : null;
|
|
let placementBuildCount = 0;
|
|
for (let i = 0; i < objects.length; i++) {
|
|
const obj = objects[i];
|
|
if (!obj.position) continue;
|
|
// Skip objects occluded by the ellipsoid (behind the horizon)
|
|
if (!occluder.isPointVisible(obj.position)) continue;
|
|
|
|
// This product renders in Cesium's 3D scene mode. Multiplying by the
|
|
// once-per-frame view-projection matrix is equivalent to the general
|
|
// SceneTransforms helper here, without repeating its mode/viewport setup
|
|
// for every observation.
|
|
const px = obj.position.x;
|
|
const py = obj.position.y;
|
|
const pz = obj.position.z;
|
|
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
|
if (clipW <= 0) continue;
|
|
const invW = 1 / clipW;
|
|
const sx =
|
|
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
|
const sy =
|
|
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
|
|
|
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
|
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
|
// they don't look tiny at altitude; other types keep fixed sizes.
|
|
const isTracked = obj.skipLabel;
|
|
let halfW;
|
|
let halfH;
|
|
if (obj.type === 'AIR') {
|
|
const bscale = nearFarScale(
|
|
Cesium.Cartesian3.distance(camPos, obj.position),
|
|
BILL_NEAR,
|
|
BILL_NEAR_SCALE,
|
|
BILL_FAR,
|
|
BILL_FAR_SCALE,
|
|
);
|
|
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
|
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
|
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
|
|
halfW = obj.bracketHalfWidth;
|
|
halfH = obj.bracketHalfHeight;
|
|
} else {
|
|
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
|
|
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
|
}
|
|
// Viewport bounds check with padding
|
|
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
|
|
continue;
|
|
|
|
// Tier color drives the bracket, accent bar, and leader line for this object.
|
|
// The bracket follows the same linear radial keyhole fade as its callout.
|
|
const color = colorFor(resolveTier(obj));
|
|
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
|
const sonarFactor =
|
|
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
|
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
|
const admissionAlpha = detectionBracketAlpha(
|
|
obj.type,
|
|
keyholeAlpha,
|
|
keyholeOutsideOpacity,
|
|
cyberMapActive,
|
|
);
|
|
const bracketAlpha = admissionAlpha * sonarFactor;
|
|
if (bracketAlpha > 0) {
|
|
const transit = obj.tier?.startsWith('transit_');
|
|
(transit ? appendTransitBracket : appendCornerBracket)(
|
|
pathFor(
|
|
transit ? _transitBracketPaths : bracketPaths,
|
|
color,
|
|
bracketAlpha,
|
|
),
|
|
sx,
|
|
sy,
|
|
halfW,
|
|
halfH,
|
|
);
|
|
visibleCount++;
|
|
if (obj.type === 'AIR')
|
|
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
|
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
|
else bracketOpacityCounts.partial++;
|
|
} else {
|
|
bracketOpacityCounts.hidden++;
|
|
}
|
|
|
|
if (obj.skipLabel) {
|
|
if (bracketAlpha > 0) protectedVisibleCount++;
|
|
continue;
|
|
}
|
|
|
|
const primary = String(obj.id || '');
|
|
const micro = String(obj.metric || '');
|
|
if (!primary && !micro) continue;
|
|
const layerId = obj._layerId || 'unknown';
|
|
const sourceId = obj.sourceId ?? obj.id ?? i;
|
|
const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false;
|
|
if (!shouldSolve && !liveForRender) continue;
|
|
|
|
obj._cohortSourceId = sourceId;
|
|
obj._cohortPriority = _semanticPriority(obj);
|
|
obj._cohortBand =
|
|
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
|
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
|
obj._candidateScreenX = sx;
|
|
obj._candidateScreenY = sy;
|
|
obj._candidateHalfW = halfW;
|
|
obj._candidateHalfH = halfH;
|
|
obj._candidateColor = color;
|
|
obj._candidateSonarFactor = sonarLabelFactor;
|
|
obj._candidatePrimary = primary;
|
|
obj._candidateMicro = micro;
|
|
|
|
if (shouldSolve && keyholeAlpha > 0) {
|
|
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
|
if (!cohortBuilders.has(layerId))
|
|
cohortBuilders.set(layerId, new BoundedCohort(256));
|
|
const incumbent =
|
|
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
|
cohortBuilders.get(layerId).consider(obj, incumbent);
|
|
}
|
|
|
|
if (liveForRender) {
|
|
placementBuildCount++;
|
|
const candidate = _materializeCandidate(
|
|
obj,
|
|
width,
|
|
height,
|
|
keyhole,
|
|
calloutOcclusionRects,
|
|
camPos,
|
|
);
|
|
if (candidate && !candidateMap.has(candidate.key))
|
|
candidateMap.set(candidate.key, candidate);
|
|
}
|
|
}
|
|
|
|
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
|
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
|
const ambientBudget = Math.max(
|
|
0,
|
|
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
|
|
);
|
|
let didSolve = false;
|
|
let solveMs = 0;
|
|
if (shouldSolve) {
|
|
const quotas = allocateLayerQuotas(
|
|
demandByLayer,
|
|
ambientBudget,
|
|
_allocationStrategy,
|
|
LAYER_WEIGHTS,
|
|
);
|
|
const solveCandidates = [];
|
|
const cohortByLayer = {};
|
|
for (const [layerId, builder] of cohortBuilders) {
|
|
const cohort = builder.values(
|
|
cohortCapForQuota(quotas.get(layerId) || 0),
|
|
);
|
|
for (const obj of cohort) {
|
|
const key = _detectionKey(layerId, obj._cohortSourceId);
|
|
let candidate = candidateMap.get(key);
|
|
if (!candidate) {
|
|
placementBuildCount++;
|
|
candidate = _materializeCandidate(
|
|
obj,
|
|
width,
|
|
height,
|
|
keyhole,
|
|
calloutOcclusionRects,
|
|
camPos,
|
|
);
|
|
if (candidate) candidateMap.set(candidate.key, candidate);
|
|
}
|
|
if (candidate) solveCandidates.push(candidate);
|
|
}
|
|
cohortByLayer[layerId] = solveCandidates.filter(
|
|
(candidate) => candidate.layerId === layerId,
|
|
).length;
|
|
}
|
|
const solveStarted = performance.now();
|
|
_labelArbiter.solve(solveCandidates, {
|
|
capacity: ambientBudget,
|
|
strategy: _allocationStrategy,
|
|
layerWeights: LAYER_WEIGHTS,
|
|
demandByLayer,
|
|
now,
|
|
// A dirty solve means the capacity/profile/policy changed, not that stable
|
|
// identities became invalid. The arbiter itself disables preservation when
|
|
// the active layer set changes; otherwise keep incumbents so increasing a
|
|
// density stop adds labels instead of replacing the visible cohort.
|
|
preserveIncumbents: true,
|
|
});
|
|
solveMs = performance.now() - solveStarted;
|
|
_lastLabelSolveAt = now;
|
|
_labelSolveDirty = false;
|
|
_lastSolveSnapshot = {
|
|
demandByLayer: Object.fromEntries(demandByLayer),
|
|
cohortByLayer,
|
|
cohortCount: solveCandidates.length,
|
|
};
|
|
didSolve = true;
|
|
}
|
|
|
|
const fade = acquireAlpha(_enableTime, now, FADE_MS);
|
|
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
|
|
_transitBracketAlpha = fade * bracketPresentationOpacity;
|
|
|
|
// Brackets — batched by tier color and linear radial-opacity band.
|
|
_ctx.lineWidth = bracketWidth;
|
|
for (const bands of bracketPaths.values()) {
|
|
for (const entry of bands) {
|
|
if (!entry) continue;
|
|
_ctx.globalAlpha = fade * entry.alpha * bracketPresentationOpacity;
|
|
_ctx.strokeStyle = entry.color;
|
|
_ctx.stroke(entry.path);
|
|
}
|
|
}
|
|
|
|
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
|
const fadingCount = countFadingRenderEntries(renderEntries);
|
|
// Demand counts fades in BOTH directions; `fadingCount` above stays the
|
|
// fade-OUT tail because that is what the published diagnostics have always
|
|
// meant. A fade-IN that nothing asks a frame for is a label that never
|
|
// becomes visible on a parked scene.
|
|
const animatingCount = countAnimatingRenderEntries(renderEntries);
|
|
_calloutCount = 0;
|
|
for (const entry of renderEntries) _stashCallout(entry, fade, keyhole);
|
|
_ctx.font = FONT;
|
|
_ctx.globalAlpha = 1;
|
|
|
|
if (didSolve || !_lastDiagnostics) {
|
|
const arbiterDiagnostics = _labelArbiter.diagnostics() || {};
|
|
const selectedCount = _labelArbiter.selectedKeys.size;
|
|
_lastDiagnostics = {
|
|
profile: MODE_LABELS[_mode],
|
|
densityPct: _densityPct,
|
|
allocationStrategy: _allocationStrategy,
|
|
viewScale: viewScaleForAltitude(altitude),
|
|
candidateCount: _lastSolveSnapshot.cohortCount,
|
|
observationCount: sampledCount,
|
|
candidatesByLayer: _lastSolveSnapshot.cohortByLayer,
|
|
demandByLayer: _lastSolveSnapshot.demandByLayer,
|
|
cohortByLayer: _lastSolveSnapshot.cohortByLayer,
|
|
cohortCount: _lastSolveSnapshot.cohortCount,
|
|
placementBuildCount,
|
|
selectedCount,
|
|
fadingCount,
|
|
didSolve,
|
|
visibleCount,
|
|
bracketOpacityCounts,
|
|
aircraftBracketSectors,
|
|
bracketPresentationOpacity,
|
|
protectedVisibleCount,
|
|
collectiveLabelBudget: collectiveBudget,
|
|
ambientLabelBudget: ambientBudget,
|
|
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
|
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
|
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
|
labelAlphaByKey: Object.fromEntries(
|
|
renderEntries.map((entry) => [
|
|
entry.candidate.key,
|
|
entry.temporalAlpha *
|
|
keyholeLabelAlphaFromGeometry(
|
|
entry.placement.centerX,
|
|
entry.placement.centerY,
|
|
keyhole,
|
|
),
|
|
]),
|
|
),
|
|
solveRevision: _labelArbiter.solveRevision,
|
|
keyholeRadius: keyhole.radius,
|
|
keyholeFeatherPx: keyhole.featherPx,
|
|
calloutOcclusionCount: calloutOcclusionRects.length,
|
|
};
|
|
} else {
|
|
const selectedCount = _labelArbiter.selectedKeys.size;
|
|
_lastDiagnostics.placementBuildCount = placementBuildCount;
|
|
_lastDiagnostics.observationCount = sampledCount;
|
|
_lastDiagnostics.selectedCount = selectedCount;
|
|
_lastDiagnostics.fadingCount = fadingCount;
|
|
_lastDiagnostics.didSolve = false;
|
|
_lastDiagnostics.visibleCount = visibleCount;
|
|
_lastDiagnostics.bracketOpacityCounts = bracketOpacityCounts;
|
|
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
|
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
|
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
|
}
|
|
|
|
_drawScanlines(width, height, now);
|
|
_drawSparseFocusRing(width, height);
|
|
_drawModeBanner(visibleCount, sampledCount);
|
|
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
|
// run — the frame that carried the request cannot be the last one.
|
|
return {
|
|
didSolve,
|
|
solveMs,
|
|
fadingCount,
|
|
animatingCount,
|
|
solvePending: _labelSolveDirty,
|
|
};
|
|
}
|