mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(awareness): render the Global Context compass from a store
The awareness layer presents the compass ring, heading, cardinal labels and contact bearing markers through subscribeDirectionOverlay(); the application shell publishes them to the gev-awareness-direction store and the scene chrome renders them in the viewer's container.
This commit is contained in:
1 parent
b22e138c55
commit
b96cc37ab5
14 files changed
+389
-100
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
@@ -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 = {
|
||||
<WindInspectionMarker />
|
||||
<MissionReplayOverlay />
|
||||
<ScreenAnnotations />
|
||||
<AwarenessDirection />
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAwarenessDirectionStore } from '../../stores/awarenessDirection.js';
|
||||
|
||||
// The Global Context compass and the nearest contacts' bearings, inside the
|
||||
// viewer's container, as the awareness layer presents them.
|
||||
const { overlay } = storeToRefs(useAwarenessDirectionStore());
|
||||
|
||||
// A part keeps no inline placement until the layer first places it.
|
||||
const place = (part) =>
|
||||
part.left === null ? undefined : { left: part.left, top: part.top };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="overlay" :to="overlay.container">
|
||||
<div id="military-awareness-direction-overlay" :hidden="overlay.hidden" aria-hidden="true"><span v-for="label in overlay.labels" :key="label.label" :class="`military-awareness-compass-label cardinal-${label.label.toLowerCase()}`" :data-bearing="String(label.bearing)" :style="place(label)">{{ label.label }}</span><div class="military-awareness-compass-ring" :style="overlay.ring ?? undefined"></div><span class="military-awareness-compass-heading" :style="place(overlay.heading)">{{ overlay.heading.text }}</span><div v-for="(marker, index) in overlay.markers" :key="index" class="military-awareness-direction-marker" :hidden="marker.hidden" :style="marker.style ?? undefined"><span class="military-awareness-direction-arrow" :style="marker.arrow ?? undefined">➜</span><span class="military-awareness-direction-label" :style="marker.caption.style ?? undefined">{{ marker.caption.text }}</span></div></div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in new issue
Block a user