Files
gods-eye-view/src/layers/awareness/rendering.js
T

313 lines
11 KiB
JavaScript

import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
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;
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
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;
});
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
}
function scheduleDirectionOverlayUpdate(force = false) {
if (layerState.directionFrame !== null || !layerState.enabled) return;
const now = Date.now();
if (
!force &&
now - layerState.lastDirectionUpdateMs <
parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving)
)
return;
layerState.directionFrame = window.requestAnimationFrame(() => {
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = Date.now();
updateDirectionOverlay();
});
}
function cancelDirectionOverlayUpdate() {
if (layerState.directionFrame !== null)
window.cancelAnimationFrame(layerState.directionFrame);
layerState.directionFrame = null;
layerState.lastDirectionUpdateMs = 0;
}
function updateDirectionOverlay() {
const root = ensureDirectionOverlay();
if (
!root ||
!layerState.enabled ||
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
return;
}
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(width, height)
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.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 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`;
}
const directionalCohort =
layerState.results.cohorts.find(
(cohort) => cohort.id === layerState.subject.layerId,
) ||
layerState.results.cohorts.find((cohort) => cohort.id === 'military');
const page = layerState.cohortPages.get(directionalCohort?.id) || 0;
const military =
directionalCohort?.summary.nearest.slice(
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
continue;
}
const direction = Cesium.Cartesian3.subtract(
item.position,
layerState.subject.position,
layerState.DIRECTION_SCRATCH[index],
);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) {
marker.hidden = true;
continue;
}
Cesium.Cartesian3.normalize(direction, direction);
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
);
if (projection.stable) marker._lastAngle = 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);
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)`;
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
undefined,
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
);
const targetCartographic = Cesium.Cartographic.fromCartesian(
item.position,
undefined,
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
);
const bearing =
subjectCartographic && targetCartographic
? bearingBetweenCoordinates(
Cesium.Math.toDegrees(subjectCartographic.latitude),
Cesium.Math.toDegrees(subjectCartographic.longitude),
Cesium.Math.toDegrees(targetCartographic.latitude),
Cesium.Math.toDegrees(targetCartographic.longitude),
)
: null;
const bearingText = Number.isFinite(bearing)
? t('BRG {value}°', {
value: formatAwarenessDirection(bearing),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · ${t('CRS {value}°', {
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
}
}
function clearVisual() {
if (layerState.visual?.entities && layerState.viewer) {
for (const entity of layerState.visual.entities)
layerState.viewer.entities.remove(entity);
// Idle mode renders only on request; a removed ring must not linger.
governorRequestRender('awareness-visual');
}
layerState.visual = null;
}
function renderVisual(subject) {
if (!layerState.viewer || !subject?.position) return;
const cartographic = Cesium.Cartographic.fromCartesian(subject.position);
if (!cartographic) return;
const groundCenter = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
0,
);
const key = `${subject.layerId}:${subject.id}`;
if (layerState.visual?.key === key) {
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
// Move it only after a meaningful displacement so live tracking does not
// churn ten large geometries for sub-pixel motion every 750 ms.
if (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250
) {
for (const entity of layerState.visual.entities)
entity.position.setValue(groundCenter);
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
// Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual');
}
return;
}
clearVisual();
const entities = [];
entities.push(
layerState.viewer.entities.add({
position: groundCenter,
ellipse: {
semiMajorAxis: AWARENESS_RADIUS_M,
semiMinorAxis: AWARENESS_RADIUS_M,
fill: false,
outline: true,
outlineColor:
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
height: CONTEXT_RIM_HEIGHT_M,
},
}),
);
layerState.visual = {
key,
entities,
center: Cesium.Cartesian3.clone(groundCenter),
};
governorRequestRender('awareness-visual');
}
return {
ensureDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
clearVisual,
renderVisual,
};
}