mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
This commit is contained in:
1 parent
fd0d51c450
commit
265aa605bd
10 files changed
+496
-90
No files matched your search
+2
-1
@@ -210,7 +210,8 @@ such as `provider-settings`, and their hosts are removed. Leaving out
|
||||
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
|
||||
with no line or handle to move it), the status chips, the toast, the ORBIT
|
||||
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
|
||||
cards, the wind inspection marker and the Space Missions replay vehicle;
|
||||
cards, the wind inspection marker, the Space Missions replay vehicle and
|
||||
the full-globe celestial ring;
|
||||
leaving out `command-dock` the voice control and its pointer reticle; and
|
||||
leaving out `context` the Bhote Koshi reconstruction panel. An unknown
|
||||
view name is an error. A host that does not initialize Core's locale passes
|
||||
|
||||
@@ -172,7 +172,12 @@ a feature supplying only its accent.
|
||||
renders what the event layer publishes to the `gev-bhote-koshi-panel`
|
||||
store: each control's text, hidden, disabled, value and attributes as
|
||||
the layer writes them, unchanged frames sending nothing; the rail takes
|
||||
`bhote-event-active` while it shows), then
|
||||
`bhote-event-active` while it shows), the full-globe celestial ring
|
||||
(`CelestialRing` teleports into the globe container what `CelestialRing`
|
||||
in `src/celestialRing.js` publishes to the `gev-celestial-ring` store and
|
||||
hands its two canvases back for the owner to paint; the application shell
|
||||
passes the store in, so the keyhole helpers other packages import need no
|
||||
Vue), then
|
||||
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||
pages, cycle toggle, regional news and local conditions, and
|
||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||
|
||||
@@ -1146,5 +1146,7 @@
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/layers/launches/replayOverlay.test.mjs",
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/data/bhoteKoshiPanelView.test.mjs"
|
||||
"src/data/bhoteKoshiPanelView.test.mjs",
|
||||
"src/ui/stores/celestialRing.js",
|
||||
"src/celestialRingView.test.mjs"
|
||||
]
|
||||
@@ -108,7 +108,9 @@
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/ui/views/panels/MissionReplayOverlay.vue",
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/ui/views/panels/BhoteKoshiPanel.vue"
|
||||
"src/ui/views/panels/BhoteKoshiPanel.vue",
|
||||
"src/ui/stores/celestialRing.js",
|
||||
"src/ui/views/panels/CelestialRing.vue"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -1029,7 +1031,8 @@
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/toast.js",
|
||||
"src/ui/stores/missionReplay.js"
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/ui/stores/celestialRing.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia"]
|
||||
},
|
||||
@@ -3026,7 +3029,8 @@
|
||||
"src/ui/stores/windInspection.js",
|
||||
"src/layers/wind/inspectionMarker.js",
|
||||
"src/ui/stores/missionReplay.js",
|
||||
"src/ui/stores/bhoteKoshiPanel.js"
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/ui/stores/celestialRing.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
+141
-84
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
|
||||
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}} [options]
|
||||
* @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 } = {}) {
|
||||
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
|
||||
this.viewer = viewer;
|
||||
this.enabled = !!enabled;
|
||||
this.visible = false;
|
||||
@@ -406,6 +414,10 @@ export class CelestialRing {
|
||||
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();
|
||||
@@ -415,7 +427,7 @@ export class CelestialRing {
|
||||
this._screenCenter = new Cesium.Cartesian2();
|
||||
this._fixedMatrix = new Cesium.Matrix3();
|
||||
|
||||
this._buildDOM();
|
||||
this._initView(store);
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||
this._draw(),
|
||||
);
|
||||
@@ -436,48 +448,68 @@ export class CelestialRing {
|
||||
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,
|
||||
/** 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this._moonCanvas = document.createElement('canvas');
|
||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
/** Whether the store still shows this ring's overlay. */
|
||||
_shown() {
|
||||
return this._store.ring?.owner === this._owner;
|
||||
}
|
||||
|
||||
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,
|
||||
/** 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,
|
||||
);
|
||||
this.viewer.container.appendChild(this._root);
|
||||
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. */
|
||||
@@ -491,12 +523,14 @@ export class CelestialRing {
|
||||
// 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();
|
||||
}
|
||||
this._present({
|
||||
enabled: this.enabled,
|
||||
...(!this.enabled && { visible: false }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the current camera already frames the complete globe inside the keyhole. */
|
||||
@@ -577,7 +611,7 @@ export class CelestialRing {
|
||||
]) {
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
this._ringOutline?.style.setProperty('display', 'none');
|
||||
this._present({ outline: { ...this._view.outline, hidden: true } });
|
||||
this._sunRenderKey = '';
|
||||
this._moonRenderKey = '';
|
||||
this._outlineRenderKey = '';
|
||||
@@ -607,10 +641,9 @@ export class CelestialRing {
|
||||
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._present({ size: { width, height } });
|
||||
this._renderScale = dpr;
|
||||
}
|
||||
|
||||
@@ -665,8 +698,8 @@ export class CelestialRing {
|
||||
);
|
||||
}
|
||||
|
||||
/** Position one icon-library marker along the keyhole circumference. */
|
||||
_positionMarker(marker, cx, cy, radius, angle, opacity) {
|
||||
/** 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;
|
||||
@@ -679,20 +712,26 @@ export class CelestialRing {
|
||||
? (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);
|
||||
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._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._present({
|
||||
outline: {
|
||||
hidden: false,
|
||||
left: cx - radius,
|
||||
top: cy - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
},
|
||||
});
|
||||
this._outlineRenderKey = outlineKey;
|
||||
}
|
||||
|
||||
@@ -724,7 +763,11 @@ export class CelestialRing {
|
||||
|
||||
/** Per-frame camera projection with GPU-composited cached effect layers. */
|
||||
_draw() {
|
||||
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
|
||||
// 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;
|
||||
@@ -732,7 +775,7 @@ export class CelestialRing {
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
if (!(width > 0 && height > 0)) return;
|
||||
this._resize(width, height);
|
||||
if (painted) this._resize(width, height);
|
||||
|
||||
const disc = this._projectedEarthDisc(width, height);
|
||||
const wasVisible = this.visible;
|
||||
@@ -740,9 +783,11 @@ export class CelestialRing {
|
||||
? 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);
|
||||
this._present({
|
||||
visible: nextVisible,
|
||||
globeVisible: String(nextVisible),
|
||||
ephemerisUpdates: String(this._ephemerisUpdateCount),
|
||||
});
|
||||
if (!nextVisible || !disc) {
|
||||
this._clear();
|
||||
this._debug = { enabled: true, visible: false, disc };
|
||||
@@ -751,10 +796,14 @@ export class CelestialRing {
|
||||
}
|
||||
|
||||
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._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
const sunProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||
@@ -779,10 +828,6 @@ export class CelestialRing {
|
||||
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
|
||||
@@ -791,22 +836,30 @@ export class CelestialRing {
|
||||
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._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,
|
||||
@@ -831,7 +884,7 @@ export class CelestialRing {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Detach the render hook and remove all overlay DOM. */
|
||||
/** Detach the render hook and withdraw the overlay. */
|
||||
destroy() {
|
||||
if (this._ephemerisTimer) {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
@@ -839,6 +892,10 @@ export class CelestialRing {
|
||||
}
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
this._root?.remove();
|
||||
if (this._shown()) {
|
||||
this._store.connect(null);
|
||||
this._store.publish(null);
|
||||
}
|
||||
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/celestialRingView.test.mjs — the full-globe ring renders in the
|
||||
// viewer's container from what the ring owner presents, and the owner paints
|
||||
// the canvases the view hands back.
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
|
||||
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CelestialRing } from './celestialRing.js';
|
||||
|
||||
// happy-dom has no 2D context; a stand-in takes every drawing call.
|
||||
const context = new Proxy(
|
||||
{},
|
||||
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
|
||||
);
|
||||
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
|
||||
|
||||
function fakeViewer(container) {
|
||||
return {
|
||||
container,
|
||||
camera: {
|
||||
rightWC: new Cesium.Cartesian3(1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
|
||||
postRender: { addEventListener: () => () => {} },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const store = useCelestialRingStore(pinia);
|
||||
const app = createApp(CelestialRingView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const rings = [];
|
||||
const create = (options = {}) => {
|
||||
const ring = new CelestialRing(fakeViewer(container), {
|
||||
enabled: false,
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
rings.push(ring);
|
||||
return ring;
|
||||
};
|
||||
t.after(() => {
|
||||
for (const ring of rings) ring.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const $ = (selector) => container.querySelector(selector);
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
return { container, store, create, $, settle };
|
||||
}
|
||||
|
||||
test('the ring renders its overlay in the viewer container', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(root.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(root.hasAttribute('data-globe-visible'), false);
|
||||
assert.deepEqual(
|
||||
[...root.children].map((child) => [child.tagName, child.className]),
|
||||
[
|
||||
['DIV', 'celestial-ring-outline'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
|
||||
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
|
||||
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
|
||||
],
|
||||
);
|
||||
assert.equal(root.children[0].style.display, 'none');
|
||||
assert.equal(root.children[3].textContent, 'light_mode');
|
||||
assert.equal(root.children[4].textContent, 'dark_mode');
|
||||
assert.ok(ring._sunCanvas === root.children[1]);
|
||||
assert.ok(ring._moonCanvas === root.children[2]);
|
||||
ring.setEnabled(true);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay');
|
||||
ring.destroy();
|
||||
await settle();
|
||||
assert.equal($('#celestial-ring-overlay'), null);
|
||||
assert.equal(ring._sunCanvas, null);
|
||||
});
|
||||
|
||||
test('a drawn frame places the outline, layers and markers', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
|
||||
ring._resize(400, 300);
|
||||
ring._renderEffectLayers(200, 150, 120, 80);
|
||||
ring._present({
|
||||
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
|
||||
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
|
||||
});
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker] = root.children;
|
||||
assert.equal(outline.style.display, '');
|
||||
assert.equal(outline.style.left, '80px');
|
||||
assert.equal(outline.style.width, '240px');
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(sun.style.transform, 'rotate(1rad)');
|
||||
assert.equal(sun.style.opacity, '0.5');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sunMarker.style.left, '300px');
|
||||
assert.equal(sunMarker.style.top, '150px');
|
||||
ring.setEnabled(false);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(outline.style.display, 'none');
|
||||
});
|
||||
|
||||
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
|
||||
const { create, store, $, settle } = fixture(t);
|
||||
const earlier = create();
|
||||
await settle();
|
||||
const later = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.ok(later._sunCanvas === root.children[1]);
|
||||
earlier.setEnabled(true);
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.ok(store.ring.owner === later._owner);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
|
||||
});
|
||||
|
||||
// A globe framed well inside the keyhole of a 400 x 300 view.
|
||||
const FRAMED = {
|
||||
earthCenterX: 200,
|
||||
earthCenterY: 150,
|
||||
earthRadius: 40,
|
||||
keyholeCenterX: 200,
|
||||
keyholeCenterY: 150,
|
||||
keyholeRadius: 140,
|
||||
};
|
||||
|
||||
/** Run one rendered frame, past the frame-rate throttle. */
|
||||
function frame(ring, disc) {
|
||||
ring._projectedEarthDisc = () => disc;
|
||||
// Fixed sun and moon directions; Cesium's own model would fetch its
|
||||
// orientation data.
|
||||
ring._updateEphemeris = () => {
|
||||
if (ring._ephemerisDirty) {
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
|
||||
ring._ephemerisDirty = false;
|
||||
ring._ephemerisUpdateCount += 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
ring._nextDrawAt = 0;
|
||||
ring._draw();
|
||||
}
|
||||
|
||||
test('a rendered frame publishes the layers and markers it painted', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
|
||||
const debug = ring.getDebugState();
|
||||
assert.equal(debug.visible, true);
|
||||
assert.equal(outline.style.display, '');
|
||||
// The outline is the keyhole circle, a fixed inset inside its radius.
|
||||
const diameter = parseFloat(outline.style.width);
|
||||
assert.ok(diameter > 0 && diameter < 280);
|
||||
assert.equal(outline.style.height, outline.style.width);
|
||||
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
|
||||
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
|
||||
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
|
||||
assert.equal(sun.style.opacity, String(debug.sunOpacity));
|
||||
assert.equal(moon.style.opacity, String(debug.moonOpacity));
|
||||
assert.ok(sunMarker.style.left.endsWith('px'));
|
||||
assert.ok(moonMarker.style.top.endsWith('px'));
|
||||
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
|
||||
});
|
||||
|
||||
test('a frame without the globe framed turns the ring off', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
frame(ring, null);
|
||||
await settle();
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('without a rendered view the ring still tracks the globe and turns off', () => {
|
||||
const pinia = createPinia();
|
||||
let disabled = 0;
|
||||
const ring = new CelestialRing(fakeViewer({}), {
|
||||
enabled: true,
|
||||
store: useCelestialRingStore(pinia),
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
frame(ring, FRAMED);
|
||||
assert.equal(ring.visible, true);
|
||||
frame(ring, null);
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
ring.destroy();
|
||||
});
|
||||
|
||||
test('a ring the store no longer shows stops drawing', async (t) => {
|
||||
const { create, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const earlier = create({
|
||||
enabled: true,
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
await settle();
|
||||
create();
|
||||
await settle();
|
||||
frame(earlier, null);
|
||||
assert.equal(earlier.enabled, true);
|
||||
assert.equal(disabled, 0);
|
||||
});
|
||||
@@ -55,6 +55,7 @@ import { PanelNavigation } from './panelNavigation.js';
|
||||
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import { useMissionReplayStore } from './stores/missionReplay.js';
|
||||
import { useCelestialRingStore } from './stores/celestialRing.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { useLocaleStore } from './stores/locale.js';
|
||||
import { useLocationStore } from './stores/location.js';
|
||||
@@ -414,6 +415,7 @@ export class StyleManager extends ShellFacade {
|
||||
// Cesium canvas but below the HUD/detection/readout z ladder.
|
||||
this.celestialRing = new CelestialRing(viewer, {
|
||||
enabled: false,
|
||||
store: useCelestialRingStore(applicationPinia()),
|
||||
onAutoDisable: () =>
|
||||
this.setCelestialRingEnabled(false, {
|
||||
syncShare: !!this.shareLinkManager,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The full-globe celestial ring as its view renders it. The ring owner
|
||||
* (src/celestialRing.js) presents the overlay's state, the outline and where
|
||||
* the sun and moon layers and markers sit; the view hands its two canvases
|
||||
* back to the connected controls, which paint them.
|
||||
*/
|
||||
export const useCelestialRingStore = defineStore('gev-celestial-ring', () => {
|
||||
/**
|
||||
* `{owner, container, enabled, visible, globeVisible, ephemerisUpdates,
|
||||
* size, outline, sun, moon, sunMarker, moonMarker}`, or null while no ring
|
||||
* exists. `container` is the element it sits in; `owner` is passed back
|
||||
* with each canvas.
|
||||
*/
|
||||
const ring = shallowRef(null);
|
||||
|
||||
/** @param {?object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
ring.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The controls whose `attach(owner, kind, canvas)` each canvas calls. */
|
||||
const controls = shallowRef(null);
|
||||
|
||||
/** @param {?object} next The controls, or null to disconnect. */
|
||||
function connect(next) {
|
||||
controls.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
return { ring, publish, controls, connect };
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import DataPacks from './panels/DataPacks.vue';
|
||||
import SceneInteractions from './panels/SceneInteractions.vue';
|
||||
import SplitFlapText from './panels/SplitFlapText.vue';
|
||||
import WindInspectionMarker from './panels/WindInspectionMarker.vue';
|
||||
import CelestialRing from './panels/CelestialRing.vue';
|
||||
|
||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||
const { style } = storeToRefs(useDisplayStore());
|
||||
@@ -101,6 +102,7 @@ const vToastText = {
|
||||
|
||||
<ImagerySplitView />
|
||||
<DataPacks />
|
||||
<CelestialRing />
|
||||
<SceneInteractions />
|
||||
<WindInspectionMarker />
|
||||
<MissionReplayOverlay />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useCelestialRingStore } from '../../stores/celestialRing.js';
|
||||
|
||||
// The full-globe sun/moon ring, inside the viewer's container, as the ring
|
||||
// owner (src/celestialRing.js) presents it.
|
||||
const { ring, controls } = storeToRefs(useCelestialRingStore());
|
||||
|
||||
// Each canvas goes to the owner it was rendered for, which paints it.
|
||||
const vRingCanvas = {
|
||||
mounted(el, { value }) {
|
||||
controls.value?.attach(value.owner, value.kind, el);
|
||||
},
|
||||
beforeUnmount(el, { value }) {
|
||||
controls.value?.attach(value.owner, value.kind, null);
|
||||
},
|
||||
};
|
||||
const px = (value) => `${value}px`;
|
||||
const layerStyle = (layer) => ({
|
||||
...(ring.value.size && {
|
||||
width: px(ring.value.size.width),
|
||||
height: px(ring.value.size.height),
|
||||
}),
|
||||
...(layer && { transform: layer.transform, opacity: layer.opacity }),
|
||||
});
|
||||
// The outline is hidden before it has ever been placed.
|
||||
const outlineStyle = (outline) =>
|
||||
outline && {
|
||||
display: outline.hidden ? 'none' : '',
|
||||
...(outline.width != null && {
|
||||
left: px(outline.left),
|
||||
top: px(outline.top),
|
||||
width: px(outline.width),
|
||||
height: px(outline.height),
|
||||
}),
|
||||
};
|
||||
const markerStyle = (marker) =>
|
||||
marker && {
|
||||
left: px(marker.left),
|
||||
top: px(marker.top),
|
||||
opacity: marker.opacity,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="ring" :to="ring.container">
|
||||
<div :key="ring.id" id="celestial-ring-overlay" class="celestial-ring-overlay" :class="{ visible: ring.visible, disabled: !ring.enabled }" aria-hidden="true" :data-globe-visible="ring.globeVisible ?? undefined" :data-ephemeris-updates="ring.ephemerisUpdates ?? undefined"><div class="celestial-ring-outline" :style="outlineStyle(ring.outline)"></div><canvas v-ring-canvas="{ owner: ring.owner, kind: 'sun' }" class="celestial-ring-canvas celestial-sun-canvas" :style="layerStyle(ring.sun)"></canvas><canvas v-ring-canvas="{ owner: ring.owner, kind: 'moon' }" class="celestial-ring-canvas celestial-moon-canvas" :style="layerStyle(ring.moon)"></canvas><span class="celestial-marker celestial-sun material-symbols-outlined" :style="markerStyle(ring.sunMarker)">light_mode</span><span class="celestial-marker celestial-moon material-symbols-outlined" :style="markerStyle(ring.moonMarker)">dark_mode</span></div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in new issue
Block a user