Files
gods-eye-view/src/celestialRing.js
T
Bilawal Sidhu b6da93b8ac release: v0.1.0 — one-click install, keyless boot, in-app Provider Settings (#142)
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.
2026-08-31 17:49:45 -05:00

715 lines
28 KiB
JavaScript

import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
/** Clearance required before entering the visible state. */
export const GLOBE_ENTER_CLEARANCE_PX = 24;
/** Clearance below which the overlay leaves the visible state. */
export const GLOBE_EXIT_CLEARANCE_PX = 12;
/** Minimum stable length of a celestial direction projected into the camera plane. */
export const CELESTIAL_PLANE_EPSILON = 0.045;
/** Responsive radial fade band used by every keyhole-aligned text overlay —
* this is the Detection FADE (label/card fading), NOT the scope-mask feather
* in scopeMask.js. 0.07 since the 2026-08-24 owner final lock (was 0.16). */
export const KEYHOLE_LABEL_FEATHER_RATIO = 0.07;
export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
/**
* First-run OUTSIDE opacity for keyhole-aligned world overlays.
*
* 0.01 since 2026-08-24 (owner final lock; 0.03 on 08-23, 0.05 before). Keep in lockstep with
* `#detection-opacity-slider`'s markup value AND readout in index.html,
* `_detectionOutsideOpacityPct` in sharelink.js,
* `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in ui.js, and
* `AIRCRAFT_BRACKET_FLOOR_ANCHOR` in detectionPolicy.js — a fresh boot applies
* no restore, so those literals ARE the first-run state. NOT the `ko` PARSE
* fallback, which stays at 5 on purpose: a link predating that field was
* authored when 5 was what its author saw. Pinned in reasonableDefaults.test.mjs.
*/
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
const MARKER_COLLISION_ANGLE = Cesium.Math.toRadians(6);
const FULL_GLOBE_RADIUS_RATIO = 0.61;
const TAU = Math.PI * 2;
// This overlay is a secondary HUD treatment. Keep it smooth while reserving
// the majority of the frame budget for Cesium's terrain and 3D tiles.
export const CELESTIAL_MAX_FRAME_RATE = 30;
// There are two independently rotating effect canvases (sun and moon), so
// this is a per-layer budget; their combined allocation remains comparable to
// the former single 4 MP canvas.
export const CELESTIAL_MAX_BACKING_PIXELS = 2_000_000;
export const CELESTIAL_MAX_BACKING_DIMENSION = 1_600;
const CELESTIAL_MAX_DEVICE_PIXEL_RATIO = 1.25;
let keyholeFadeRatio = KEYHOLE_LABEL_FEATHER_RATIO;
let keyholeOutsideOpacity = KEYHOLE_OUTSIDE_OPACITY_DEFAULT;
/** Clamp a number to an inclusive range. */
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/** Update the shared fade distance and outside-opacity floor. */
export function setKeyholeFadeTuning({ fadeRatio, outsideOpacity } = {}) {
if (Number.isFinite(fadeRatio)) {
keyholeFadeRatio = clamp(fadeRatio, 0, KEYHOLE_LABEL_FEATHER_MAX_RATIO);
}
if (Number.isFinite(outsideOpacity)) {
keyholeOutsideOpacity = clamp(outsideOpacity, 0, 1);
}
return getKeyholeFadeTuning();
}
/** Read the current normalized keyhole fade settings. */
export function getKeyholeFadeTuning() {
return { fadeRatio: keyholeFadeRatio, outsideOpacity: keyholeOutsideOpacity };
}
/** Return the single shared screen-space keyhole circle and label feather. */
export function getKeyholeGeometry(width, height) {
const w = Number(width);
const h = Number(height);
if (!(w > 0) || !(h > 0)) {
return { centerX: 0, centerY: 0, radius: 0, featherPx: 0 };
}
const radius = h * 0.5 * KEYHOLE_OUTER_RADIUS;
return {
centerX: w * 0.5,
centerY: h * 0.5,
radius,
featherPx: radius * keyholeFadeRatio,
};
}
/**
* Compute the radial opacity for a callout's visual center. Text remains fully
* opaque inside the keyhole and fades linearly to the configured outside-opacity
* floor through a band derived from the live keyhole radius.
*/
export function keyholeLabelAlpha(labelX, labelY, width, height) {
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
}
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
const feather = geometry.featherPx;
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
if (distance <= geometry.radius) return 1;
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress;
}
/** Normalize an angle to [0, 2π). */
export function normalizeAngle(angle) {
const wrapped = angle % TAU;
return wrapped < 0 ? wrapped + TAU : wrapped;
}
/** Return the shortest unsigned distance between two circular angles. */
export function circularAngleDistance(a, b) {
const delta = Math.abs(normalizeAngle(a) - normalizeAngle(b));
return Math.min(delta, TAU - delta);
}
/** The optical celestial treatment is intentionally limited to Normal view. */
export function isCelestialRingStyleSupported(styleName) {
return styleName === 'normal';
}
/**
* Resolve a screen-space direction angle. Canvas Y grows downward, so camera-up
* is inverted. When the celestial vector points almost directly into/out of the
* camera, retain the prior stable bearing and fade the marker.
*
* @param {number} rightComponent - Dot(direction, camera.rightWC).
* @param {number} upComponent - Dot(direction, camera.upWC).
* @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}}
*/
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return {
angle: normalizeAngle(Number.isFinite(lastAngle) ? lastAngle : 0),
opacity: clamp(planeLength / CELESTIAL_PLANE_EPSILON, 0, 1),
stable: false,
};
}
return {
angle: normalizeAngle(Math.atan2(-upComponent, rightComponent)),
opacity: clamp((planeLength - CELESTIAL_PLANE_EPSILON) / 0.16, 0.28, 1),
stable: true,
};
}
/**
* Test whether a projected Earth disc is completely inside the shared keyhole.
* The different enter/exit clearances provide hysteresis while zooming.
*
* @param {object} geometry
* @param {number} geometry.earthCenterX
* @param {number} geometry.earthCenterY
* @param {number} geometry.earthRadius
* @param {number} geometry.keyholeCenterX
* @param {number} geometry.keyholeCenterY
* @param {number} geometry.keyholeRadius
* @param {boolean} wasVisible
* @returns {boolean}
*/
export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
const {
earthCenterX,
earthCenterY,
earthRadius,
keyholeCenterX,
keyholeCenterY,
keyholeRadius,
} = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
const clearance = keyholeRadius - (offset + earthRadius);
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
}
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if (
!Number.isFinite(cameraDistance)
|| cameraDistance <= earthRadiusM
|| !(viewportHeight > 0)
|| !Number.isFinite(fovy)
|| fovy <= 0
|| fovy >= Math.PI
) return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
return Number.isFinite(radius) && radius > 0 ? radius : null;
}
/**
* Project the visible Earth disc into viewport coordinates.
* Shared by full-globe UI treatments that must agree on visual containment.
*
* @param {object} viewer - Cesium Viewer-like object.
* @param {number} width - Viewport width in CSS pixels.
* @param {number} height - Viewport height in CSS pixels.
* @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result.
* @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result.
* @returns {object|null}
*/
export function projectEarthDiscToViewport(
viewer,
width,
height,
scratchCenter = undefined,
scratchToCenter = undefined,
) {
const camera = viewer?.camera;
const scene = viewer?.scene;
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
const toCenter = Cesium.Cartesian3.negate(
camera.positionWC,
scratchToCenter || new Cesium.Cartesian3(),
);
if (Cesium.Cartesian3.dot(camera.directionWC, toCenter) <= 0) return null;
const center = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
Cesium.Cartesian3.ZERO,
scratchCenter,
);
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height);
return {
earthCenterX: center.x,
earthCenterY: center.y,
earthRadius,
keyholeCenterX: keyhole.centerX,
keyholeCenterY: keyhole.centerY,
keyholeRadius: keyhole.radius,
};
}
/** Draw one tapered orbital arc around a celestial marker. */
function drawTaperedArc(ctx, cx, cy, radius, angle, rgb, strength) {
const span = 0.82;
const segments = 18;
ctx.save();
ctx.lineCap = 'round';
ctx.shadowColor = `rgba(${rgb}, ${0.5 * strength})`;
ctx.shadowBlur = 9;
for (let i = 0; i < segments; i++) {
const t0 = i / segments;
const t1 = (i + 1) / segments;
const a0 = angle - span + span * 2 * t0;
const a1 = angle - span + span * 2 * t1;
const envelope = Math.sin(Math.PI * ((t0 + t1) * 0.5));
ctx.strokeStyle = `rgba(${rgb}, ${strength * envelope * envelope})`;
ctx.lineWidth = 0.7 + envelope * 1.25;
ctx.beginPath();
ctx.arc(cx, cy, radius, a0, a1);
ctx.stroke();
}
ctx.restore();
}
/** Draw the soft directional rays cast inward from the sun marker. */
function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
const sx = cx + Math.cos(angle) * radius;
const sy = cy + Math.sin(angle) * radius;
ctx.save();
// The reference keeps the globe untouched: all illumination lives in the
// annulus between the Earth limb and the outer optics ring.
ctx.beginPath();
ctx.arc(cx, cy, radius + 3, 0, TAU);
ctx.arc(cx, cy, innerRadius, 0, TAU, true);
ctx.clip('evenodd');
ctx.globalCompositeOperation = 'screen';
const glow = ctx.createRadialGradient(sx, sy, 4, sx, sy, radius * 0.94);
glow.addColorStop(0, 'rgba(255, 222, 126, 0.17)');
glow.addColorStop(0.22, 'rgba(255, 229, 157, 0.085)');
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow;
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
ctx.shadowBlur = 18;
const annulusWidth = Math.max(1, radius - innerRadius);
for (let i = -2; i <= 2; i++) {
const spread = i * 0.043;
const rayAngle = angle + Math.PI + spread;
const rayLength = annulusWidth * (0.7 + (2 - Math.abs(i)) * 0.07);
const ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
}
ctx.restore();
}
/** Draw a restrained cool haze around the moon sector. */
function drawMoonHaze(ctx, cx, cy, radius, angle) {
const mx = cx + Math.cos(angle) * radius;
const my = cy + Math.sin(angle) * radius;
const haze = ctx.createRadialGradient(mx, my, 0, mx, my, radius * 0.22);
haze.addColorStop(0, 'rgba(63, 214, 255, 0.16)');
haze.addColorStop(0.42, 'rgba(63, 214, 255, 0.055)');
haze.addColorStop(1, 'rgba(63, 214, 255, 0)');
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = haze;
ctx.beginPath();
ctx.arc(mx, my, radius * 0.22, 0, TAU);
ctx.fill();
ctx.restore();
}
/**
* Screen-space ring and celestial direction overlay for the full-globe view.
*/
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
*/
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false;
this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0;
this._sunAngle = 0;
this._moonAngle = Math.PI;
this._sunOpacity = 1;
this._moonOpacity = 1;
this._debug = null;
this._nextDrawAt = 0;
this._renderScale = 1;
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
this._sunInertial = new Cesium.Cartesian3();
this._moonInertial = new Cesium.Cartesian3();
this._sunFixed = new Cesium.Cartesian3();
this._moonFixed = new Cesium.Cartesian3();
this._toCenter = new Cesium.Cartesian3();
this._screenCenter = new Cesium.Cartesian2();
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each
// minute and request the one frame that repaints the ring.
this._ephemerisTimer = setInterval(() => {
if (!this.enabled) return;
// Always mark dirty so a long-hidden interval can't serve stale
// sun/moon vectors on return — but only request the repaint frame
// while visible; the visibility-restore request (main.js) picks the
// dirty flag up immediately. (review finding)
this._ephemerisDirty = true;
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
this.setEnabled(this.enabled);
}
/** Construct cached effect layers and Material Symbols celestial markers. */
_buildDOM() {
this._root = document.createElement('div');
this._root.id = 'celestial-ring-overlay';
this._root.className = 'celestial-ring-overlay';
this._root.setAttribute('aria-hidden', 'true');
this._ringOutline = document.createElement('div');
this._ringOutline.className = 'celestial-ring-outline';
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._sunMarker = document.createElement('span');
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
this._ringOutline,
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker
);
this.viewer.container.appendChild(this._root);
}
/** Enable or disable the user preference for the effect. */
setEnabled(enabled) {
const wasEnabled = this.enabled;
this.enabled = !!enabled;
if (this.enabled && !wasEnabled) this._ephemerisDirty = true;
// The ring paints its canvases from postRender, which only fires on
// rendered frames — under the idle render governor an enable (or the
// clearing disable) must request its frame or the ring never draws at
// all. Camera motion covers every later repaint; the 60 s ephemeris
// timer requests its own. (perf wave 2 fix — owner playtest finding)
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
this._root.classList.toggle('disabled', !this.enabled);
if (!this.enabled) {
this.visible = false;
this._root.classList.remove('visible');
this._clear();
}
}
/** Whether the current camera already frames the complete globe inside the keyhole. */
isGlobeFullyVisible() {
const canvas = this.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return false;
const disc = this._projectedEarthDisc(width, height);
return !!disc && isFullGlobeInsideKeyhole(disc, false);
}
/**
* Fly outward to the full-globe composition used by the celestial ring while
* preserving the hemisphere currently beneath the camera.
* @param {{duration?:number}} [options]
* @returns {boolean} Whether a valid camera flight was started.
*/
focusFullGlobe({ duration = 2.4 } = {}) {
const canvas = this.viewer.scene.canvas;
const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy;
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
return false;
}
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan(
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
this._focusInProgress = true;
const finishFocus = () => {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude
),
orientation: {
heading: this.viewer.camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: finishFocus,
cancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
return true;
}
/** Clear the backing canvas. */
_clear() {
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
this._sunRenderKey = '';
this._moonRenderKey = '';
this._outlineRenderKey = '';
}
/** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
const bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * dpr));
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._renderScale = dpr;
}
/**
* Sample Earth-fixed directions once per enable. Camera movement only
* re-projects these cached vectors; it never re-runs the planetary model.
*/
_updateEphemeris(time) {
if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false;
this._ephemerisUpdateCount += 1;
return true;
}
/** Disable after the camera leaves the complete-globe composition. */
_autoDisable() {
if (!this.enabled) return;
this.setEnabled(false);
this._onAutoDisable?.();
}
/** Return the projected Earth disc used for the full-globe gate. */
_projectedEarthDisc(width, height) {
return projectEarthDiscToViewport(
this.viewer,
width,
height,
this._screenCenter,
this._toCenter,
);
}
/** Position one icon-library marker along the keyhole circumference. */
_positionMarker(marker, cx, cy, radius, angle, opacity) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
marker.style.opacity = String(opacity);
}
/** Update the cached effect geometry only when camera distance or viewport changes. */
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
const outlineKey = `${cx}:${cy}:${radius}`;
if (outlineKey !== this._outlineRenderKey) {
this._ringOutline.style.display = '';
this._ringOutline.style.left = `${cx - radius}px`;
this._ringOutline.style.top = `${cy - radius}px`;
this._ringOutline.style.width = `${radius * 2}px`;
this._ringOutline.style.height = `${radius * 2}px`;
this._outlineRenderKey = outlineKey;
}
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) {
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey;
}
if (outlineKey !== this._moonRenderKey) {
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey;
}
}
/** Per-frame camera projection with GPU-composited cached effect layers. */
_draw() {
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
const now = performance.now();
if (now < this._nextDrawAt) return;
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
const canvas = this.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return;
this._resize(width, height);
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
if (!nextVisible || !disc) {
this._clear();
this._debug = { enabled: true, visible: false, disc };
if (!this._focusInProgress) this._autoDisable();
return;
}
if (!wasVisible) this._ephemerisDirty = true;
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
const camera = this.viewer.camera;
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle
);
const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle
);
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
this._sunOpacity = sunProjection.opacity;
this._moonOpacity = moonProjection.opacity;
const cx = width * 0.5;
const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX;
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
this._moonCanvas.style.opacity = String(this._moonOpacity);
// Markers sit inside the illuminated band rather than straddling its outer
// stroke. Near conjunction (such as a new moon), keep the true bearings but
// move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
< MARKER_COLLISION_ANGLE;
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity
);
this._debug = {
enabled: true,
visible: true,
sunAngle: this._sunAngle,
moonAngle: this._moonAngle,
sunOpacity: this._sunOpacity,
moonOpacity: this._moonOpacity,
markersCollide,
ephemerisUpdates: this._ephemerisUpdateCount,
renderScale: this._renderScale,
disc: { ...disc },
};
}
/** Read-only geometry snapshot for browser QA. */
getDebugState() {
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
}
/** Detach the render hook and remove all overlay DOM. */
destroy() {
if (this._ephemerisTimer) {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
this._root?.remove();
}
}