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

@@ -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,