fix(awareness): wrap north-bound bearings (#1390)

Wrap awareness bearing and course labels at north and register the formatting helper in package boundaries.

Contribution by @rudycelekli.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-25 16:21:50 -07:00
commit 5ae22ac333
6 files changed
+30 -2

No files matched your search

@@ -0,0 +1,2 @@
- Render awareness-overlay bearings and courses that round through north as
`000°`, rather than `360°` (Rudy Celekli, #1390).
+1
View File
@@ -4684,6 +4684,7 @@ silently demoting every later lookup for the session.
- Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards.
- Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout.
- While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact.
- Awareness `BRG` and `CRS` labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `000°` rather than `360°`.
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
+3
View File
@@ -1287,6 +1287,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
@@ -1944,6 +1945,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
@@ -2736,6 +2738,7 @@
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
+10
View File
@@ -0,0 +1,10 @@
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
* @returns {string}
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
}
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatAwarenessDirection } from './presentation.js';
test('awareness bearings that round through north read 000, not 360', () => {
assert.equal(formatAwarenessDirection(359.4), '359');
assert.equal(formatAwarenessDirection(359.6), '000');
assert.equal(formatAwarenessDirection(0), '000');
assert.equal(formatAwarenessDirection(-0.4), '000');
assert.equal(formatAwarenessDirection(Number.NaN), '—');
});
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
export function createRendering({
state: layerState,
@@ -225,12 +226,12 @@ export function createRendering({
: null;
const bearingText = Number.isFinite(bearing)
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
value: formatAwarenessDirection(bearing),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;