diff --git a/changelog.d/1454-voice-context-heading-north.md b/changelog.d/1454-voice-context-heading-north.md index ee9f3a63a..5653c4785 100644 --- a/changelog.d/1454-voice-context-heading-north.md +++ b/changelog.d/1454-voice-context-heading-north.md @@ -1,2 +1,5 @@ - Describe a selected flight or military contact on 359.5° or more to the voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454). +- Heading and bearing readouts share one rounding helper, so the Cockpit's + TRACK ACQUIRED course no longer reads 360°, and a negative bearing wraps + instead of printing a minus sign (#1454). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f30047d8..d243a3a51 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5757,6 +5757,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c6ae4b493..abe454fe2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1180,5 +1180,6 @@ "src/testSupport/hudReadouts.mjs", "src/ui/stores/firstRun.js", "src/firstRunLauncherView.test.mjs", - "src/firstRunCopy.js" + "src/firstRunCopy.js", + "src/data/headingDegrees.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c8783bde9..bf1eb1a3c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -745,6 +745,7 @@ "cctv-controls": { "exports": ["./ui/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", @@ -814,6 +815,7 @@ "./ui/cockpit/vision" ], "modules": [ + "src/data/headingDegrees.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", @@ -883,6 +885,7 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/ui/mobileShell.js", "src/exclusiveSurfaces.js", @@ -1253,6 +1256,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1315,6 +1319,7 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1400,6 +1405,7 @@ "vessel-layer": { "exports": ["./layers/vessels"], "modules": [ + "src/data/headingDegrees.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -1645,6 +1651,7 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/cockpitMath.js", @@ -1783,6 +1790,7 @@ "cctv-layer": { "exports": ["./layers/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/data/cctvCards.js", "src/data/cctvGizmo.js", "src/data/cctvLod.js", @@ -1911,6 +1919,7 @@ "alpr-cameras": { "exports": ["./layers/alpr"], "modules": [ + "src/data/headingDegrees.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", @@ -2189,6 +2198,7 @@ "./standalone/catalog" ], "modules": [ + "src/data/headingDegrees.js", "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", @@ -3163,6 +3173,7 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/headingDegrees.js", "src/cyberSonar.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", @@ -4319,6 +4330,7 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", "src/data/approxDistance.js", @@ -4396,6 +4408,7 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", @@ -4623,6 +4636,7 @@ "tools": { "exports": ["./tools"], "modules": [ + "src/data/headingDegrees.js", "src/tools/index.js", "src/tools/catalog.js", "src/tools/schema.js", diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 888c33ead..3186b1f3c 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -1,10 +1,10 @@ -export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; +import { + normalizeHeading, + roundHeadingDegrees, +} from './data/headingDegrees.js'; -/** Normalize a heading into the [0, 360) range. */ -export function normalizeHeading(value) { - if (!Number.isFinite(value)) return 0; - return ((value % 360) + 360) % 360; -} +export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; +export { normalizeHeading }; /** * Three-digit heading readout, 000–359. @@ -12,7 +12,7 @@ export function normalizeHeading(value) { */ export function formatHeadingReadout(degrees) { if (!Number.isFinite(degrees)) return '---'; - return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0'); + return String(roundHeadingDegrees(degrees)).padStart(3, '0'); } /** Advance a displayed heading along the shortest arc without exceeding a slew rate. */ diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index e49b91a57..5a7f029c0 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => { assert.equal(formatHeadingReadout(359.4), '359'); assert.equal(formatHeadingReadout(0), '000'); assert.equal(formatHeadingReadout(10.2), '010'); + assert.equal(formatHeadingReadout(-0.6), '359'); assert.equal(formatHeadingReadout(Number.NaN), '---'); }); diff --git a/src/data/headingDegrees.js b/src/data/headingDegrees.js new file mode 100644 index 000000000..af2c1203a --- /dev/null +++ b/src/data/headingDegrees.js @@ -0,0 +1,19 @@ +/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */ +export function normalizeHeading(value) { + if (!Number.isFinite(value)) return 0; + // Only a negative remainder takes the extra turn, so a positive bearing + // keeps its precision (0.49999999999999 must not become 360.5). + const remainder = value % 360; + const wrapped = remainder < 0 ? remainder + 360 : remainder; + return wrapped === 360 ? 0 : wrapped; +} + +/** + * Whole degrees for display, 0–359. 359.5° and above rounds to north (0), + * and negative or multi-turn values wrap. Null when the value is not finite, + * so each caller keeps its own placeholder. + */ +export function roundHeadingDegrees(value) { + if (!Number.isFinite(value)) return null; + return Math.round(normalizeHeading(value)) % 360; +} diff --git a/src/data/headingDegrees.test.mjs b/src/data/headingDegrees.test.mjs new file mode 100644 index 000000000..021813eff --- /dev/null +++ b/src/data/headingDegrees.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js'; + +test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => { + assert.equal(normalizeHeading(370), 10); + assert.equal(normalizeHeading(-10), 350); + assert.equal(normalizeHeading(720), 0); + assert.equal(normalizeHeading(Number.NaN), 0); + assert.equal(normalizeHeading(Infinity), 0); +}); + +test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => { + assert.equal(roundHeadingDegrees(359.4), 359); + assert.equal(roundHeadingDegrees(359.5), 0); + assert.equal(roundHeadingDegrees(359.6), 0); + assert.equal(roundHeadingDegrees(360), 0); + assert.equal(roundHeadingDegrees(0), 0); + assert.equal(roundHeadingDegrees(194.2), 194); +}); + +test('roundHeadingDegrees wraps negative and multi-turn bearings', () => { + assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true); + assert.equal(roundHeadingDegrees(-0.6), 359); + assert.equal(roundHeadingDegrees(-1), 359); + assert.equal(roundHeadingDegrees(719.6), 0); + assert.equal(roundHeadingDegrees(-360.6), 359); +}); + +test('roundHeadingDegrees returns null for a value that is not finite', () => { + for (const value of [ + Number.NaN, + Infinity, + -Infinity, + null, + undefined, + '12', + ]) { + assert.equal(roundHeadingDegrees(value), null); + } +}); + +test('normalizeHeading keeps a positive bearing just under a half degree', () => { + assert.equal(roundHeadingDegrees(0.49999999999999), 0); + assert.equal(roundHeadingDegrees(359.49999999999994), 359); + assert.equal(normalizeHeading(-1e-15) < 360, true); +}); diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 2a9202d92..9f4d9899a 100644 --- a/src/layers/alpr/visuals.js +++ b/src/layers/alpr/visuals.js @@ -5,6 +5,7 @@ import { DIRECTION_CONE_HALF_ANGLE_DEG, MARKER_SCALE_BY_DISTANCE, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; /** * Badge scale at a camera distance, matching Cesium's NearFarScalar for the @@ -49,9 +50,8 @@ export function alprLabelDetails(record, source) { const details = [ sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, ]; - // Wrap after rounding: 359.6° rounds to 360°, which is north. if (Number.isFinite(record.directionDeg)) - details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`); + details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js index 7439907d9..854fee3b8 100644 --- a/src/layers/awareness/presentation.js +++ b/src/layers/awareness/presentation.js @@ -1,3 +1,5 @@ +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; + /** * Format a compass direction for the awareness overlay. * @param {number} degrees @@ -5,6 +7,5 @@ */ export function formatAwarenessDirection(degrees) { if (!Number.isFinite(degrees)) return '—'; - const normalized = ((degrees % 360) + 360) % 360; - return String(Math.round(normalized) % 360).padStart(3, '0'); + return String(roundHeadingDegrees(degrees)).padStart(3, '0'); } diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index fad0867de..1c9be36fe 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -1,4 +1,5 @@ import { t } from '../../i18n/index.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; /** * @module headingConfidence @@ -44,8 +45,7 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - // 359.5° and above rounds to north; modulo keeps it from printing 360°. - const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; + const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dd61e6948..59270892e 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -24,6 +24,7 @@ import { TRAIL_COLOR, CYAN_TRANSPARENT, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createTracking({ flightState, @@ -125,9 +126,8 @@ export function createTracking({ speed: Number.isFinite(described.velocityMps) ? `${Math.round(described.velocityMps * 1.944)} kt` : '', - // 359.5° and above rounds to north; modulo keeps it from reading 360°. heading: Number.isFinite(described.track) - ? `${Math.round(described.track) % 360}°` + ? `${roundHeadingDegrees(described.track)}°` : '', route: route || '', icao24, diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index c1c923a8c..a17ba93c1 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -1,6 +1,7 @@ import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js'; import { formatNumber, t, tn } from '../../i18n/index.js'; import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const DASH = '—'; @@ -152,7 +153,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track % 360}°`, + track: track === null ? DASH : `${roundHeadingDegrees(track)}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b43a4d88b..5ad60adf9 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -25,6 +25,7 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createTracking({ flightState, @@ -119,9 +120,8 @@ export function createTracking({ speed: Number.isFinite(described.velocityMps) ? `${Math.round(described.velocityMps * 1.944)} kt` : '', - // 359.5° and above rounds to north; modulo keeps it from reading 360°. heading: Number.isFinite(described.track) - ? `${Math.round(described.track) % 360}°` + ? `${roundHeadingDegrees(described.track)}°` : '', icao24, // Honesty cue: the contact is coasting on dead reckoning, so the diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 33e03aab4..eabe5df4a 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -9,6 +9,7 @@ import { transitRouteLabel, } from '../../data/transitFeeds.js'; import { displayMotion, playbackPosition } from './movement.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected'; export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ @@ -405,11 +406,10 @@ export function buildTransitSelectionCopy( reportedMotion.push( t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }), ); - // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(record.bearing)) reportedMotion.push( t('reported hdg {heading}°', { - heading: Math.round(record.bearing) % 360, + heading: roundHeadingDegrees(record.bearing), }), ); // The operator's own claim, kept as a REPORT rather than as the live state. diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 2cab53ed1..bcf3459f1 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -4,6 +4,7 @@ import { normalizeVesselType, } from '../../data/vesselLabels.js'; import { formatNumber, t } from '../../i18n/index.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createCards({ vesselState, @@ -55,10 +56,8 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - // A course of 359.5° and above rounds to north; modulo keeps it from - // printing 360°. The selected card and the HUD wrap the same way. if (Number.isFinite(direction)) - parts.push(`${Math.round(direction) % 360}°`); + parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -89,7 +88,9 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', + Number.isFinite(direction) + ? `${roundHeadingDegrees(direction)}°` + : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -179,7 +180,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', + heading: roundHeadingDegrees(heading) ?? '--', }); } diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index cd298e108..2d5b398cc 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { createCctvVideoSurface } from './cctvVideo.js'; import { presentCctvCalibration } from './cctvCalibration.js'; +import { roundHeadingDegrees } from '../data/headingDegrees.js'; export function _calBadgeLabel(badge) { switch (badge) { case 'calibrated': @@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg) % 360}°`, + heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel, diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 484125db1..dc9964ff8 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -120,7 +120,7 @@ export function updateHud( (tick) => formatAltitudeRulerTick(tick.valueFt), ); view.heading = { - text: String(Math.round(heading) % 360).padStart(3, '0'), + text: formatHeadingReadout(heading), value: heading, immediate: forceContext, }; diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index e787b79e0..6b557de7d 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,6 +1,10 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; +import { + normalizeHeading, + roundHeadingDegrees, +} from '../data/headingDegrees.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -135,8 +139,7 @@ export function cockpitUnitCase(text) { export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); - const normalized = ((value % 360) + 360) % 360; - // 359.5° and above is north. Modulo keeps that from printing 360°. - const degrees = Math.round(normalized) % 360; + const normalized = normalizeHeading(value); + const degrees = roundHeadingDegrees(value); return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index b945da4de..f308b0d2f 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -1,6 +1,7 @@ /** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ import { t } from '../i18n/index.js'; import { + formatHeadingReadout, normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; @@ -186,9 +187,7 @@ export function enter() { this.startBriefRotation(); const trackLabel = info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); - const trackHeading = String( - Math.round(normalizeHeading(info.track ?? 0)), - ).padStart(3, '0'); + const trackHeading = formatHeadingReadout(info.track ?? 0); this.pushCockpitSignal( 'track', 'track', diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index d7359dfec..95af86b1a 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -7,6 +7,7 @@ import { translateKnown, } from '../i18n/index.js'; import { keyGuidanceText, keyRequirementText } from './keyGuidance.js'; +import { roundHeadingDegrees } from '../data/headingDegrees.js'; // Street Level UI state to panel strings and flags. Pure, so it is testable. @@ -175,9 +176,8 @@ function presentViewer(state) { const { street } = state; const right = []; if (street.isPano) right.push('360°'); - // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing) % 360}°`); + right.push(`${roundHeadingDegrees(street.bearing)}°`); if (street.capturedAt) right.push(formatDate(street.capturedAt, street.capturedAtPrecision)); const left = [];