mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user