From 265aa605bdb4be76ff4e2285398031b7ab2e1d57 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 21:20:27 -0700 Subject: [PATCH] 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. --- docs/APPLICATION.md | 3 +- docs/VUE-MIGRATION.md | 7 +- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 10 +- src/celestialRing.js | 225 ++++++++++++++--------- src/celestialRingView.test.mjs | 251 ++++++++++++++++++++++++++ src/ui/applicationShell.js | 2 + src/ui/stores/celestialRing.js | 33 ++++ src/ui/views/SceneChromeView.vue | 2 + src/ui/views/panels/CelestialRing.vue | 49 +++++ 10 files changed, 496 insertions(+), 90 deletions(-) create mode 100644 src/celestialRingView.test.mjs create mode 100644 src/ui/stores/celestialRing.js create mode 100644 src/ui/views/panels/CelestialRing.vue diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index ff20e213d..531dd4ac9 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 93a3cc254..42de7325b 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8eda84e3c..bb7d1b35d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 12d9a537f..fee40768c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/celestialRing.js b/src/celestialRing.js index 3e30e62ec..c031593d4 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -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; } } diff --git a/src/celestialRingView.test.mjs b/src/celestialRingView.test.mjs new file mode 100644 index 000000000..c834ed53a --- /dev/null +++ b/src/celestialRingView.test.mjs @@ -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); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 2af763e67..79a04a560 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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, diff --git a/src/ui/stores/celestialRing.js b/src/ui/stores/celestialRing.js new file mode 100644 index 000000000..1fac43af5 --- /dev/null +++ b/src/ui/stores/celestialRing.js @@ -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 }; +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 2b22e0f05..52eda3d86 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -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 = { + diff --git a/src/ui/views/panels/CelestialRing.vue b/src/ui/views/panels/CelestialRing.vue new file mode 100644 index 000000000..82d017744 --- /dev/null +++ b/src/ui/views/panels/CelestialRing.vue @@ -0,0 +1,49 @@ + + +