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:
Sameh Khamis committed 2026-10-31 22:01:56 -07:00
1 parent b22e138c55
commit b96cc37ab5
14 files changed
+389 -100

No files matched your search

+2 -2
View File
@@ -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
+6 -1
View File
@@ -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
+3 -1
View File
@@ -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"
]
+7 -3
View File
@@ -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",
@@ -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,
);
});
+1
View File
@@ -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,
+1 -6
View File
@@ -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;
+1 -1
View File
@@ -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. */
+116 -81
View File
@@ -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,
+6 -5
View File
@@ -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,
+13
View File
@@ -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?.();
+28
View File
@@ -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 };
},
);
+2
View File
@@ -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>