Merge pull request #1518 from manjunath22466/manjunath/hal-769-range-ring-visibility

fix(awareness): make the 250 km range ring readable on any ground
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-03 10:59:48 -08:00
commit a4e1763b53
6 files changed
+309 -15

No files matched your search

@@ -0,0 +1,4 @@
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
so it stays visible over desert, snow, ocean and forest and under the FLIR,
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
#1518).
+1
View File
@@ -4997,6 +4997,7 @@ silently demoting every later lookup for the session.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
+2 -1
View File
@@ -1223,5 +1223,6 @@
"src/voice/areaActions.test.mjs",
"src/voice/imageryActions.test.mjs",
"src/voice/osmActions.test.mjs",
"src/voice/realtimeProtocol.test.mjs"
"src/voice/realtimeProtocol.test.mjs",
"src/layers/awareness/rangeRing.test.mjs"
]
+14
View File
@@ -44,6 +44,20 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
export const CONTEXT_RIM_HEIGHT_M = 2500;
/** @constant The 250 km range ring around the Global Context subject.
* The old 1 px ellipse outline vanished over desert, snow and under
* the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps
* luminance contrast on light and dark ground alike, and luminance is what
* the monochrome styles keep. The core is the Context panel's blue; the halo
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
color: 'rgba(98, 181, 255, 0.95)',
outlineColor: 'rgba(4, 12, 16, 0.8)',
width: 4,
outlineWidth: 1,
segments: 128,
});
export const VESSEL_FOCUS_RADIUS_M = 3000;
export const SOURCE_LABEL = {
+204
View File
@@ -0,0 +1,204 @@
// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range
// ring is a haloed polyline that stays legible on light and dark ground,
// sits where the old ellipse outline did, and follows the subject.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { awarenessRangeRingPositions, createRendering } from './rendering.js';
import { createState } from './state.js';
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
function luminance(color) {
const channel = (c) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
return (
0.2126 * channel(color.red) +
0.7152 * channel(color.green) +
0.0722 * channel(color.blue)
);
}
/** The rendering part over a bare entity collection, counting render asks. */
function ringRendering() {
const state = createState({ services: {} });
const entities = new Cesium.EntityCollection();
state.viewer = { entities };
let renders = 0;
const rendering = createRendering({
state,
services: {
geometry: {},
render: {
governorRequestRender() {
renders += 1;
},
},
},
parts: {},
});
return { entities, rendering, renders: () => renders };
}
/** Surface distance from `center` to each ring position. */
function surfaceDistances(center, positions) {
const from = Cesium.Cartographic.fromCartesian(center);
return positions.map((position) => {
const to = Cesium.Cartographic.fromCartesian(position);
return new Cesium.EllipsoidGeodesic(from, to).surfaceDistance;
});
}
for (const [place, lat, lon] of [
['equator', 0, 0],
['Sahara', 27, 3],
['Greenland', 72, -40],
['antimeridian', 10, 179.9],
]) {
test(`ring positions stay 250 km from the subject at ${place}`, () => {
const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
const positions = awarenessRangeRingPositions(
center,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
assert.equal(positions.length, AWARENESS_RANGE_RING_STYLE.segments + 1);
assert.ok(
Cesium.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 0, 1e-6),
'the ring closes on itself',
);
// Within 100 m of 250 km on the WGS84 ellipsoid.
for (const distance of surfaceDistances(center, positions))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100, `${distance} m`);
for (const position of positions)
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(position).height -
CONTEXT_RIM_HEIGHT_M,
) < 1e-3,
);
});
}
for (const [place, lat, lon] of [
['the North Pole', 90, 0],
['the South Pole', -90, 0],
['1e-7° from the North Pole', 90 - 1e-7, 45],
['1e-8° from the South Pole', -90 + 1e-8, -120],
]) {
test(`the ring stays a full circle at ${place}`, () => {
const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
const segments = AWARENESS_RANGE_RING_STYLE.segments;
const positions = awarenessRangeRingPositions(
center,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
assert.equal(positions.length, segments + 1);
assert.ok(
Cesium.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 0, 1e-6),
'the ring closes on itself',
);
const distinct = new Set(
positions
.slice(0, segments)
.map(({ x, y, z }) => [x, y, z].map((v) => v.toFixed(1)).join()),
);
assert.equal(distinct.size, segments, 'every vertex is its own point');
// Ordinary chords are 2π × 250 km / 128 ≈ 12.3 km long.
for (let i = 0; i < segments; i++) {
const chord = Cesium.Cartesian3.distance(positions[i], positions[i + 1]);
assert.ok(chord > 11_500 && chord < 13_000, `chord ${i}: ${chord} m`);
}
for (const distance of surfaceDistances(center, positions))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100, `${distance} m`);
});
}
test('the ring core and halo contrast on light and dark ground', () => {
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
const halo = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.outlineColor,
);
// Core against halo is the edge that survives bright desert and snow, and
// the monochrome styles that map everything to luminance.
const ratio = (luminance(core) + 0.05) / (luminance(halo) + 0.05);
assert.ok(ratio >= 4.5, `core:halo contrast ${ratio.toFixed(2)}`);
assert.ok(core.alpha >= 0.9 && halo.alpha >= 0.7);
// A visible halo on each side of a core at least 2 px wide.
const { width, outlineWidth } = AWARENESS_RANGE_RING_STYLE;
assert.ok(outlineWidth >= 1);
assert.ok(width - 2 * outlineWidth >= 2);
});
test('renderVisual draws one haloed polyline ring, not an ellipse outline', () => {
const { entities, rendering } = ringRendering();
const subject = {
layerId: 'flights',
id: 'abc123',
position: Cesium.Cartesian3.fromDegrees(3, 27, 9000),
};
rendering.renderVisual(subject);
assert.equal(entities.values.length, 1);
const [entity] = entities.values;
assert.equal(entity.ellipse, undefined);
const now = Cesium.JulianDate.now();
assert.equal(entity.polyline.width.getValue(now), 4);
assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE);
const material = entity.polyline.material;
assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty);
assert.equal(
material.color.getValue(now).toCssColorString(),
Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
).toCssColorString(),
);
assert.equal(
material.outlineWidth.getValue(now),
AWARENESS_RANGE_RING_STYLE.outlineWidth,
);
const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0);
for (const distance of surfaceDistances(
ground,
entity.polyline.positions.getValue(now),
))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
});
test('the ring follows the subject past 250 m and ignores smaller drift', () => {
const { entities, rendering, renders } = ringRendering();
const subject = (lon) => ({
layerId: 'flights',
id: 'abc123',
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
});
rendering.renderVisual(subject(3));
const [entity] = entities.values;
const now = Cesium.JulianDate.now();
const first = entity.polyline.positions.getValue(now);
const rendersAfterAdd = renders();
// ~100 m east: under the threshold, nothing is rebuilt or rendered.
rendering.renderVisual(subject(3.001));
assert.equal(entity.polyline.positions.getValue(now), first);
assert.equal(renders(), rendersAfterAdd);
// ~1 km east: the same entity moves to the new centre.
rendering.renderVisual(subject(3.01));
assert.equal(entities.values.length, 1);
assert.equal(entities.values[0], entity);
const moved = entity.polyline.positions.getValue(now);
assert.notEqual(moved, first);
for (const distance of surfaceDistances(
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
moved,
))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
assert.equal(renders(), rendersAfterAdd + 1);
rendering.clearVisual();
assert.equal(entities.values.length, 0);
});
+84 -14
View File
@@ -10,9 +10,66 @@ import {
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import {
AWARENESS_PAGE_SIZE,
AWARENESS_RANGE_RING_STYLE,
CONTEXT_RIM_HEIGHT_M,
} from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
/**
* A closed ring of positions `radiusM` along the surface from `groundCenter`,
* raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's
* radius of curvature in that direction (Euler's formula), which keeps the
* ring within ~50 m of the true geodesic radius at 250 km.
* @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid.
* @param {number} radiusM Surface radius in metres.
* @param {number} heightM Height of the ring above the ellipsoid.
* @param {number} [segments] Chords around the ring.
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
*/
export function awarenessRangeRingPositions(
groundCenter,
radiusM,
heightM,
segments = AWARENESS_RANGE_RING_STYLE.segments,
) {
const { latitude, longitude } =
Cesium.Cartographic.fromCartesian(groundCenter);
const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii;
const e2 = 1 - (b * b) / (a * a);
const sinLat = Math.sin(latitude);
const cosLat = Math.cos(latitude);
const sinLon = Math.sin(longitude);
const cosLon = Math.cos(longitude);
const w = 1 - e2 * sinLat * sinLat;
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
const primeVertical = a / Math.sqrt(w);
// Step along the great circle in 3D from the centre's up, north and east
// unit vectors. Unlike the latitude/longitude destination formula, this
// stays well conditioned at and next to the poles.
const up = [cosLat * cosLon, cosLat * sinLon, sinLat];
const north = [-sinLat * cosLon, -sinLat * sinLon, cosLat];
const east = [-sinLon, cosLon, 0];
const positions = [];
for (let i = 0; i <= segments; i++) {
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
const cosB = Math.cos(bearing);
const sinB = Math.sin(bearing);
const angular =
radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical);
const cosA = Math.cos(angular);
const sinA = Math.sin(angular);
const [x, y, z] = up.map(
(u, k) => u * cosA + (north[k] * cosB + east[k] * sinB) * sinA,
);
const lat = Math.atan2(z, Math.hypot(x, y));
const lon = Math.atan2(y, x);
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
}
return positions;
}
export function createRendering({
state: layerState,
services,
@@ -292,15 +349,19 @@ export function createRendering({
);
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.
// Move the ring only after a meaningful displacement so live tracking
// does not rebuild its geometry for sub-pixel motion every 750 ms.
if (
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
250
) {
const positions = awarenessRangeRingPositions(
groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
);
for (const entity of layerState.visual.entities)
entity.position.setValue(groundCenter);
entity.polyline.positions.setValue(positions);
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
// Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual');
@@ -311,17 +372,26 @@ export function createRendering({
const entities = [];
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
const ring = AWARENESS_RANGE_RING_STYLE;
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,
polyline: {
positions: new Cesium.ConstantProperty(
awarenessRangeRingPositions(
groundCenter,
AWARENESS_RADIUS_M,
CONTEXT_RIM_HEIGHT_M,
),
),
width: ring.width,
arcType: Cesium.ArcType.NONE,
material: new Cesium.PolylineOutlineMaterialProperty({
color: Cesium.Color.fromCssColorString(ring.color),
outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor),
outlineWidth: ring.outlineWidth,
}),
},
}),
);