mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The first formal release. - One-click install via Pinokio; keyless boot lands on a live Esri World Imagery satellite globe with keyless terrain, with automatic OSM fallback and graceful degradation when terrain is unavailable. - Provider Settings (the POWER UP panel): add, replace, or remove API keys inside the app; credential files made owner-only before any secret is written; external keys shown read-only; the panel refuses shared or proxied servers. - Keyless capability responses for the optional HUD summary and place search. - Aircraft-identity voice answers cover operator, type, and route, and say so plainly when enrichment is unavailable. - README rewritten keyless-first; concise 0.1.0 changelog section; CI workflow included. Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression 108/108, map-source tray QA pass, setup doctor ready.
530 lines
21 KiB
JavaScript
530 lines
21 KiB
JavaScript
import { getKeyholeGeometry } from './celestialRing.js';
|
|
|
|
/**
|
|
* Scope mask — the app's signature circular viewport treatment, made real.
|
|
*
|
|
* History (2026-08-08 owner field test): the scope was never implemented.
|
|
* It emerged from six zero-intensity style PostProcessStages whose stacked
|
|
* "identity" passes progressively smeared the starfield into a circular
|
|
* falloff — every grep for a mask came up empty because none existed. The
|
|
* owner ruled: draw it explicitly on a canvas, make the edge featherable
|
|
* (like the NVG/FLIR tube masks), and free the six shader passes for real.
|
|
*
|
|
* Implementation: one fixed canvas parented into the viewer container
|
|
* BELOW the detection surface (z-index 2 < 5) so detection's
|
|
* mix-blend-mode still composites over the masked scene, exactly like the
|
|
* old in-scene artifact. The mask is a radial gradient — fully transparent
|
|
* inside the shared keyhole circle, feathering to near-opaque page black
|
|
* outside — redrawn only on resize, tuning change, DPR change, or a
|
|
* QUANTIZED terminus-alpha step (see below). No rAF, no per-frame paint, no
|
|
* render-loop coupling.
|
|
*
|
|
* Altitude-adaptive edge terminus (owner-approved 2026-08-17, band retuned the
|
|
* same day after a field test): the outside fill's terminus alpha is 0.94 only
|
|
* at TRUE full-globe altitude — above 10 Mm, where the relaxed 6% keeps faint
|
|
* stars alive in the corners — and fades QUICKLY to fully opaque black on the
|
|
* way down, reaching solid black by 7 Mm. Every working altitude below that is
|
|
* opaque, because there the same 6% bleed reads as smeared geometry rather
|
|
* than atmosphere. FEATHER is untouched BY THIS RAMP — only the terminus opacity
|
|
* moves here. (The feather's own default later moved 35 → 0 on 2026-08-22; see
|
|
* SCOPE_FEATHER_RATIO_DEFAULT below. The two are independent.)
|
|
*
|
|
* Perf contract for that ramp: the height sample is throttled AND the repaint
|
|
* is gated on a QUANTIZED alpha step, so a full zoom gesture costs a handful
|
|
* of 2D-canvas repaints (~12 across the whole band — the alpha span, not the
|
|
* altitude span, sets that) and a parked camera costs none. Under the idle
|
|
* render governor a parked camera produces no frames at all, so the sampler is
|
|
* literally free at rest. SCOPE OFF costs even less: no sampling, and no
|
|
* canvas work beyond the single clear on the disable transition.
|
|
*/
|
|
|
|
/** Matches the page background the emergent scope faded into. */
|
|
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
|
/**
|
|
* Default edge feather as a fraction of the keyhole radius.
|
|
*
|
|
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
|
|
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
|
|
* set it to zero: a subtle soft edge rather than either the hard crop or the
|
|
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
|
|
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
|
|
* share link carries, or the operator dials in, overrides this immediately, and
|
|
* the hard-crop path at 0 is still reachable from the handle.
|
|
*
|
|
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
|
|
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
|
|
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
|
|
* fallback, which stays at 35 on purpose: a link predating that field was
|
|
* authored when 35 was what its author saw, and a link from the feather-0 era
|
|
* carries `scf=0` explicitly because the generator always writes the field.
|
|
* Pinned in reasonableDefaults.test.mjs.
|
|
*/
|
|
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11;
|
|
/**
|
|
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
|
|
* faint stars survive in the corners at globe scale.
|
|
*/
|
|
export const SCOPE_OUTSIDE_ALPHA = 0.94;
|
|
/**
|
|
* Lowest SUPPORTED terminus opacity, as a share-link percent. The band exists
|
|
* because anything below the globe-scale terminus is not a scope any more —
|
|
* it is a hole in the mask — so `sce` is clamped to it on the way in AND on
|
|
* the way out rather than round-tripping an unsupported value.
|
|
*/
|
|
export const SCOPE_TERMINUS_MIN_PCT = Math.round(SCOPE_OUTSIDE_ALPHA * 100);
|
|
export const SCOPE_TERMINUS_MAX_PCT = 100;
|
|
/** Terminus opacity at/below SCOPE_TERMINUS_NEAR_M — full black, no bleed. */
|
|
export const SCOPE_TERMINUS_ALPHA_NEAR = 1;
|
|
/**
|
|
* Camera height at/above which the terminus stays at SCOPE_OUTSIDE_ALPHA.
|
|
* Owner retune (2026-08-17 field test): the relaxed 6% corners belong to TRUE
|
|
* full-globe views only — "the moment we go past roughly 10 million m in
|
|
* altitude, looking at the world, it should start quickly fading into black".
|
|
*/
|
|
export const SCOPE_TERMINUS_FAR_M = 10_000_000;
|
|
/**
|
|
* Camera height at/below which the terminus is fully opaque. The fade is
|
|
* deliberately STEEP: 3 Mm below the far edge everything outside the scope is
|
|
* solid black, so every working altitude (orbital, regional, city) is opaque.
|
|
*/
|
|
export const SCOPE_TERMINUS_NEAR_M = 7_000_000;
|
|
/**
|
|
* Repaint granularity. The ramp spans 0.06 alpha, so 0.005 steps give ~12
|
|
* repaints across the ENTIRE 3 Mm band — the alpha span sets that count, so
|
|
* widening the altitude band costs nothing — and zero while hovering, since a
|
|
* still camera never crosses a step.
|
|
*/
|
|
export const SCOPE_TERMINUS_QUANTUM = 0.005;
|
|
/** Height-sample throttle. Bounds the sampler to ~8 Hz while the camera moves. */
|
|
const SCOPE_TERMINUS_SAMPLE_MS = 120;
|
|
|
|
let _canvas = null;
|
|
let _container = null;
|
|
let _viewer = null;
|
|
let _enabled = true;
|
|
let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
|
let _resizeObserver = null;
|
|
let _dprQuery = null;
|
|
let _dprListener = null;
|
|
/** Quantized terminus alpha currently PAINTED (drives the repaint gate). */
|
|
let _terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
|
/** null = adaptive; a number pins the terminus (share-link `sce`). */
|
|
let _terminusOverride = null;
|
|
let _cameraSampleRemover = null;
|
|
let _cameraMoveEndRemover = null;
|
|
let _lastTerminusSampleMs = -Infinity;
|
|
/** Repaints caused by a terminus step — evidence seam for the perf claim. */
|
|
let _terminusRepaints = 0;
|
|
/** Whether the canvas currently holds ink (drives the disabled-state early-out). */
|
|
let _painted = false;
|
|
/** Set while a coalescing scope is open; draw() defers to its single paint. */
|
|
let _paintDirty = false;
|
|
let _coalescingPaint = false;
|
|
|
|
/**
|
|
* Smoothstep ramp from the globe-scale terminus to full black.
|
|
* Pure — unit-tested directly.
|
|
* @param {number} heightM - Camera height above the ellipsoid, in metres.
|
|
* @returns {number} Terminus alpha in [SCOPE_OUTSIDE_ALPHA, 1].
|
|
*/
|
|
export function scopeTerminusAlpha(heightM) {
|
|
const h = Number(heightM);
|
|
if (!Number.isFinite(h)) return SCOPE_OUTSIDE_ALPHA; // unknown height → globe-scale default
|
|
if (h >= SCOPE_TERMINUS_FAR_M) return SCOPE_OUTSIDE_ALPHA;
|
|
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR;
|
|
// 0 at the far edge → 1 at the near edge, eased so neither end steps visibly.
|
|
const t = (SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
|
|
const eased = t * t * (3 - 2 * t);
|
|
return SCOPE_OUTSIDE_ALPHA + (SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased;
|
|
}
|
|
|
|
/**
|
|
* Snap a terminus alpha to the repaint grid. Equal quantized values mean the
|
|
* paint would be indistinguishable, so the repaint is skipped.
|
|
* @param {number} alpha
|
|
* @returns {number}
|
|
*/
|
|
export function quantizeScopeTerminusAlpha(alpha) {
|
|
const a = Math.max(0, Math.min(1, Number(alpha) || 0));
|
|
// Round the PRODUCT too: n * 0.005 lands on values like 0.9400000000000001,
|
|
// which would ride straight into the rgba() string. The quantum is 3-decimal,
|
|
// so 3 decimals is lossless here.
|
|
return Math.round(Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000) / 1000;
|
|
}
|
|
|
|
/**
|
|
* Clamp a share-link/UI terminus PERCENT into the supported band.
|
|
* Non-numeric input is not a value at all — it means "absent", i.e. adaptive.
|
|
* Pure — unit-tested directly.
|
|
* @param {*} value
|
|
* @returns {?number} 94..100, or null for adaptive.
|
|
*/
|
|
export function clampScopeTerminusPct(value) {
|
|
// Anything that is not an actual number — null, undefined, '', 'abc', a
|
|
// boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
|
|
// floor on every adaptive write.)
|
|
const raw = typeof value === 'string' ? value.trim() : value;
|
|
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === '')) return null;
|
|
const pct = Number(raw);
|
|
if (!Number.isFinite(pct)) return null;
|
|
return Math.max(SCOPE_TERMINUS_MIN_PCT, Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)));
|
|
}
|
|
|
|
/**
|
|
* Resolve the terminus alpha for a camera height, honoring any override, and
|
|
* repaint ONLY when the quantized value actually moves.
|
|
* @param {number} heightM - Camera height above the ellipsoid, in metres.
|
|
* @returns {boolean} Whether this call repainted.
|
|
*/
|
|
export function updateScopeTerminusForHeight(heightM) {
|
|
const target = quantizeScopeTerminusAlpha(
|
|
_terminusOverride == null ? scopeTerminusAlpha(heightM) : _terminusOverride,
|
|
);
|
|
if (target === _terminusAlpha) return false; // the whole perf story lives here
|
|
_terminusAlpha = target;
|
|
_terminusRepaints += 1;
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Pin the terminus alpha, or restore adaptive behavior.
|
|
* @param {?number} alpha - Alpha in [0,1], or null/undefined for adaptive.
|
|
* @returns {void}
|
|
*/
|
|
/**
|
|
* Run `fn` with paints COALESCED: any draw() it triggers only marks the canvas
|
|
* dirty, and exactly one paint happens at the end. The case that matters is a
|
|
* DPR change landing on the same tick as a terminus step — two full-viewport
|
|
* repaints where one will do.
|
|
* @param {Function} fn
|
|
* @returns {void}
|
|
*/
|
|
function withCoalescedPaint(fn) {
|
|
if (_coalescingPaint) { fn(); return; } // already inside a scope
|
|
_coalescingPaint = true;
|
|
_paintDirty = false;
|
|
try {
|
|
fn();
|
|
} finally {
|
|
_coalescingPaint = false;
|
|
if (_paintDirty) {
|
|
_paintDirty = false;
|
|
draw();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */
|
|
function currentTerminusTarget() {
|
|
return quantizeScopeTerminusAlpha(
|
|
_terminusOverride == null ? scopeTerminusAlpha(currentCameraHeightM()) : _terminusOverride,
|
|
);
|
|
}
|
|
|
|
export function setScopeTerminusOverride(alpha) {
|
|
if (alpha == null || !Number.isFinite(Number(alpha))) {
|
|
_terminusOverride = null;
|
|
} else {
|
|
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is
|
|
// a hole in the mask, not a scope, so every entry point floors it.
|
|
_terminusOverride = Math.max(SCOPE_OUTSIDE_ALPHA, Math.min(1, Number(alpha)));
|
|
}
|
|
// Re-resolve immediately against the live camera so the override is visible
|
|
// without waiting for the next sample.
|
|
const target = currentTerminusTarget();
|
|
if (target !== _terminusAlpha) {
|
|
_terminusAlpha = target;
|
|
_terminusRepaints += 1;
|
|
}
|
|
draw();
|
|
}
|
|
|
|
/** @returns {?number} The pinned terminus alpha, or null when adaptive. */
|
|
export function getScopeTerminusOverride() {
|
|
return _terminusOverride;
|
|
}
|
|
|
|
/** @returns {number} The terminus alpha currently painted. */
|
|
export function getScopeTerminusAlpha() {
|
|
return _terminusAlpha;
|
|
}
|
|
|
|
/** Test/diagnostics seam: repaints caused by terminus steps since install. */
|
|
export function getScopeTerminusRepaintCount() {
|
|
return _terminusRepaints;
|
|
}
|
|
|
|
/** Backing-store scale actually used by the last draw(). */
|
|
export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined' ? window.devicePixelRatio : 1)) {
|
|
return Math.min(2, Number(ratio) || 1);
|
|
}
|
|
|
|
/**
|
|
* Watch for devicePixelRatio changes and redraw.
|
|
*
|
|
* The ResizeObserver only fires on CONTENT-BOX changes, so dragging the
|
|
* window between a 1x and a 2x monitor keeps the same CSS size while the
|
|
* backing store stays at the old resolution — a permanently soft/aliased
|
|
* scope edge until something else happens to resize. `(resolution: Ndppx)`
|
|
* only matches the CURRENT ratio, so the listener is re-armed after every
|
|
* change (the standard DPR-watch pattern).
|
|
* @returns {void}
|
|
*/
|
|
function watchDevicePixelRatio() {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
teardownDevicePixelRatioWatch();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
let query;
|
|
try {
|
|
query = window.matchMedia(`(resolution: ${dpr}dppx)`);
|
|
} catch {
|
|
return; // no matchMedia support for resolution queries — resize still covers the common case
|
|
}
|
|
_dprQuery = query;
|
|
_dprListener = () => {
|
|
withCoalescedPaint(() => {
|
|
// Re-arm against the NEW ratio first, then repaint at the new scale.
|
|
watchDevicePixelRatio();
|
|
// Dragging a window between monitors mid-descent lands a DPR change and a
|
|
// terminus step on the same tick; fold both into the single paint below.
|
|
if (_enabled) updateScopeTerminusForHeight(currentCameraHeightM());
|
|
draw();
|
|
});
|
|
};
|
|
if (typeof query.addEventListener === 'function') query.addEventListener('change', _dprListener, { once: true });
|
|
else if (typeof query.addListener === 'function') query.addListener(_dprListener);
|
|
}
|
|
|
|
function teardownDevicePixelRatioWatch() {
|
|
if (_dprQuery && _dprListener) {
|
|
if (typeof _dprQuery.removeEventListener === 'function') _dprQuery.removeEventListener('change', _dprListener);
|
|
else if (typeof _dprQuery.removeListener === 'function') _dprQuery.removeListener(_dprListener);
|
|
}
|
|
_dprQuery = null;
|
|
_dprListener = null;
|
|
}
|
|
|
|
/**
|
|
* Compute the gradient stops for the scope mask.
|
|
* Pure — unit-tested directly.
|
|
* @param {number} width - Viewport CSS width.
|
|
* @param {number} height - Viewport CSS height.
|
|
* @param {number} featherRatio - Edge feather as a fraction of keyhole radius.
|
|
* @returns {{centerX:number, centerY:number, innerR:number, outerR:number,
|
|
* maxR:number}|null} Geometry, or null when the viewport is degenerate.
|
|
*/
|
|
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
|
const keyhole = getKeyholeGeometry(width, height);
|
|
if (!(keyhole.radius > 0)) return null;
|
|
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
|
|
// Feather straddles the keyhole edge so the visible radius stays anchored
|
|
// to the shared geometry every keyhole consumer (labels, cards) fades on.
|
|
const half = keyhole.radius * feather * 0.5;
|
|
return {
|
|
centerX: keyhole.centerX,
|
|
centerY: keyhole.centerY,
|
|
innerR: Math.max(0, keyhole.radius - half),
|
|
outerR: keyhole.radius + half,
|
|
maxR: Math.hypot(Math.max(keyhole.centerX, width - keyhole.centerX),
|
|
Math.max(keyhole.centerY, height - keyhole.centerY)),
|
|
};
|
|
}
|
|
|
|
function draw() {
|
|
if (_coalescingPaint) { _paintDirty = true; return; } // one paint at scope exit
|
|
if (!_canvas || !_container) return;
|
|
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the
|
|
// backing-store resize + clear (a full-viewport allocation) that used to run
|
|
// on every disabled draw. The last painted mask is cleared exactly once, on
|
|
// the transition, and after that a disabled mask does no canvas work at all.
|
|
if (!_enabled) {
|
|
if (!_painted) return;
|
|
const clearCtx = _canvas.getContext('2d');
|
|
if (clearCtx) {
|
|
clearCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
clearCtx.clearRect(0, 0, _canvas.width, _canvas.height);
|
|
}
|
|
_painted = false;
|
|
return;
|
|
}
|
|
const width = _container.clientWidth;
|
|
const height = _container.clientHeight;
|
|
if (!(width > 0) || !(height > 0)) return;
|
|
const dpr = scopeMaskDevicePixelRatio();
|
|
_canvas.width = Math.round(width * dpr);
|
|
_canvas.height = Math.round(height * dpr);
|
|
const ctx = _canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, width, height);
|
|
_painted = false; // resize+clear wiped the surface; ink goes on below
|
|
const geo = scopeMaskGeometry(width, height, _featherRatio);
|
|
if (!geo) return;
|
|
const { r, g, b } = SCOPE_OUTSIDE_COLOR;
|
|
if (geo.outerR - geo.innerR < 1) {
|
|
// Zero/near-zero feather: a radial gradient with equal radii is
|
|
// DEGENERATE in Canvas2D (Chromium paints nothing — browser
|
|
// finding). Draw the hard crop explicitly: rect minus circle, evenodd.
|
|
// The hard crop honors the same altitude terminus — a hard edge at city
|
|
// scale must be fully opaque too, not 6% translucent.
|
|
ctx.fillStyle = `rgba(${r},${g},${b},${_terminusAlpha})`;
|
|
ctx.beginPath();
|
|
ctx.rect(0, 0, width, height);
|
|
ctx.arc(geo.centerX, geo.centerY, Math.max(1, geo.innerR), 0, Math.PI * 2);
|
|
ctx.fill('evenodd');
|
|
_painted = true;
|
|
return;
|
|
}
|
|
const gradient = ctx.createRadialGradient(
|
|
geo.centerX, geo.centerY, geo.innerR,
|
|
geo.centerX, geo.centerY, geo.outerR,
|
|
);
|
|
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
|
|
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, height);
|
|
_painted = true;
|
|
}
|
|
|
|
/**
|
|
* Install the scope mask into the viewer container. Idempotent.
|
|
* @param {Cesium.Viewer} viewer
|
|
* @returns {void}
|
|
*/
|
|
export function installScopeMask(viewer) {
|
|
if (_canvas || !viewer?.container) return;
|
|
_container = viewer.container;
|
|
_viewer = viewer;
|
|
_canvas = document.createElement('canvas');
|
|
_canvas.id = 'scope-mask';
|
|
_canvas.setAttribute('aria-hidden', 'true');
|
|
_container.appendChild(_canvas);
|
|
_resizeObserver = new ResizeObserver(() => draw());
|
|
_resizeObserver.observe(_container);
|
|
watchDevicePixelRatio();
|
|
watchCameraHeight(viewer);
|
|
// Seed from the live camera so the first paint is already correct for the
|
|
// restored/initial altitude instead of flashing the globe-scale terminus.
|
|
_terminusAlpha = currentTerminusTarget();
|
|
draw();
|
|
}
|
|
|
|
/** @returns {number} Live camera height above the ellipsoid, or +Inf if unknown. */
|
|
function currentCameraHeightM() {
|
|
const height = _viewer?.camera?.positionCartographic?.height;
|
|
return Number.isFinite(height) ? height : Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
/**
|
|
* Sample camera height on the scene's existing frame signal, throttled, and
|
|
* let {@link updateScopeTerminusForHeight} decide whether a repaint is even
|
|
* warranted. Two cheap compares per rendered frame; under the idle governor a
|
|
* parked camera renders no frames at all, so this costs nothing at rest.
|
|
* moveEnd additionally pins the exact settled value.
|
|
* @param {Cesium.Viewer} viewer
|
|
* @returns {void}
|
|
*/
|
|
function watchCameraHeight(viewer) {
|
|
const preRender = viewer?.scene?.preRender;
|
|
if (preRender?.addEventListener) {
|
|
_cameraSampleRemover = preRender.addEventListener(() => {
|
|
// SCOPE OFF paints nothing, so it samples nothing: no height read, no
|
|
// throttle bookkeeping, no quantize — the listener is a single compare.
|
|
// setScopeMaskEnabled(true) re-syncs the alpha it skipped.
|
|
if (!_enabled) return;
|
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
|
|
_lastTerminusSampleMs = now;
|
|
updateScopeTerminusForHeight(currentCameraHeightM());
|
|
});
|
|
}
|
|
const moveEnd = viewer?.camera?.moveEnd;
|
|
if (moveEnd?.addEventListener) {
|
|
_cameraMoveEndRemover = moveEnd.addEventListener(() => {
|
|
if (!_enabled) return;
|
|
updateScopeTerminusForHeight(currentCameraHeightM());
|
|
});
|
|
}
|
|
}
|
|
|
|
function teardownCameraHeightWatch() {
|
|
if (_cameraSampleRemover) { _cameraSampleRemover(); _cameraSampleRemover = null; }
|
|
if (_cameraMoveEndRemover) { _cameraMoveEndRemover(); _cameraMoveEndRemover = null; }
|
|
_lastTerminusSampleMs = -Infinity;
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} enabled
|
|
* @returns {void}
|
|
*/
|
|
export function setScopeMaskEnabled(enabled) {
|
|
const next = Boolean(enabled);
|
|
const reEnabled = next && !_enabled;
|
|
_enabled = next;
|
|
if (reEnabled) {
|
|
// The camera moved freely while the scope was off and nothing sampled it,
|
|
// so the painted terminus can be a whole altitude band stale. Re-sync once
|
|
// here (not a "step", so it does not count as a terminus repaint) and let
|
|
// the next frame resume normal sampling.
|
|
_lastTerminusSampleMs = -Infinity;
|
|
_terminusAlpha = currentTerminusTarget();
|
|
}
|
|
draw();
|
|
}
|
|
|
|
/** @returns {boolean} */
|
|
export function isScopeMaskEnabled() {
|
|
return _enabled;
|
|
}
|
|
|
|
/**
|
|
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
|
|
* @returns {void}
|
|
*/
|
|
export function setScopeMaskFeather(ratio) {
|
|
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
|
|
draw();
|
|
}
|
|
|
|
/** @returns {number} */
|
|
export function getScopeMaskFeather() {
|
|
return _featherRatio;
|
|
}
|
|
|
|
/**
|
|
* Tear the mask down (canvas + observer). Reinstall with installScopeMask.
|
|
* @returns {void}
|
|
*/
|
|
export function destroyScopeMask() {
|
|
_resizeObserver?.disconnect();
|
|
_resizeObserver = null;
|
|
teardownDevicePixelRatioWatch();
|
|
teardownCameraHeightWatch();
|
|
_canvas?.remove();
|
|
_canvas = null;
|
|
_container = null;
|
|
_viewer = null;
|
|
_painted = false;
|
|
}
|
|
|
|
/** Test seam. */
|
|
export function _resetScopeMaskForTest() {
|
|
_resizeObserver?.disconnect();
|
|
_resizeObserver = null;
|
|
teardownDevicePixelRatioWatch();
|
|
teardownCameraHeightWatch();
|
|
_canvas?.remove();
|
|
_canvas = null;
|
|
_container = null;
|
|
_viewer = null;
|
|
_enabled = true;
|
|
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
|
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
|
_terminusOverride = null;
|
|
_terminusRepaints = 0;
|
|
_painted = false;
|
|
_paintDirty = false;
|
|
_coalescingPaint = false;
|
|
}
|