mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34: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.
715 lines
28 KiB
JavaScript
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();
|
|
}
|
|
}
|