mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(awareness): make the 250 km range ring readable on any ground
The Global Context range ring was a Cesium ellipse outline at 1 px in rgba(98,181,255,0.72). WebGL ignores larger ellipse outline widths, so it could not be thickened, and it vanished over desert and snow and under the FLIR, NVG and CRT styles. Draw it as a polyline instead: a 4 px line with a 2 px Context-blue core inside a 1 px dark halo on each side (PolylineOutlineMaterialProperty). The halo gives luminance contrast on light and dark ground, which the monochrome styles keep. The ring sits at the same 2.5 km height and follows the subject on the same 250 m threshold. Positions come from a new awarenessRangeRingPositions helper that stays within 50 m of 250 km. Style values live in AWARENESS_RANGE_RING_STYLE (awareness/policy.js). Adds rangeRing.test.mjs (geometry at equator, Sahara, Greenland and the antimeridian; contrast; entity shape; follow threshold) and a CURRENT-STATE note.
This commit is contained in:
1 parent
0e27d69fcc
commit
2a629f0987
5 files changed
+264
-14
No files matched your search
@@ -4957,6 +4957,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.
|
||||
|
||||
@@ -1177,6 +1177,7 @@
|
||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||
"src/ui/stores/awarenessDirection.js",
|
||||
"src/layers/awareness/directionOverlay.test.mjs",
|
||||
"src/layers/awareness/rangeRing.test.mjs",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/stores/hud.js",
|
||||
"src/ui/views/intelHudView.test.mjs",
|
||||
|
||||
@@ -44,6 +44,21 @@ 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,169 @@
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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,60 @@ 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 a few metres 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 w = 1 - e2 * sinLat * sinLat;
|
||||
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
|
||||
const primeVertical = a / Math.sqrt(w);
|
||||
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 lat = Math.asin(
|
||||
sinLat * Math.cos(angular) + cosLat * Math.sin(angular) * cosB,
|
||||
);
|
||||
const lon =
|
||||
longitude +
|
||||
Math.atan2(
|
||||
sinB * Math.sin(angular) * cosLat,
|
||||
Math.cos(angular) - sinLat * Math.sin(lat),
|
||||
);
|
||||
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -292,15 +343,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 +366,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