diff --git a/changelog.d/1518-range-ring-visibility.md b/changelog.d/1518-range-ring-visibility.md new file mode 100644 index 000000000..64adf2115 --- /dev/null +++ b/changelog.d/1518-range-ring-visibility.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 24c6e2263..e48dc20fc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e2952f326..4d03ad6ed 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 22cc8eb98..492bbc238 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -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 = { diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs new file mode 100644 index 000000000..b17651090 --- /dev/null +++ b/src/layers/awareness/rangeRing.test.mjs @@ -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); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index f65def213..be20f9d50 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -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, + }), }, }), );