diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 9d51ed11a..2ca681150 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -211,8 +211,8 @@ 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, the Space Missions replay vehicle, the -full-globe celestial ring, the screen-space map annotations and the -visible map target list; +full-globe celestial ring, the screen-space map annotations, the visible +map target list and the Global Context compass; 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 78fb2be45..c442246d7 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -189,7 +189,12 @@ a feature supplying only its accent. the application shell publishes to the `gev-world-overlay-actions` store and answers with `activateWorldOverlayAction()`; the module needs no Vue for the portable transit entry, and its card and detection canvases stay - painting surfaces it draws on, like the Cesium canvas), then + painting surfaces it draws on, like the Cesium canvas), the Global + Context compass and contact bearings (`AwarenessDirection` teleports into + the globe container what the awareness layer presents through + `subscribeDirectionOverlay()`, which the application shell publishes to + the `gev-awareness-direction` store, so the portable `./layers/awareness` + entry needs 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 74163968a..f45225c19 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1151,5 +1151,7 @@ "src/celestialRingView.test.mjs", "src/ui/stores/screenAnnotations.js", "src/ui/stores/worldOverlayActions.js", - "src/overlays/worldOverlayActionsView.test.mjs" + "src/overlays/worldOverlayActionsView.test.mjs", + "src/ui/stores/awarenessDirection.js", + "src/layers/awareness/directionOverlay.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 79b16fb5a..23bf720f0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,9 @@ "src/ui/views/panels/CelestialRing.vue", "src/ui/stores/screenAnnotations.js", "src/ui/views/panels/ScreenAnnotations.vue", - "src/ui/stores/worldOverlayActions.js" + "src/ui/stores/worldOverlayActions.js", + "src/ui/stores/awarenessDirection.js", + "src/ui/views/panels/AwarenessDirection.vue" ], "external": ["vue", "pinia"] }, @@ -1036,7 +1038,8 @@ "src/ui/toast.js", "src/ui/stores/missionReplay.js", "src/ui/stores/celestialRing.js", - "src/ui/stores/worldOverlayActions.js" + "src/ui/stores/worldOverlayActions.js", + "src/ui/stores/awarenessDirection.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3036,7 +3039,8 @@ "src/ui/stores/bhoteKoshiPanel.js", "src/ui/stores/celestialRing.js", "src/ui/stores/screenAnnotations.js", - "src/ui/stores/worldOverlayActions.js" + "src/ui/stores/worldOverlayActions.js", + "src/ui/stores/awarenessDirection.js" ], "external": [ "hls.js", diff --git a/src/layers/awareness/directionOverlay.test.mjs b/src/layers/awareness/directionOverlay.test.mjs new file mode 100644 index 000000000..283c5e286 --- /dev/null +++ b/src/layers/awareness/directionOverlay.test.mjs @@ -0,0 +1,185 @@ +// src/layers/awareness/directionOverlay.test.mjs — the Global Context compass +// and contact bearings are presented to subscribers and rendered from a store +// inside the viewer's container. +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useAwarenessDirectionStore } from '../../ui/stores/awarenessDirection.js'; +import AwarenessDirection from '../../ui/views/panels/AwarenessDirection.vue'; +import { createState } from './state.js'; +import { createRendering } from './rendering.js'; + +/** The rendering part over a subject with one military contact due east. */ +function overlay(screenAngle) { + const state = createState({ services: {} }); + const container = document.body.appendChild(document.createElement('div')); + const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000); + Object.assign(state, { + viewer: { + container, + scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, + camera: { + heading: 0, + rightWC: new Cesium.Cartesian3(0, 1, 0), + upWC: new Cesium.Cartesian3(0, 0, 1), + }, + }, + enabled: true, + subject: { position: subject, layerId: 'military' }, + results: { + cohorts: [ + { + id: 'military', + summary: { + nearest: [ + { + callsign: 'RCH123', + position: Cesium.Cartesian3.fromDegrees(0.05, 0, 1000), + distanceM: 5500, + track: 90, + }, + ], + }, + }, + ], + }, + cohortPages: new Map(), + }); + const rendering = createRendering({ + state, + services: { + geometry: { + getKeyholeGeometry: () => ({ centerX: 400, centerY: 300, radius: 250 }), + celestialScreenAngle: + screenAngle || + ((x, y) => ({ + angle: Math.atan2(-y, x), + opacity: 1, + stable: true, + })), + }, + render: { governorRequestRender() {} }, + }, + parts: {}, + }); + return { state, container, rendering }; +} + +test('the overlay publishes the compass and the nearest contact bearings', () => { + const { rendering, container } = overlay(); + const views = []; + rendering.subscribeDirectionOverlay((view) => views.push(view)); + assert.deepEqual(views, [null]); + rendering.updateDirectionOverlay(); + const view = views.at(-1); + assert.ok(view.container === container); + assert.equal(view.hidden, false); + // The compass sits at 66% of the 250px keyhole: 165px. + assert.equal(view.ring.width, '330px'); + assert.equal(view.ring['--compass-rotation'], '0deg'); + assert.equal(view.heading.text, 'HDG 000°'); + assert.deepEqual( + view.labels.map((label) => label.label), + ['N', 'E', 'S', 'W'], + ); + assert.equal(view.labels[0].left, '400px'); + const [marker, second] = view.markers; + assert.equal(marker.hidden, false); + assert.match(marker.caption.text, /^RCH123 · .+\nBRG 090° · CRS 090°$/); + assert.equal(marker.arrow.opacity, '1'); + assert.equal(second.hidden, true); + // An unchanged frame publishes nothing. + const count = views.length; + rendering.updateDirectionOverlay(); + assert.equal(views.length, count); + rendering.hideDirectionOverlay(); + assert.equal(views.at(-1).hidden, true); + assert.equal(views.at(-1).heading.text, 'HDG 000°', 'placement is kept'); + rendering.removeDirectionOverlay(); + assert.equal(views.at(-1), null); +}); + +test('an unstable frame keeps the last stable bearing, until the overlay is removed', () => { + const answers = [ + { angle: 1, opacity: 1, stable: true }, + { angle: 2, opacity: 0.5, stable: false }, + ]; + const previous = []; + const { state, rendering } = overlay((x, y, last) => { + previous.push(last); + return answers.shift() ?? { angle: last ?? 0, opacity: 1, stable: true }; + }); + rendering.updateDirectionOverlay(); + assert.equal(state.directionAngles[0], 1); + // The heading turns, so the next frame recomputes the contact. + state.viewer.camera.heading = 0.1; + rendering.updateDirectionOverlay(); + assert.equal(previous[1], 1, 'the unstable frame is given the last angle'); + assert.equal(state.directionAngles[0], 1, 'and does not replace it'); + rendering.removeDirectionOverlay(); + rendering.updateDirectionOverlay(); + assert.equal(previous[2], 0, 'a rebuilt overlay starts afresh'); +}); + +test('the scene chrome view renders the published overlay in its container', async (t) => { + document.body.replaceChildren(); + const pinia = createPinia(); + const store = useAwarenessDirectionStore(pinia); + const app = createApp(AwarenessDirection).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const { rendering, container } = overlay(); + rendering.subscribeDirectionOverlay((view) => store.publish(view)); + rendering.ensureDirectionOverlay(); + await nextTick(); + const root = container.querySelector('#military-awareness-direction-overlay'); + assert.equal(root.hidden, true); + assert.equal(root.getAttribute('aria-hidden'), 'true'); + assert.deepEqual( + [...root.children].map((child) => child.className), + [ + 'military-awareness-compass-label cardinal-n', + 'military-awareness-compass-label cardinal-e', + 'military-awareness-compass-label cardinal-s', + 'military-awareness-compass-label cardinal-w', + 'military-awareness-compass-ring', + 'military-awareness-compass-heading', + 'military-awareness-direction-marker', + 'military-awareness-direction-marker', + 'military-awareness-direction-marker', + ], + ); + assert.equal(root.children[0].dataset.bearing, '0'); + assert.equal(root.children[0].getAttribute('style'), null); + rendering.updateDirectionOverlay(); + await nextTick(); + assert.equal(root.hidden, false); + const ring = root.querySelector('.military-awareness-compass-ring'); + assert.equal(ring.style.getPropertyValue('--compass-rotation'), '0deg'); + assert.equal( + root.querySelector('.military-awareness-compass-heading').textContent, + 'HDG 000°', + ); + const [marker, second] = root.querySelectorAll( + '.military-awareness-direction-marker', + ); + assert.equal(marker.hidden, false); + assert.equal( + marker.querySelector('.military-awareness-direction-arrow').textContent, + '➜', + ); + assert.match( + marker.querySelector('.military-awareness-direction-label').textContent, + /^RCH123 · /, + ); + assert.equal(second.hidden, true); + rendering.removeDirectionOverlay(); + await nextTick(); + assert.equal( + container.querySelector('#military-awareness-direction-overlay'), + null, + ); +}); diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js index 1998ca426..2efa7424d 100644 --- a/src/layers/awareness/index.js +++ b/src/layers/awareness/index.js @@ -57,6 +57,7 @@ export function createAwarenessLayer({ services, source }) { historySubjectSnapshot: parts.history.historySubjectSnapshot, findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex, subscribePanel: parts.panel.subscribePanel, + subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay, awarenessResultsAreLive: parts.panel.awarenessResultsAreLive, awarenessNeedsContinuousRender: parts.model.awarenessNeedsContinuousRender, diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 34ee5a2f2..925a3a9ca 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -230,12 +230,7 @@ export function createLifecycle({ this.disable(); detachRuntimeListeners(); parts.panel.clearPanel(); - layerState.directionRoot?.remove(); - layerState.directionRoot = null; - layerState.compassRing = null; - layerState.compassLabels = []; - layerState.compassHeading = null; - layerState.directionMarkers = []; + parts.rendering.removeDirectionOverlay(); layerState.subjectListener = null; layerState.contextListener = null; layerState.clearListener = null; diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index d5711b194..8c7e4b51c 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -86,7 +86,7 @@ export function createPanel({ state: layerState, parts }) { ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'), }); - if (layerState.directionRoot) layerState.directionRoot.hidden = true; + parts.rendering.hideDirectionOverlay(); } /** Clear the panel when the layer is destroyed. */ diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 957e0bbdd..f65def213 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -22,55 +22,75 @@ export function createRendering({ const { getKeyholeGeometry, celestialScreenAngle } = services.geometry; const { governorRequestRender } = services.render; + const CARDINALS = [ + ['N', 0], + ['E', 90], + ['S', 180], + ['W', 270], + ]; + + /** + * The direction overlay as its view renders it: the compass ring, its + * cardinals and heading, and up to three contact markers inside the + * viewer's container. Each part keeps its last placement while hidden. + */ function ensureDirectionOverlay() { - if (layerState.directionRoot || !layerState.viewer) - return layerState.directionRoot; - const root = document.createElement('div'); - root.id = 'military-awareness-direction-overlay'; - root.hidden = true; - root.setAttribute('aria-hidden', 'true'); - - const compass = document.createElement('div'); - compass.className = 'military-awareness-compass-ring'; - const cardinals = [ - ['N', 0], - ['E', 90], - ['S', 180], - ['W', 270], - ]; - layerState.compassLabels = cardinals.map(([label, bearing]) => { - const element = document.createElement('span'); - element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`; - element.textContent = label; - element.dataset.bearing = String(bearing); - root.appendChild(element); - return element; + if (layerState.directionView || !layerState.viewer) + return layerState.directionView; + presentDirection({ + container: layerState.viewer.container, + hidden: true, + ring: null, + heading: { text: '', left: null, top: null }, + labels: CARDINALS.map(([label, bearing]) => ({ + label, + bearing, + left: null, + top: null, + })), + markers: Array.from({ length: 3 }, () => ({ + hidden: false, + style: null, + arrow: null, + caption: { text: '', style: null }, + })), }); - const heading = document.createElement('span'); - heading.className = 'military-awareness-compass-heading'; - root.append(compass, heading); + return layerState.directionView; + } - layerState.directionMarkers = Array.from({ length: 3 }, () => { - const marker = document.createElement('div'); - marker.className = 'military-awareness-direction-marker'; - const arrow = document.createElement('span'); - arrow.className = 'military-awareness-direction-arrow'; - arrow.textContent = '➜'; - const label = document.createElement('span'); - label.className = 'military-awareness-direction-label'; - marker.append(arrow, label); - marker._arrow = arrow; - marker._label = label; - marker._lastAngle = 0; - root.appendChild(marker); - return marker; - }); + /** Publish a change to the overlay, skipping one that changes nothing. */ + function presentDirection(patch) { + const next = patch && { ...layerState.directionView, ...patch }; + const signature = JSON.stringify(next, (name, value) => + name === 'container' ? undefined : value, + ); + if (next && signature === layerState.directionSignature) return; + layerState.directionSignature = next ? signature : ''; + layerState.directionView = next && Object.freeze(next); + for (const listener of [...layerState.directionListeners]) + listener(layerState.directionView); + } - layerState.compassRing = compass; - layerState.compassHeading = heading; - layerState.viewer.container.appendChild(root); - layerState.directionRoot = root; - return root; + /** + * Receive the direction overlay now and after every change. + * @param {(view: ?object) => void} listener + * @returns {() => void} Unsubscribe. + */ + function subscribeDirectionOverlay(listener) { + layerState.directionListeners.add(listener); + listener(layerState.directionView); + return () => layerState.directionListeners.delete(listener); + } + + /** Hide the overlay, keeping its last placement. */ + function hideDirectionOverlay() { + if (layerState.directionView) presentDirection({ hidden: true }); + } + + /** Withdraw the overlay when the layer is destroyed. */ + function removeDirectionOverlay() { + if (layerState.directionView) presentDirection(null); + layerState.directionAngles = [0, 0, 0]; } function scheduleDirectionOverlayUpdate(force = false) { @@ -104,7 +124,7 @@ export function createRendering({ !layerState.subject?.position || !layerState.results ) { - if (root) root.hidden = true; + if (root) presentDirection({ hidden: true }); return; } @@ -116,41 +136,43 @@ export function createRendering({ : null; const geometry = getKeyholeGeometry(width, height); if (!(geometry.radius > 0)) { - root.hidden = true; + presentDirection({ hidden: true }); return; } - root.hidden = false; // Keep the compass comfortably inside the shader keyhole so its cardinals // remain visible around the subject instead of hiding under edge panels. const compassRadius = Math.max( 120, Math.min(geometry.radius - 48, geometry.radius * 0.66), ); - layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`; - layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`; - layerState.compassRing.style.width = `${compassRadius * 2}px`; - layerState.compassRing.style.height = `${compassRadius * 2}px`; - const cameraHeading = layerState.viewer.camera.heading || 0; const headingDeg = (Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360; - layerState.compassRing.style.setProperty( - '--compass-rotation', - `${-headingDeg}deg`, - ); - layerState.compassHeading.textContent = t('HDG {value}°', { - value: String(headingDeg).padStart(3, '0'), - }); - layerState.compassHeading.style.left = `${geometry.centerX}px`; - layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`; - for (const label of layerState.compassLabels) { - const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing)); + const ring = { + left: `${geometry.centerX - compassRadius}px`, + top: `${geometry.centerY - compassRadius}px`, + width: `${compassRadius * 2}px`, + height: `${compassRadius * 2}px`, + '--compass-rotation': `${-headingDeg}deg`, + }; + const heading = { + text: t('HDG {value}°', { + value: String(headingDeg).padStart(3, '0'), + }), + left: `${geometry.centerX}px`, + top: `${geometry.centerY - compassRadius + 39}px`, + }; + const labels = root.labels.map((label) => { + const bearing = Cesium.Math.toRadians(label.bearing); const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO; const labelRadius = compassRadius - 17; - label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`; - label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`; - } + return { + ...label, + left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`, + top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`, + }; + }); const directionalCohort = layerState.results.cohorts.find( @@ -163,8 +185,9 @@ export function createRendering({ page, page + AWARENESS_PAGE_SIZE, ) || []; - for (let index = 0; index < layerState.directionMarkers.length; index++) { - const marker = layerState.directionMarkers[index]; + const markers = root.markers.map((marker) => ({ ...marker })); + for (let index = 0; index < markers.length; index++) { + const marker = markers[index]; const item = military[index]; if (!item?.position) { marker.hidden = true; @@ -183,27 +206,32 @@ export function createRendering({ const projection = celestialScreenAngle( Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC), Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC), - marker._lastAngle, + layerState.directionAngles[index], ); - if (projection.stable) marker._lastAngle = projection.angle; + if (projection.stable) + layerState.directionAngles[index] = projection.angle; // Every detected contact shares the compass rim. Keeping the bearing // markers on one radius makes them read as compass contacts rather than // unconstrained labels floating over the map. const markerRadius = compassRadius; - marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`; - marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`; - marker.style.opacity = String(projection.opacity); + marker.style = { + left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`, + top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`, + opacity: String(projection.opacity), + }; const sonarFactor = sonar?.at( geometry.centerX + Math.cos(projection.angle) * markerRadius, geometry.centerY + Math.sin(projection.angle) * markerRadius, ) ?? 1; - marker._arrow.style.opacity = String(sonarFactor); - marker._label.style.opacity = String( - sonar ? sonar.label(sonarFactor) : 1, - ); - marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`; - marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`; + marker.arrow = { + opacity: String(sonarFactor), + transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`, + }; + const labelStyle = { + opacity: String(sonar ? sonar.label(sonarFactor) : 1), + transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`, + }; const label = formatAwarenessLabel(item); const subjectCartographic = Cesium.Cartographic.fromCartesian( layerState.subject.position, @@ -234,9 +262,13 @@ export function createRendering({ value: formatAwarenessDirection(item.track), })}` : ''; - marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; + marker.caption = { + text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`, + style: labelStyle, + }; marker.hidden = false; } + presentDirection({ hidden: false, ring, heading, labels, markers }); } function clearVisual() { @@ -303,6 +335,9 @@ export function createRendering({ } return { ensureDirectionOverlay, + subscribeDirectionOverlay, + hideDirectionOverlay, + removeDirectionOverlay, scheduleDirectionOverlayUpdate, cancelDirectionOverlayUpdate, updateDirectionOverlay, diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index ac530cb60..5fd8bb912 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -43,11 +43,12 @@ export function createState({ services }) { subjectClearListener: null, runtimeListenersAttached: false, preRenderRemover: null, - directionRoot: null, - compassRing: null, - compassLabels: [], - compassHeading: null, - directionMarkers: [], + /** The published direction overlay, its signature and its subscribers. */ + directionView: null, + directionSignature: '', + directionListeners: new Set(), + /** Each marker's last stable screen angle. */ + directionAngles: [0, 0, 0], navigationHistory: [], navigationVisited: new Set(), navigationIndex: -1, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 52b674b8b..b9716e741 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -57,6 +57,7 @@ import { useContextStore } from './stores/context.js'; import { useMissionReplayStore } from './stores/missionReplay.js'; import { useCelestialRingStore } from './stores/celestialRing.js'; import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js'; +import { useAwarenessDirectionStore } from './stores/awarenessDirection.js'; import { useDisplayStore } from './stores/display.js'; import { useLocaleStore } from './stores/locale.js'; import { useLocationStore } from './stores/location.js'; @@ -1042,6 +1043,15 @@ export class StyleManager extends ShellFacade { this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.( (view) => contextStore.publishAwareness(view), ); + // And its compass and contact bearings, which the scene chrome renders. + const awarenessDirectionStore = + useAwarenessDirectionStore(applicationPinia()); + this._awarenessDirectionUnsubscribe?.(); + awarenessDirectionStore.publish(null); + this._awarenessDirectionUnsubscribe = + militaryAwarenessLayer?.subscribeDirectionOverlay?.((view) => + awarenessDirectionStore.publish(view), + ); // The Space Missions layer publishes its roster and selected mission. contextStore.connectMissions(rocketLaunchesLayer); this._missionsPanelUnsubscribe?.(); @@ -1805,6 +1815,9 @@ export class StyleManager extends ShellFacade { this._localSdrControls?.destroy(); this._awarenessPanelUnsubscribe?.(); this._awarenessPanelUnsubscribe = null; + this._awarenessDirectionUnsubscribe?.(); + this._awarenessDirectionUnsubscribe = null; + useAwarenessDirectionStore(applicationPinia()).publish(null); this._missionsPanelUnsubscribe?.(); this._missionsPanelUnsubscribe = null; this._missionReplayUnsubscribe?.(); diff --git a/src/ui/stores/awarenessDirection.js b/src/ui/stores/awarenessDirection.js new file mode 100644 index 000000000..f3bdd62de --- /dev/null +++ b/src/ui/stores/awarenessDirection.js @@ -0,0 +1,28 @@ +import { defineStore } from 'pinia'; +import { shallowRef } from 'vue'; + +/** + * The Global Context compass and contact direction overlay as its view + * renders it. The awareness layer presents it through + * `subscribeDirectionOverlay()` and the application shell publishes it here. + */ +export const useAwarenessDirectionStore = defineStore( + 'gev-awareness-direction', + () => { + /** + * `{container, hidden, ring, heading, labels, markers}`, or null while + * the layer has no overlay. `container` is the element it sits in. + */ + const overlay = shallowRef(null); + + /** + * @param {?object} next A fresh presentation, frozen by the layer; every + * publish re-renders. + */ + function publish(next) { + overlay.value = next; + } + + return { overlay, publish }; + }, +); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index db467aabe..b6d264fd6 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -14,6 +14,7 @@ import SplitFlapText from './panels/SplitFlapText.vue'; import WindInspectionMarker from './panels/WindInspectionMarker.vue'; import CelestialRing from './panels/CelestialRing.vue'; import ScreenAnnotations from './panels/ScreenAnnotations.vue'; +import AwarenessDirection from './panels/AwarenessDirection.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -127,4 +128,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/AwarenessDirection.vue b/src/ui/views/panels/AwarenessDirection.vue new file mode 100644 index 000000000..3e8f09d50 --- /dev/null +++ b/src/ui/views/panels/AwarenessDirection.vue @@ -0,0 +1,18 @@ + + +