mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1667 lines
59 KiB
JavaScript
1667 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;
|
|
}
|
|
// The source shows its own card for this object (e.g. an ambient vessel
|
|
// card): keep the bracket, draw no callout over the card.
|
|
if (obj.hideLabel) 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,
|
|
};
|
|
}
|