diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md new file mode 100644 index 000000000..af7fb4da3 --- /dev/null +++ b/changelog.d/1390-awareness-bearing-north.md @@ -0,0 +1,2 @@ +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b444589b9..b0865b7b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 68a57066a..cddb29c40 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js new file mode 100644 index 000000000..7439907d9 --- /dev/null +++ b/src/layers/awareness/presentation.js @@ -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'); +} diff --git a/src/layers/awareness/presentation.test.mjs b/src/layers/awareness/presentation.test.mjs new file mode 100644 index 000000000..54d40765f --- /dev/null +++ b/src/layers/awareness/presentation.test.mjs @@ -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), '—'); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8fe147e46..957e0bbdd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -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}`;