Files
gods-eye-view/src/celestialRing.js
T
Sameh Khamis 265aa605bd feat(celestial-ring): render the full-globe ring from a store
The celestial ring presents its overlay to a store and the scene chrome
renders it in the globe container, handing back the two canvases the
ring paints. The application shell passes the store in so the keyhole
helpers other packages import stay free of Vue. Without its canvases the
ring still tracks whether the globe is framed and turns itself off.
2026-10-31 21:20:27 -07:00

902 lines
30 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();
}
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
let rings = 0;
/**
* Screen-space ring and celestial direction overlay for the full-globe view.
* The overlay is presented to the celestial ring store, whose view renders it
* inside the viewer's container and hands back the two canvases this paints.
*/
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
* the keyhole helpers here stay free of Vue for the packages that use
* them.
*/
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
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._sunCanvas = null;
this._sunCtx = null;
this._moonCanvas = null;
this._moonCtx = null;
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._initView(store);
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);
}
/** Present the overlay and take the canvases its view hands back. */
_initView(store) {
const owner = {};
this._store = store;
this._owner = owner;
this._view = {
id: ++rings,
owner,
container: this.viewer.container,
enabled: this.enabled,
visible: false,
globeVisible: null,
ephemerisUpdates: null,
size: null,
outline: null,
sun: null,
moon: null,
sunMarker: null,
moonMarker: null,
};
this._viewKey = '';
store.publish(this._view);
store.connect({
attach: (from, kind, canvas) => {
if (from === owner && this._shown()) this._attach(kind, canvas);
},
});
}
/** Whether the store still shows this ring's overlay. */
_shown() {
return this._store.ring?.owner === this._owner;
}
/** Publish a change to the overlay, skipping one that changes nothing. */
_present(patch) {
if (!this._shown()) return;
const next = { ...this._view, ...patch };
const key = JSON.stringify(next, (name, value) =>
name === 'owner' || name === 'container' ? undefined : value,
);
if (key === this._viewKey) return;
this._view = next;
this._viewKey = key;
this._store.publish(next);
}
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
_attach(kind, canvas) {
const ctx =
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
// A new canvas starts blank, so the next frame repaints its layer.
if (kind === 'sun') {
this._sunCanvas = canvas;
this._sunCtx = ctx;
this._sunRenderKey = '';
} else if (kind === 'moon') {
this._moonCanvas = canvas;
this._moonCtx = ctx;
this._moonRenderKey = '';
} else return;
if (canvas && this.enabled) governorRequestRender('celestial-ring');
}
/** 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');
if (!this.enabled) {
this.visible = false;
this._clear();
}
this._present({
enabled: this.enabled,
...(!this.enabled && { visible: false }),
});
}
/** 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._present({ outline: { ...this._view.outline, hidden: true } });
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;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._present({ size: { width, height } });
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,
);
}
/** Where one icon-library marker sits along the keyhole circumference. */
_markerPosition(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);
return {
left: cx + cos * markerRadius,
top: cy + sin * markerRadius,
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._present({
outline: {
hidden: false,
left: cx - radius,
top: cy - radius,
width: radius * 2,
height: radius * 2,
},
});
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() {
// A ring the store no longer shows has handed its overlay to a later one.
if (!this.enabled || !this._shown()) return;
// Without its canvases (no view rendered them) the ring still tracks
// whether the globe is framed, so it turns itself off as before.
const painted = !!(this._sunCtx && this._moonCtx);
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;
if (painted) 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._present({
visible: nextVisible,
globeVisible: String(nextVisible),
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;
if (!painted) {
this._debug = { enabled: true, visible: true, disc };
return;
}
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._present({ 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);
// 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._present({
sun: {
transform: `rotate(${this._sunAngle}rad)`,
opacity: String(this._sunOpacity),
},
moon: {
transform: `rotate(${this._moonAngle}rad)`,
opacity: String(this._moonOpacity),
},
sunMarker: this._markerPosition(
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
),
moonMarker: this._markerPosition(
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 withdraw the overlay. */
destroy() {
if (this._ephemerisTimer) {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
this._store.connect(null);
this._store.publish(null);
}
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
}
}