import * as Cesium from 'cesium'; /** * Focus-aware sprite treatment shared by the live contact layers. * * The selected target is protected by changing ambient sprite alpha, never by * collection ordering: Cesium's translucent distance sort is not stable for * collections with similar bounding volumes. This is a narrow amendment to * the normal always-visible contact rule. Ambient contacts remain present and * never fall below `dimFloor`; only sprites competing with the tracked target * are smoothly de-emphasized. * * The production consumers sample this service every 80 ms. The default * 300 ms attack and 600 ms release deliberately span several consumer * quanta, so a contact receives at least four distinct rendered emphasis * values during attack instead of crossing the range in one or two ticks. */ export const DEFAULT_FOCUS_DEEMPHASIS_PARAMS = Object.freeze({ paddingPx: 18, dimFloor: 0.25, nearerBehavior: 'allow', hysteresisPx: 6, distanceHysteresisRatio: 0.08, attackMs: 300, releaseMs: 600, writeEpsilon: 0.005, }); let _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS }; let _focusTarget = null; const _spriteStates = new WeakMap(); const _scratchScreen = new Cesium.Cartesian2(); const _advanceResult = { factor: 1, changed: false, transitioning: false, active: false, desired: 1, }; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function resolvedParams(overrides) { if (!overrides || overrides === _params) return _params; return { ..._params, ...overrides }; } function focusTargetEmphasisResolved( spriteScreenPosition, spriteCameraDistance, target, tuning, previouslyCompeting, spriteHalfWidthPx, spriteHalfHeightPx, previouslyFarther, ) { if (!target?.screenRect || !spriteScreenPosition) return 1; if ( !Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y) ) return 1; // Published rects already contain their publication-time padding. The // delta keeps direct pure-function calls and runtime A/B overrides honest. // The ambient extent makes overlap mean visual overlap, not center-point // containment: a class-sized icon whose edge intrudes into the focus rect // yields even when its center remains just outside it. const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0); const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0; const expansionX = paddingDelta + hysteresis + Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0); const expansionY = paddingDelta + hysteresis + Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0); const rect = target.screenRect; const inside = spriteScreenPosition.x >= rect.left - expansionX && spriteScreenPosition.x <= rect.right + expansionX && spriteScreenPosition.y >= rect.top - expansionY && spriteScreenPosition.y <= rect.bottom + expansionY; if (!inside) return 1; const floor = clamp(tuning.dimFloor, 0.01, 1); if (tuning.nearerBehavior === 'dim') return floor; const distanceBand = Number.isFinite(target.cameraDistance) ? Math.max(0, target.cameraDistance * tuning.distanceHysteresisRatio) : 0; const fartherThreshold = Number.isFinite(target.cameraDistance) ? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand) : Number.NaN; const farther = !Number.isFinite(spriteCameraDistance) || !Number.isFinite(fartherThreshold) || spriteCameraDistance >= fartherThreshold; if (farther) return floor; if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5; return 1; } function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) { const from = clamp( Number.isFinite(current) ? current : 1, tuning.dimFloor, 1, ); const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1); if (Math.abs(to - from) < Number.EPSILON) return to; const duration = to < from ? tuning.attackMs : tuning.releaseMs; if (duration <= 0) return to; const t = clamp( (Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1, ); // Cubic ease-out reaches the exact endpoint at the configured duration and // stays continuous when a moving contact reverses direction mid-transition. const eased = 1 - (1 - t) ** 3; return clamp(from + (to - from) * eased, tuning.dimFloor, 1); } /** * Replace the runtime tuning values used by every focus consumer. * Unknown keys are ignored so evidence-harness overrides cannot accidentally * grow an undocumented rendering contract. * @param {Partial} [patch] * @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current normalized values. */ export function setFocusDeemphasisParams(patch = {}) { const next = { ..._params }; if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx); if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1); if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) { next.nearerBehavior = patch.nearerBehavior; } if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx); if (Number.isFinite(patch.distanceHysteresisRatio)) { next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5); } if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs); if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs); if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon); _params = next; return { ..._params }; } /** @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current runtime tuning. */ export function getFocusDeemphasisParams() { return _params; } /** Restore production defaults. Primarily useful for deterministic QA. */ export function resetFocusDeemphasisParams() { _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS }; } /** * Publish a focus target from the exact world position already cached by its * tracking/render path. Callers must never re-derive a position here: a second * propagation/dead-reckoning sample at a different frame phase recreates the * tracked-target jitter class this service is designed to avoid. * * @param {object} input * @param {string} input.ownerLayer * @param {string|number} input.id * @param {Cesium.Scene} input.scene * @param {Cesium.Camera} input.camera * @param {Cesium.Cartesian3} input.displayPosition Cached display position. * @param {number} [input.widthPx=24] Projected visual width before padding. * @param {number} [input.heightPx=24] Projected visual height before padding. * @param {Partial} [input.params] * @returns {object|null} Published target, or null when it cannot be projected. */ export function publishFocusTargetFromCachedPosition({ ownerLayer, id, scene, camera, displayPosition, widthPx = 24, heightPx = 24, params, }) { if ( !ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition ) { clearFocusTarget(ownerLayer, id); return null; } const screen = Cesium.SceneTransforms.worldToWindowCoordinates( scene, displayPosition, _scratchScreen, ); if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) { clearFocusTarget(ownerLayer, id); return null; } const tuning = resolvedParams(params); const padding = tuning.paddingPx; const halfWidth = Math.max(1, widthPx) / 2; const halfHeight = Math.max(1, heightPx) / 2; const appeared = !_focusTarget; _focusTarget = { ownerLayer, id, screenRect: { left: screen.x - halfWidth - padding, top: screen.y - halfHeight - padding, right: screen.x + halfWidth + padding, bottom: screen.y + halfHeight + padding, }, paddingPx: padding, cameraDistance: Cesium.Cartesian3.distance( camera.positionWC, displayPosition, ), frameNumber: scene.frameState?.frameNumber ?? -1, }; if (appeared) { for (const listener of _focusAppearListeners) { try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); } } } return _focusTarget; } const _focusAppearListeners = new Set(); /** * Notify on the null→set edge of the shared focus target. Consumers that * self-suspend their per-frame focus work (e.g. CCTV's projection loop) use * this single edge to re-arm; the set→null edge needs no event because the * consumer's own loop is still running through the relax tail and * self-stops via focusPassIsNeeded. (perf wave 1) * @param {() => void} listener Called when a focus target is published after none existed. * @returns {() => void} Unsubscribe. */ export function onFocusTargetAppear(listener) { _focusAppearListeners.add(listener); return () => _focusAppearListeners.delete(listener); } /** * Clear the focus only when the caller still owns it. This prevents idle * tracked-layer ticks from erasing a target just published by another layer. * @param {string} [ownerLayer] * @param {string|number|null} [id] */ export function clearFocusTarget(ownerLayer, id = null) { if (!_focusTarget) return; if (ownerLayer && _focusTarget.ownerLayer !== ownerLayer) return; if (id !== null && id !== undefined && _focusTarget.id !== id) return; _focusTarget = null; } /** @returns {object|null} The current shared focus target. */ export function getFocusTarget() { return _focusTarget; } /** * Pure focus decision for one ambient sprite. * * `previouslyCompeting` is state, not tuning: it expands the exit edge by * `hysteresisPx` without moving the entry edge. Farther sprites dim fully; * nearer treatment deliberately remains a runtime tuning choice. * * @param {{x:number,y:number}|null} spriteScreenPosition * @param {number} spriteCameraDistance * @param {object|null} target * @param {Partial} [params] * @param {boolean} [previouslyCompeting=false] * @param {number} [spriteHalfWidthPx=0] Ambient visual half-width. * @param {number} [spriteHalfHeightPx=spriteHalfWidthPx] Ambient visual half-height. * @param {boolean} [previouslyFarther=previouslyCompeting] Prior distance-side state. * @returns {number} Target emphasis in [dimFloor, 1]. */ export function focusTargetEmphasis( spriteScreenPosition, spriteCameraDistance, target, params, previouslyCompeting = false, spriteHalfWidthPx = 0, spriteHalfHeightPx = spriteHalfWidthPx, previouslyFarther = previouslyCompeting, ) { const tuning = resolvedParams(params); return focusTargetEmphasisResolved( spriteScreenPosition, spriteCameraDistance, target, tuning, previouslyCompeting, spriteHalfWidthPx, spriteHalfHeightPx, previouslyFarther, ); } /** * Pure time-based easing step. Attack means motion toward de-emphasis; * release means restoration toward full emphasis. * @param {number} current * @param {number} target * @param {number} elapsedMs * @param {Partial} [params] * @returns {number} */ export function smoothFocusEmphasis(current, target, elapsedMs, params) { const tuning = resolvedParams(params); return smoothFocusEmphasisResolved(current, target, elapsedMs, tuning); } /** * Advance one sprite's persistent focus state. The caller owns the eventual * color write so focus can compose with freshness/horizon alpha at one site. * @param {object} sprite Stable billboard/point primitive identity. * @param {object} input * @param {{x:number,y:number}|null} input.screenPosition * @param {number} input.cameraDistance * @param {number} input.nowMs * @param {object|null} [input.target] * @param {Partial} [input.params] * @param {number} [input.spriteHalfWidthPx=0] * @param {number} [input.spriteHalfHeightPx=input.spriteHalfWidthPx] * * IMPORTANT: the returned object is a module-owned mutable singleton. Consume * every needed property before the next call; never retain it as a snapshot. * @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}} */ export function advanceSpriteFocus( sprite, { screenPosition, cameraDistance, nowMs, target = _focusTarget, params, spriteHalfWidthPx = 0, spriteHalfHeightPx = spriteHalfWidthPx, }, ) { const tuning = resolvedParams(params); let state = _spriteStates.get(sprite); if (!state) { if (!target) { _advanceResult.factor = 1; _advanceResult.changed = false; _advanceResult.transitioning = false; _advanceResult.active = false; _advanceResult.desired = 1; return _advanceResult; } state = { factor: 1, desired: 1, transitionFrom: 1, transitionStartMs: nowMs, wasFarther: false, }; _spriteStates.set(sprite, state); } // First sample the OLD transition at this timestamp. If the desired state // changes, the new transition must begin at the value already on screen, // not the prior tick's stale anchor. This preserves progress during a // retarget and, paired with range hysteresis, prevents boundary chatter // from continually restarting at 1.0. const oldElapsedMs = Math.max(0, nowMs - state.transitionStartMs); const current = smoothFocusEmphasisResolved( state.transitionFrom, state.desired, oldElapsedMs, tuning, ); const desired = focusTargetEmphasisResolved( screenPosition, cameraDistance, target, tuning, state.desired < 1, spriteHalfWidthPx, spriteHalfHeightPx, state.wasFarther, ); if (desired !== state.desired) { state.transitionFrom = current; state.transitionStartMs = nowMs; state.desired = desired; } const elapsedMs = Math.max(0, nowMs - state.transitionStartMs); const next = smoothFocusEmphasisResolved( state.transitionFrom, desired, elapsedMs, tuning, ); const changed = Math.abs(next - state.factor) > tuning.writeEpsilon; state.factor = next; state.desired = desired; state.wasFarther = tuning.nearerBehavior !== 'dim' && Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon; const transitioning = Math.abs(next - desired) > tuning.writeEpsilon; const active = Math.abs(next - 1) > tuning.writeEpsilon; if (!target && !transitioning && next === 1) _spriteStates.delete(sprite); _advanceResult.factor = next; _advanceResult.changed = changed; _advanceResult.transitioning = transitioning; _advanceResult.active = active; _advanceResult.desired = desired; return _advanceResult; } /** * Project and advance one world-anchored sprite using an owning layer's * existing render pass. * @param {object} sprite Stable primitive identity. * @param {Cesium.Cartesian3} position Current cached/rendered world position. * @param {Cesium.Scene} scene * @param {Cesium.Camera} camera * @param {number} nowMs * @param {object|null} [target] * @param {Partial} [params] * @param {number} [spriteHalfWidthPx=0] * @param {number} [spriteHalfHeightPx=spriteHalfWidthPx] * @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}} */ export function advanceProjectedSpriteFocus( sprite, position, scene, camera, nowMs, target = _focusTarget, params, spriteHalfWidthPx = 0, spriteHalfHeightPx = spriteHalfWidthPx, ) { // Common case: nothing tracked and this sprite holds no dimming state — // advanceSpriteFocus would return identity without reading the projection, // so skip the worldToWindowCoordinates round trip entirely (it was N // wasted projections per fleet tick with nothing tracked). Sprites still // relaxing keep their state entry until fully settled (see the delete at // the transition tail), so they keep projecting until done. (perf item 8) if (!target && !_spriteStates.has(sprite)) { _advanceResult.factor = 1; _advanceResult.changed = false; _advanceResult.transitioning = false; _advanceResult.active = false; _advanceResult.desired = 1; return _advanceResult; } const screen = position && scene ? Cesium.SceneTransforms.worldToWindowCoordinates( scene, position, _scratchScreen, ) : null; const cameraDistance = position && camera?.positionWC ? Cesium.Cartesian3.distance(camera.positionWC, position) : Number.NaN; return advanceSpriteFocus(sprite, { screenPosition: screen, cameraDistance, nowMs, target, params, spriteHalfWidthPx, spriteHalfHeightPx, }); } /** * A gated consumer must keep ticking until every previously dimmed sprite has * returned to identity, even when no target remains. * @param {object|null} target * @param {number} activeCount Count of emphasis factors outside the 1.0 deadband. * @returns {boolean} */ export function focusPassIsNeeded(target, activeCount) { return ( (target !== null && target !== undefined) || (Number.isFinite(activeCount) && activeCount > 0) ); } /** * Match Cesium's shader-side NearFarScalar interpolation for rendered extents. * Cesium interpolates against squared camera distance with a 0.2 exponent. * @param {{near:number,nearValue:number,far:number,farValue:number}|null} scalar * @param {number} cameraDistanceM * @returns {number} */ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) { if (!scalar || !Number.isFinite(cameraDistanceM)) return 1; const nearSq = scalar.near * scalar.near; const farSq = scalar.far * scalar.far; if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1; const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq); const t = Math.pow(clamp(rawT, 0, 1), 0.2); return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t; } /** Shared virtual time used only by the DEV evidence seam. */ let _focusEvidenceNowMs = null; /** Resolve production time unless deterministic evidence capture owns it. */ export function focusNowMs(productionNowMs) { return _focusEvidenceNowMs ?? productionNowMs; } /** Set deterministic evidence time; called only through development seams. */ export function setFocusEvidenceNowMs(nowMs) { _focusEvidenceNowMs = Number.isFinite(nowMs) ? nowMs : null; return _focusEvidenceNowMs; } /** Advance deterministic evidence time without consulting a wall clock. */ export function advanceFocusEvidenceNowMs(deltaMs) { if (_focusEvidenceNowMs === null) return null; _focusEvidenceNowMs += Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0); return _focusEvidenceNowMs; } /** Forget state when a primitive is removed from its owning collection. */ export function forgetSpriteFocus(sprite) { if (sprite) _spriteStates.delete(sprite); } /** * Deadband predicate shared by consumers' final alpha write sites. * @param {number} currentAlpha * @param {number} nextAlpha * @param {Partial} [params] * @returns {boolean} */ export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) { const tuning = resolvedParams(params); return ( !Number.isFinite(currentAlpha) || Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon ); }