/** * @module detectionPolicy * @description Pure detection density/profile and collective-budget helpers. * The five-stop UI, share links, voice actions, and renderer all use this * module so density cannot drift into contradictory mode semantics again. */ export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_WEIGHTED = 'WEIGHTED'; export const ALLOCATION_STRATEGIES = Object.freeze([ ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED, ]); // The keyhole's default 1% outside opacity is appropriate for text, but it // made side aircraft brackets effectively disappear while the same contacts // remained eligible. AIR geometry therefore gets a readable floor, without // changing label budgets. // // The floor is 0.35 AT THE DEFAULT OUTSIDE OPACITY, and it SCALES with the // OUTSIDE slider rather than overriding it. A flat 0.35 made that slider a // no-op for aircraft: dialling it down to 1% still painted brackets at 35%, so // the control did nothing across a third of its travel while visibly working // for labels. A setting that moves everything except the thing the operator is // looking at is a broken control, not a safety net. export const AIRCRAFT_BRACKET_ALPHA_FLOOR = 0.35; /** * Mirror of celestialRing's KEYHOLE_OUTSIDE_OPACITY_DEFAULT — the OUTSIDE * setting the bracket look is calibrated at, so `AIRCRAFT_BRACKET_ALPHA_FLOOR` * lands EXACTLY at the shipped default. Mirrored rather than imported so this * stays a pure policy module with no Cesium dependency; detectionPolicy.test.mjs * imports the real constant and pins the two together so they cannot drift. * * It MOVES WITH THE DEFAULT (0.05 → 0.03 → 0.01; owner final lock 2026-08-24). * That pin is the tripwire * for exactly this change, and the decision it forces is which of two things the * approval attaches to: the bracket BRIGHTNESS, or the slider POSITION. It is * the brightness — the floor exists so side aircraft brackets stay readable at * whatever the default is, and the owner's directive moved the label default, * not the bracket look. Holding the anchor at 0.05 instead would dim brackets to * 0.07 on a first run at the 1% default, which is the dead-zone complaint in * reverse. */ export const AIRCRAFT_BRACKET_FLOOR_ANCHOR = 0.01; /** * The AIR bracket alpha floor for a given OUTSIDE opacity setting. * * Piecewise linear through three fixed points, chosen so the control is honest * across its whole travel while the shipped look does not move: * * slider 0 → 0 off means off; the operator's zero stays authoritative * slider 0.01 → 0.35 EXACTLY the shipped constant at the default, so the * approved look is reproduced unchanged (above the * anchor the floor rises smoothly — 0.05 ≈ 0.376) * slider 1.0 → 1.0 the boost decays to nothing: at full opacity the floor * equals the label alpha and stops overriding at all * * Strictly increasing, so every step of the slider changes the picture and * there is no dead zone at either end — which was the whole complaint. * * A non-finite input falls back to the anchor: an unreadable setting should * reproduce the approved default, never silently blank the brackets. * * @param {number} outsideOpacity - Keyhole outside opacity, 0-1. * @returns {number} Alpha floor for AIR brackets, 0-1. */ export function aircraftBracketAlphaFloor(outsideOpacity) { const raw = Number(outsideOpacity); const outside = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : AIRCRAFT_BRACKET_FLOOR_ANCHOR; if (outside <= 0) return 0; if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) { return ( AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR) ); } const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR); return ( AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress ); } /** * Resolve the paint alpha for a detection bracket without altering admission. * @param {string} type - Detection object type; only AIR is floored. * @param {number} keyholeAlpha - Radial keyhole alpha for this contact. * @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means * the default, so a caller that does not know it reproduces the shipped look. * @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR * readability floor; the bracket follows the radial keyhole alpha exactly. * @returns {number} Paint alpha, 0-1. */ export function detectionBracketAlpha( type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR, strictKeyholeFade = false, ) { const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0)); if (strictKeyholeFade) return alpha; if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha; return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha); } /** Stable left/front/right bucket for bracket coverage diagnostics and QA. */ export function detectionHorizontalSector(screenX, viewportWidth) { const width = Number(viewportWidth); const x = Number(screenX); if (!(width > 0) || !Number.isFinite(x)) return 'front'; if (x < width / 3) return 'left'; if (x > (width * 2) / 3) return 'right'; return 'front'; } const VIEW_SCALE_BUDGETS = Object.freeze({ street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }), city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }), metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }), regional: Object.freeze({ 0: 4, 25: 10, 50: 20, 75: 30, 100: 40 }), // Full-globe views leave substantial radial screen space around the Earth. // Dense satellite fields can use that space without card overlap, so the // global row intentionally has a larger ceiling than the regional row. global: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 42, 100: 56 }), }); /** Clamp and canonicalize arbitrary input onto the five approved density stops. */ export function canonicalizeDensity(inputPct, fallback = 50) { const raw = Number(inputPct); if (!Number.isFinite(raw)) return canonicalizeDensity(fallback, 50); const pct = Math.max(0, Math.min(100, raw)); if (pct <= 25) return pct < 12.5 ? 0 : 25; if (pct < 75) return 50; return pct < 87.5 ? 75 : 100; } /** Derive the only valid enabled profile from a canonical density stop. */ export function profileForDensity(inputPct) { const stop = canonicalizeDensity(inputPct); if (stop <= 25) return 'SPARSE'; if (stop >= 75) return 'DENSE'; return 'BALANCED'; } /** Return the canonical default stop for an enabled profile or legacy alias. */ export function defaultDensityForProfile(profile) { const normalized = normalizeProfile(profile); if (normalized === 'SPARSE') return 25; if (normalized === 'DENSE') return 75; return 50; } /** Normalize current and legacy profile labels. OFF remains an enabled-state value. */ export function normalizeProfile(profile) { const raw = String(profile || '') .trim() .toUpperCase(); if (raw === 'OFF') return 'OFF'; if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE'; if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED'; if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE'; return null; } /** Normalize the user-selectable layer allocation strategy. */ export function normalizeAllocationStrategy( strategy, fallback = ALLOCATION_ELASTIC, ) { const raw = String(strategy || '') .trim() .toUpperCase(); if (ALLOCATION_STRATEGIES.includes(raw)) return raw; const normalizedFallback = String(fallback || '') .trim() .toUpperCase(); return ALLOCATION_STRATEGIES.includes(normalizedFallback) ? normalizedFallback : ALLOCATION_ELASTIC; } /** Classify camera altitude into the shared label-budget view scale. */ export function viewScaleForAltitude(altitudeM) { const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9; if (altitude < 1200) return 'street'; if (altitude < 4500) return 'city'; if (altitude < 20000) return 'metro'; if (altitude < 150000) return 'regional'; return 'global'; } /** Resolve the collective text-callout cap for an altitude and density stop. */ export function labelBudgetFor(altitudeM, densityPct) { const scale = viewScaleForAltitude(altitudeM); const stop = canonicalizeDensity(densityPct); return VIEW_SCALE_BUDGETS[scale][stop]; } /** * Migrate a persisted/legacy mode+density pair into one canonical state. * Legacy Panoptic/God intent resolves to Dense even when its historical * density was below 75; legacy Sparse remains inside the Sparse band. */ export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) { const rawMode = String(mode || '') .trim() .toUpperCase(); const normalized = normalizeProfile(rawMode); if (normalized === 'OFF') { const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity; const stop = canonicalizeDensity(supplied, fallbackDensity); return { enabled: false, profile: profileForDensity(stop), densityPct: stop, }; } if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') { const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75; return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density), }; } if (rawMode === 'SPARSE' || rawMode === 'SURVEY') { const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25; const stop = canonicalizeDensity(density, 25); return { enabled: true, profile: 'SPARSE', densityPct: stop }; } if (normalized === 'BALANCED' || normalized === 'DENSE') { const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized); const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized); return { enabled: true, profile: normalized, densityPct: stop }; } const stop = canonicalizeDensity(densityPct, fallbackDensity); return { enabled: true, profile: profileForDensity(stop), densityPct: stop }; }