Files
gods-eye-view/src/data/detectionRenderDemand.js
T

197 lines
8.9 KiB
JavaScript

/**
* Detection's render demand — when the overlay actually needs another frame.
*
* Detection used to take an unconditional continuous-render hold for as long as
* it was on (`holdContinuousRender('detection')`). That was honest when it was
* written: the overlay repainted from scratch every frame, so "on" really did
* mean "per-frame work". It stopped being honest once the world-overlay host
* became demand-driven — `invalidateHost()` already requests a frame on every
* content change, and Cesium's `requestRenderMode` already renders on camera
* input and tile loads, so a parked scene with detection on was paying 60 fps
* for pixels that do not change.
*
* Nobody noticed because detection was OFF by default. Turning it ON by default
* (2026-08-22) would have shipped that hot loop to every idle first-run tab —
* defeating the render governor, whose whole reason for existing is the ~60% GPU
* and ~54% of a core a parked scene used to burn (`src/renderGovernor.js`).
*
* So detection no longer holds. It is driven the way the rest of the host is:
* repaint on CHANGE, and — for work that spans frames — ask for exactly one more
* frame while that work is outstanding. That is the pattern `drawWorldOverlay`
* already uses for its own fades, and the same shape as the earthquake-disc fix
* (static geometry, hold removed) that this follows.
*
* ── THE ONE RULE ─────────────────────────────────────────────────────────────
*
* Every input below must be able to reach a state where it stops asking. A
* predicate that can stay true forever is the old hold under another name, and
* that is the property the unit pins defend hardest.
*
* Three kinds of outstanding work qualify, and each terminates:
*
* 1. the fade-in when detection (re)activates — a bounded window;
* 2. label fades, IN as well as out — the arbiter drives every one of them to
* a settled alpha;
* 3. a label solve the frame could not run — the throttle defers it by at most
* one interval, a skipped paint by exactly one frame.
*
* (2) said "out" only, once. A newly selected label fading IN is `selected`, so
* counting the fade-OUT tail missed it entirely: on a parked scene a label could
* be selected and then never painted past its first frame — invisible until some
* unrelated frame happened along. Fade-in and fade-out are the same animation
* seen from two ends, with the same termination. Both count.
*
* ── ONE CLOCK ────────────────────────────────────────────────────────────────
*
* The timestamp is the host frame's own `frame.timestamp` — `performance.now()`,
* sampled ONCE per frame by `drawWorldOverlay` — and the SAME value must reach
* both the paint and this policy.
*
* Two samples of a clock taken a millisecond apart inside one frame is a real
* defect, not pedantry: paint at age 219 ms draws alpha 0.99545, and a policy
* re-sampling at 220 ms answers "done", so the frame that would have painted the
* settled alpha is never requested and the fade visibly stops a hair short.
* Sharing one timestamp makes the frame that paints final state the same frame
* that ends demand, by construction. Monotonicity removes the other half of that
* defect: a wall clock can jump backwards and leave an elapsed-time test true
* until it catches up, which is exactly the "asks forever" failure this module
* exists to prevent.
*
* Pure functions + constants so the policy is unit-testable without a viewer or
* a canvas; detection.js owns the plumbing.
*/
/** Duration of the fade-in when detection (re)activates, in ms. */
export const DETECTION_ENABLE_FADE_MS = 220;
/**
* Cadence of the scanline's scroll, in ms per 2px step — one frame at 60 fps.
*
* Derived rather than written as `16`: a rounded 16 ms is 62.5 Hz, which against
* a real 60 fps frame clock beats slowly and repeats an offset every few frames
* instead of alternating. Deriving the cadence from a clock rather than from a
* frame counter is what lets the animation rest without a render hold.
*/
export const SCANLINE_STEP_MS = 1000 / 60;
/** Scanline pattern period in px — the offset walks 0, 2, 0, 2, … */
export const SCANLINE_PERIOD_PX = 4;
/**
* Scanline scroll offset for a monotonic frame timestamp.
*
* @param {number} nowMs - Frame timestamp in ms (`performance.now()`).
* @returns {number} Vertical offset in px, within [0, SCANLINE_PERIOD_PX).
*/
export function scanlineOffsetPx(nowMs) {
if (!Number.isFinite(nowMs)) return 0;
return (Math.floor(nowMs / SCANLINE_STEP_MS) * 2) % SCANLINE_PERIOD_PX;
}
/**
* Whether detection still has work spanning frames, and therefore needs one more
* frame after the one it just painted.
*
* Returns false for everything change-driven: those paths already request their
* own frame through `invalidateHost()`, and asking here as well would turn a
* bounded chain into a permanent one.
*
* @param {object} input
* @param {boolean} input.active - Detection is on and not suspended.
* @param {number} input.nowMs - The frame's monotonic timestamp (see header).
* @param {number} input.enabledAtMs - When detection last (re)activated, same clock.
* @param {number} [input.fadeMs] - Enable fade-in duration.
* @param {number} [input.animatingLabelCount] - Labels mid-fade, IN or out.
* @param {boolean} [input.solvePending] - A label solve is owed but did not run.
* @returns {boolean} True when one more frame is owed.
*/
export function detectionNeedsFollowUpFrame({
active,
nowMs,
enabledAtMs,
fadeMs = DETECTION_ENABLE_FADE_MS,
animatingLabelCount = 0,
solvePending = false,
} = {}) {
if (!active) return false;
if (Number(animatingLabelCount) > 0) return true;
if (solvePending === true) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(enabledAtMs)) return false;
const age = nowMs - enabledAtMs;
// `age >= 0` is the fail-toward-idle guard. It cannot trip on the monotonic
// clock this is fed, but if a caller ever mixes clocks the wrong answer must
// be "stop asking", never "keep asking until time catches up".
return age >= 0 && age < fadeMs;
}
/**
* Paint cost above which the relief valve may skip alternate detection paints.
* Unchanged from when the valve was introduced; only its demand accounting moved.
*/
export const DETECTION_PAINT_SKIP_THRESHOLD_MS = 22;
/**
* The pathological-load relief valve: may this frame's detection paint be
* skipped, and if so, what does the skip owe?
*
* Extracted as a pure decision because the answer has two halves that MUST stay
* welded together. Under the old continuous hold a skip cost nothing — the next
* frame was guaranteed — so the valve could simply return false and forget. With
* no hold, the frame it declines may be the only one anybody asked for, and a
* skip that forgets strands whatever requested it: a reactivation, a new label,
* a fresh solve. Skipping is a DEFERRAL, never a cancellation.
*
* `requestFollowUp` is therefore exactly `skip`, and that equality is the
* invariant worth pinning — a wrapper cannot skip without re-requesting, because
* the same decision produces both.
*
* @param {object} input
* @param {boolean} input.layoutChanged - The host's layout revision moved this frame.
* @param {number} input.lastPaintMs - Cost of the previous detection paint.
* @param {number} input.frameCount - Monotonic detection frame counter.
* @param {number} [input.thresholdMs]
* @returns {{skip: boolean, requestFollowUp: boolean}}
*/
export function detectionPaintSkipDecision({
layoutChanged,
lastPaintMs,
frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) {
const skip =
!layoutChanged &&
Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip };
}
/**
* Whether a rendered label row's alpha will still change on a later frame.
*
* A `selected` row is still climbing while its alpha is below 1. An unselected
* row only survives the arbiter's render pass while its exit tail is above 0, so
* any that reaches here is by definition still moving. Stateless rows have hard
* cliffs (exactly 1, or gone) and never animate.
*
* @param {{selected?: boolean, temporalAlpha?: number}} entry - Arbiter render row.
* @returns {boolean}
*/
export function renderEntryIsAnimating(entry) {
if (!entry) return false;
if (!entry.selected) return true;
return Number(entry.temporalAlpha) < 1;
}
/**
* Count rendered rows whose alpha is still moving, in either direction.
* @param {Array<{selected?: boolean, temporalAlpha?: number}>} renderEntries
* @returns {number}
*/
export function countAnimatingRenderEntries(renderEntries) {
let count = 0;
for (let i = 0; i < renderEntries.length; i++) {
if (renderEntryIsAnimating(renderEntries[i])) count++;
}
return count;
}