diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0718fd1..038d23fa0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 447dbf199..1ce21e6c0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 22cc8eb98..b33da8433 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -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 = { diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs new file mode 100644 index 000000000..074d449fe --- /dev/null +++ b/src/layers/awareness/rangeRing.test.mjs @@ -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); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index f65def213..8cee87d80 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -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, + }), }, }), );