From d58199a21497f3f71b2ef211965c5b14c7045872 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:28 +0530 Subject: [PATCH 01/17] =?UTF-8?q?fix(vessels):=20show=20a=20359.7=C2=B0=20?= =?UTF-8?q?course=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vessel cards and the AIS HUD rounded the direction without wrapping, so a vessel reporting no heading and a course over ground of 359.5° or more read "360°" on its cards and "HDG: 360DEG" in the HUD. The rounded direction now wraps to 0°. --- docs/CURRENT-STATE.md | 4 +++- src/data/aisLiveVessels.test.mjs | 21 +++++++++++++++++++++ src/layers/vessels/cards.js | 9 ++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..11bc07655 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -503,7 +503,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, and vessel cards, the HUD and analyst queries show a missing measurement rather than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the -highest speed and course the fields can encode. +highest speed and course the fields can encode. Vessel cards and the HUD wrap +the direction after rounding to whole degrees, so a course of 359.5° or more +reads `0°` (`0DEG` in the HUD) rather than `360°`. For an already tracked vessel, AISStream static messages update name, type, destination and IMO without changing its position, motion, fix timestamps, diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 96a5cd105..85abb9323 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1322,6 +1322,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted', assert.deepEqual(bare.details, []); }); +test('vessel cards read a course that rounds to north as 0°, not 360°', () => { + const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); + assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); + assert.equal( + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + 'CONTAINER SHIP · 14.5KT · 0°', + ); +}); + +test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { heading: null, course: 359.7 }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.textContent, /HDG: 0DEG/); + } finally { + harness.cleanup(); + } +}); + test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => { const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' })); assert.equal(unnamed.title, 'MMSI 353136000'); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 64e115637..a5671ae54 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -60,7 +60,10 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); + // 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}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -91,7 +94,7 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', + Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -181,7 +184,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) : '--', + heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', }); } From 003a622602c72b7edc8e3245478d4c7473e83212 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:12 +0530 Subject: [PATCH 02/17] docs(changelog): add #1432 fragment --- changelog.d/1432-vessel-course-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1432-vessel-course-north.md diff --git a/changelog.d/1432-vessel-course-north.md b/changelog.d/1432-vessel-course-north.md new file mode 100644 index 000000000..45d9e5845 --- /dev/null +++ b/changelog.d/1432-vessel-course-north.md @@ -0,0 +1,3 @@ +- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD + rounded the course without wrapping, so a ship with no reported heading on + a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432). From 940050d4e5abd244b291408f9de8cb5e8277de4a Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:07 +0530 Subject: [PATCH 03/17] =?UTF-8?q?fix(cctv):=20show=20a=20camera=20facing?= =?UTF-8?q?=20359.6=C2=B0=20as=20HDG=200=C2=B0,=20not=20HDG=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CCTV HUD heading token and the camera panel's meta line rounded the bearing without wrapping, so a camera facing 359.5° or more (a feed bearing, or a heading calibrated just west of north) read "HDG 360°". Both now wrap after rounding: 359.6° reads "HDG 0°", 359.4° "HDG 359°". --- docs/CURRENT-STATE.md | 4 +++- src/layers/cctv/headingConfidence.js | 3 ++- src/layers/cctv/headingConfidence.test.mjs | 9 ++++++++ src/ui/cctvControls.test.mjs | 24 ++++++++++++++++++++++ src/ui/cctvPresentation.js | 2 +- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..c3e87e8d6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4788,7 +4788,9 @@ silently demoting every later lookup for the session. wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and - `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). + `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the + camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera + facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index 26a877558..fad0867de 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -44,7 +44,8 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - const hdg = Math.round(Number(camera?.headingDeg) || 0); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. + const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs index 513bf0d1b..3cf673861 100644 --- a/src/layers/cctv/headingConfidence.test.mjs +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => { ); }); +test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => { + assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°'); + assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°'); + assert.equal( + headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }), + 'HDG 0° (ESTIMATED)', + ); +}); + test('HUD token stays untagged for surveyed and calibrated bearings', () => { assert.equal( headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 6a47ff91f..23e5ea75e 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,6 +47,30 @@ function fixture(t) { return { controls, requests }; } +test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { + const { controls } = fixture(t); + controls.actions.setPanelCollapsed = () => {}; + controls._cctvMeta = element(); + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + controls._renderCctvState({ + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }); + return controls._cctvMeta.textContent; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); + test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 070c47f64..e4dffa98e 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -171,7 +171,7 @@ export function _renderCctvState(state) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg)}°`, + heading: `${Math.round(activeCamera.headingDeg) % 360}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel, From 065d7f4f8bf35fa9b465656bbed987c9a8944f87 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:15:16 +0530 Subject: [PATCH 04/17] =?UTF-8?q?fix(alpr):=20show=20a=20359.6=C2=B0=20cam?= =?UTF-8?q?era=20bearing=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected ALPR card rounded a mapped camera:direction without wrapping, so a camera facing 359.5° or more read "DIRECTION 360°". The rounded bearing now wraps to 0°. --- docs/CURRENT-STATE.md | 4 +++- src/layers/alpr/overlay.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/visuals.js | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..dd595af17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -894,7 +894,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is replaced once by the rendered surface under it (two depth samples per paint, accepted only within -30/+40 m of the floor cell), so badges do not float or sink on slopes; wedge corners follow their own floor cells instead of staying -level with the apex. Unknown bearings never acquire a wedge. The row reads +level with the apex. Unknown bearings never acquire a wedge. The selected +card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing +of 359.5° or more reads `0°` rather than `360°`. The row reads `N nearby · M on screen`; when every loaded camera is outside the view it adds "None on screen — nearby cameras are outside the view" instead of looking unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off, diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 1bb9985af..c9aa8e5f5 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createAlprOverlay } from './overlay.js'; +import { normalizeAlprNode } from './records.js'; import { directionWedgePositions, alprLabelDetails, @@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => { ); }); +test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => { + const direction = (tag) => + alprLabelDetails( + normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag }, + }), + { label: 'Custom directory' }, + ).find((line) => line.startsWith('DIRECTION')); + assert.equal(direction('359.6'), 'DIRECTION 0°'); + assert.equal(direction('359.4'), 'DIRECTION 359°'); + assert.equal(direction('0.4'), 'DIRECTION 0°'); +}); + test('overlay glyphs and native badges shrink alike with distance', () => { assert.equal(markerScale(100), 1.15); assert.equal(markerScale(40_000), 0.45); diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 3a3d3fc5f..2a9202d92 100644 --- a/src/layers/alpr/visuals.js +++ b/src/layers/alpr/visuals.js @@ -49,8 +49,9 @@ 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)}°`); + details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] From 6364817b5b83adf099b370c951cc38170eb18cba Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:16:45 +0530 Subject: [PATCH 05/17] fix(directions): show a 9.96 km route as 10 km, not 10.0 km routeDistanceText picked one decimal while the raw kilometres were under 10, then rounded them for display, so 9.95 km or more read "10.0 km". It now picks the unit from the rounded tenth, as it already does at the metre/kilometre boundary. --- docs/CURRENT-STATE.md | 4 +++- src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..ce635160b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4172,7 +4172,9 @@ animate). Disable clears all state. At the metre/kilometre boundary, route distance labels use the rounded ten-metre value to select the unit, switching to kilometres at 995 metres; -a 996-metre leg displays as `1.0 km` rather than `1000 m`. +a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres +start where the label would round to 10.0 km, so a 9.96 km route displays as +`10 km` rather than `10.0 km`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index bf6e2878e..a51796edd 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', ( assert.equal(routeDistanceText(994), '990\u00a0m'); assert.equal(routeDistanceText(996), '1.0\u00a0km'); assert.equal(routeDistanceText(1500), '1.5\u00a0km'); + assert.equal(routeDistanceText(9940), '9.9\u00a0km'); + assert.equal(routeDistanceText(9960), '10\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); assert.equal(routeDurationText(59.4), '59\u00a0s'); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b8cceff02..9d0516c28 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -115,7 +115,8 @@ export function routeDistanceText(meters) { const tens = Math.round(meters / 10) * 10; if (tens < 1000) return formatQuantity(tens, 'm'); const km = meters / 1000; - return km < 10 + // Pick the unit from the tenth it prints: 9.96 km read "10.0 km". + return Math.round(meters / 100) < 100 ? formatQuantity(km, 'km', { minimumFractionDigits: 1, maximumFractionDigits: 1, From e4a964375a0fa0f50a1cf58166f5a5a27fea3eaf Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:47 +0530 Subject: [PATCH 06/17] fix(annotations): label a 996 m line or route as 1.0 km, not 1000 m Placed route, arrow and drawn-line labels picked metres before rounding to ten metres, so anything from 995 m to 1 km read "1000 m", even where the draw hint for the same line read "1.0 km". The label now rounds first, then picks the unit, and does the same at the 10 km cut, where 9,960 m read "10.0 km". --- docs/CURRENT-STATE.md | 2 +- src/annotations/annotationEngine.js | 7 +++++-- src/annotations/annotationEngine.test.mjs | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..1b08eb7d9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5347,7 +5347,7 @@ request viewbox and continues searching without that geographic preference. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. -- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index b0622579d..c867782c7 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1573,8 +1573,11 @@ function greatCircleM(a, b) { function formatDistance(m) { if (!Number.isFinite(m)) return null; - if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; - return `${Math.round(m / 10) * 10} m`; + // Round before each unit cut: 996 m was printed as "1000 m" and + // 9,960 m as "10.0 km". + const tens = Math.round(m / 10) * 10; + if (tens < 1000) return `${tens} m`; + return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`; } function composeRouteLabel(baseLabel, distM, durS, mode, fallback) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 5f39a077a..4f5804290 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t assert.deepEqual(received.map(options => options.allowDistant), [false, true]); engine.destroy(); }); + +test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => { + installAnimationFrameStubs(t); + const labels = []; + const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + for (const metres of [994, 996, 9940, 9960]) { + await engine.annotate([ + { type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] }, + ]); + } + assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']); + engine.destroy(); +}); From fdcbcdaaef39373c5ce2981914ade6a4185add9e Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:02 +0530 Subject: [PATCH 07/17] docs(changelog): add #1441 fragment --- changelog.d/1441-cctv-heading-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1441-cctv-heading-north.md diff --git a/changelog.d/1441-cctv-heading-north.md b/changelog.d/1441-cctv-heading-north.md new file mode 100644 index 000000000..d65e5c2d8 --- /dev/null +++ b/changelog.d/1441-cctv-heading-north.md @@ -0,0 +1,3 @@ +- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token + and the camera panel rounded the bearing without wrapping, so a camera + facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441). From 81cf79157cff29960d4e5c9174507255eed2c74b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:34 +0530 Subject: [PATCH 08/17] docs(changelog): add #1445 fragment --- changelog.d/1445-annotation-label-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1445-annotation-label-units.md diff --git a/changelog.d/1445-annotation-label-units.md b/changelog.d/1445-annotation-label-units.md new file mode 100644 index 000000000..494c663db --- /dev/null +++ b/changelog.d/1445-annotation-label-units.md @@ -0,0 +1,3 @@ +- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of + 9,950 m or more as 10 km. The placed label picked the unit before rounding, + so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445). From 50e06181c6f82780d57a1653335ebd73e46ee657 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:25:12 +0530 Subject: [PATCH 09/17] docs(changelog): add #1450 fragment --- changelog.d/1450-directions-ten-km.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1450-directions-ten-km.md diff --git a/changelog.d/1450-directions-ten-km.md b/changelog.d/1450-directions-ten-km.md new file mode 100644 index 000000000..1b348493e --- /dev/null +++ b/changelog.d/1450-directions-ten-km.md @@ -0,0 +1,3 @@ +- Show a route of 9.95 km or more as 10 km. The Directions row and step + cards kept one decimal until the raw distance reached 10 km, so 9.96 km + read "10.0 km" (Aditya, @Rohilalala, #1450). From 420d64ceccad8f6827cbc97f773f488e7241f454 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:37:01 +0530 Subject: [PATCH 10/17] docs(changelog): add #1451 fragment --- changelog.d/1451-alpr-direction-north.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1451-alpr-direction-north.md diff --git a/changelog.d/1451-alpr-direction-north.md b/changelog.d/1451-alpr-direction-north.md new file mode 100644 index 000000000..74f04b7ab --- /dev/null +++ b/changelog.d/1451-alpr-direction-north.md @@ -0,0 +1,2 @@ +- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0° + rather than DIRECTION 360° (Aditya, @Rohilalala, #1451). From ac0e124a67ea8cc45435fc0d6b51136656f19b61 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:11:42 +0530 Subject: [PATCH 11/17] =?UTF-8?q?fix(flights):=20read=20a=20359.6=C2=B0=20?= =?UTF-8?q?track=20as=200=C2=B0=20in=20the=20voice=20context,=20not=20360?= =?UTF-8?q?=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voice context records for a selected flight or military contact rounded the track without wrapping, so a contact on 359.5° or more was described to the assistant as heading "360°". Both now wrap after rounding: 359.6° reads "0°", 359.4° still reads "359°". --- docs/CURRENT-STATE.md | 2 +- src/layers/flights/ownership.test.mjs | 22 ++++++++++++++++++++++ src/layers/flights/tracking.js | 3 ++- src/layers/military/ownership.test.mjs | 25 +++++++++++++++++++++++++ src/layers/military/tracking.js | 3 ++- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..7bd48f5e7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5164,7 +5164,7 @@ and unreachable upstream (502/504) separately from road geometry. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). - **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`. - **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight. - - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. + - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`. - **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload. - **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`). - **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt. diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 813df5641..16c1a9cc6 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createCivilFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createEnrichment } from './enrichment.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy await new Promise((done) => setImmediate(done)); assert.equal(flightState._enrichActive, 0); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: services(), + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, altitudeM: 9000 }), + _contactLabel: () => 'AAL1', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('abc123').properties.heading, + '0°', + ); +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dde8f4f95..dd61e6948 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -125,8 +125,9 @@ 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)}°` + ? `${Math.round(described.track) % 360}°` : '', route: route || '', icao24, diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 41081c137..49aebee76 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, ); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const stubs = services(); + stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type }; + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: stubs, + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }), + _toCleanText: (value) => value || '', + _formatAltitude: () => '', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('ae1234').properties.heading, + '0°', + ); +}); diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8c9723176..b43a4d88b 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -119,8 +119,9 @@ 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)}°` + ? `${Math.round(described.track) % 360}°` : '', icao24, // Honesty cue: the contact is coasting on dead reckoning, so the From bf5cbe74233b9e986d01099302536442bb8d567d Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:16:40 +0530 Subject: [PATCH 12/17] docs(changelog): add #1454 fragment --- changelog.d/1454-voice-context-heading-north.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1454-voice-context-heading-north.md diff --git a/changelog.d/1454-voice-context-heading-north.md b/changelog.d/1454-voice-context-heading-north.md new file mode 100644 index 000000000..ee9f3a63a --- /dev/null +++ b/changelog.d/1454-voice-context-heading-north.md @@ -0,0 +1,2 @@ +- Describe a selected flight or military contact on 359.5° or more to the + voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454). From af79691a190b692963f4ff5793a6f9af787f0963 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:32 -0800 Subject: [PATCH 13/17] test(cctv): check the north-rounding meta line through presentCctvPanel The panel now renders from the CCTV store, so the meta line is no longer written to a DOM node by _renderCctvState. Assert on presentCctvPanel's meta text instead. --- src/ui/cctvControls.test.mjs | 24 ------------------------ src/ui/cctvPanel.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 5389bf0f4..9a5d746d7 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,30 +47,6 @@ function fixture(t) { return { controls, requests }; } -test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { - const { controls } = fixture(t); - controls.actions.setPanelCollapsed = () => {}; - controls._cctvMeta = element(); - const meta = (headingDeg) => { - const camera = { - id: 'a', - city: 'Austin', - headingDeg, - fovDeg: 74, - rangeM: 700, - }; - controls._renderCctvState({ - enabled: true, - activeCameraId: camera.id, - activeCamera: camera, - cameras: [camera], - }); - return controls._cctvMeta.textContent; - }; - assert.match(meta(359.4), /· HDG 359° ·/); - assert.match(meta(359.6), /· HDG 0° ·/); -}); - test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 1675d6119..a3dbef786 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => { await nextTick(); assert.equal(byId('cctv-summary').textContent, text); }); + +test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => { + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + return presentCctvPanel( + { + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }, + true, + ).meta; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); From 0e170e0515b646d5250e2eb28ddb1f1a798b801f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:37 -0800 Subject: [PATCH 14/17] test(vessels): read the north-rounding HUD heading from the published readout The vessel HUD now publishes its text through services.readouts, so the test harness exposes hud.readout rather than a DOM node's textContent. --- src/data/aisLiveVessels.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index f9a897f65..8e12bff94 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1336,7 +1336,7 @@ test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { try { Object.assign(harness.record, { heading: null, course: 359.7 }); assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); - assert.match(harness.hud.textContent, /HDG: 0DEG/); + assert.match(harness.hud.readout.text, /HDG: 0DEG/); } finally { harness.cleanup(); } From 0e90a03de702cccbc007ee19ed93d991379f9ab1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:03:12 -0800 Subject: [PATCH 15/17] Round every heading readout through one shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transit, ALPR, vessel, CCTV and flight/military context fixes each wrapped 359.5° to north with an inline Math.round(x) % 360. They now call roundHeadingDegrees in src/data/headingDegrees.js, which also wraps negative and multi-turn bearings. normalizeHeading moves there with it, and cockpitMath re-exports it so existing imports keep working. It now adds a turn only to a negative remainder, so a bearing just under half a degree keeps its precision. The awareness, Local ADS-B, cockpit wind, cockpit heading and Street Level readouts use the same helper. The Cockpit's TRACK ACQUIRED signal rounded without wrapping and could read COURSE 360°; it now uses formatHeadingReadout, which shows --- for a track that is not finite. --- .../1454-voice-context-heading-north.md | 3 ++ docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 14 ++++++ src/cockpitMath.js | 14 +++--- src/cockpitMath.test.mjs | 1 + src/data/headingDegrees.js | 19 ++++++++ src/data/headingDegrees.test.mjs | 47 +++++++++++++++++++ src/layers/alpr/visuals.js | 4 +- src/layers/awareness/presentation.js | 5 +- src/layers/cctv/headingConfidence.js | 4 +- src/layers/flights/tracking.js | 4 +- src/layers/localAdsb/card.js | 3 +- src/layers/military/tracking.js | 4 +- src/layers/transit/policy.js | 4 +- src/layers/vessels/cards.js | 11 +++-- src/ui/cctvPresentation.js | 3 +- src/ui/cockpitInstruments.js | 2 +- src/ui/cockpitPresentation.js | 9 ++-- src/ui/cockpitTrackingController.js | 5 +- src/ui/streetLevelPresentation.js | 4 +- 21 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 src/data/headingDegrees.js create mode 100644 src/data/headingDegrees.test.mjs 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 = []; From 7d236a1174c5913787ff5a130b1b7c6da7d7d322 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:51:51 -0800 Subject: [PATCH 16/17] build: cache compiled components and load them on the test thread Since the UI moved to single-file components, every unit-test process compiled the views it imports from scratch, and the loader ran off-thread (module.register), adding a round trip to every module a test imports, including the ones that are not components. Core's unit tests took about twice as long, and a host running Core's suite on smaller runners passed its CI time limit. register-vue now installs synchronous hooks on the loading thread (module.registerHooks). Compiled components are kept in the system temp directory, keyed by the file's path and text, the Vue version and the loader's own code, so each one compiles once across test processes; the compiler itself loads only on a cache miss. Cached modules run as code, so the directory is used only when it is a real directory owned by the current user and writable by no one else; on Windows it lives in the user's local app data instead of a temp directory that may be shared. The cache never sits inside the package tree, and GEV_VUE_CACHE=0 compiles every time. On four cores the full suite went from 441 s to 240 s from a cold cache. --- build/registerVue.js | 9 ++- build/vueLoader.js | 129 +++++++++++++++++++++++++++++++-- scripts/format-scope.json | 1 + src/tooling/vueLoader.test.mjs | 111 ++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 src/tooling/vueLoader.test.mjs diff --git a/build/registerVue.js b/build/registerVue.js index eb8dc4736..6db7d017c 100644 --- a/build/registerVue.js +++ b/build/registerVue.js @@ -1,5 +1,8 @@ -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; +import { load } from './vueLoader.js'; // Lets Node import single-file components: `node --import=`. -// Hosts reach it as `/build/register-vue`. -register('./vueLoader.js', import.meta.url); +// Hosts reach it as `/build/register-vue`. The hooks run on the +// loading thread: off-thread hooks (module.register) would add a round trip +// to every module a test imports, not only the components. +registerHooks({ load }); diff --git a/build/vueLoader.js b/build/vueLoader.js index a549fca47..13026dc04 100644 --- a/build/vueLoader.js +++ b/build/vueLoader.js @@ -1,14 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js'; +const require = createRequire(import.meta.url); + +// The compiler loads on the first component that is not cached, so a test +// process that imports no views, or only cached ones, never pays for it. +let compiler = null; +const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc')); + /** * Compile a single-file component to an ES module for Node, so unit tests (the * application's and a host's) can import components without a bundler. * Styles are dropped: tests read the global stylesheet separately. */ export function compileVueModule(source, filename) { + const { compileScript, compileTemplate, parse } = sfcCompiler(); // The template is parsed with the SFC; parse it as the browser build does. const { descriptor, errors } = parse(source, { filename, @@ -40,14 +58,113 @@ export function compileVueModule(source, filename) { return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } -/** Node module hook: load `.vue` files as compiled modules. */ -export async function load(url, context, nextLoad) { +// The loader's own code is part of every key, so a change to how components +// compile never reuses output from before it. +let loaderFingerprint = null; +const fingerprint = () => + (loaderFingerprint ??= createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./vueOptions.js', import.meta.url))) + .update(require('vue/package.json').version) + .digest('hex')); + +/** + * Where compiled components are kept between test processes, never a + * package's own tree (a host's checks refuse files inside Core): the system + * temp directory, or on Windows the per-user local app data directory, since + * a temp directory there may be shared and its ownership is not checked. + * GEV_VUE_CACHE=0 compiles every time. + */ +export function vueCacheDirectory( + env = process.env, + platform = process.platform, +) { + if (env.GEV_VUE_CACHE === '0') return null; + if (platform === 'win32') { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null; + } + return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`); +} + +/** The cache entry for one component: its path, its text and the loader. */ +export function vueCacheKey(source, filename) { + return createHash('sha256') + .update(JSON.stringify([fingerprint(), filename])) + .update('\0') + .update(source) + .digest('hex'); +} + +/** + * Compile a component, reusing a previous process's result for the same text. + * A cache that cannot be read or written only costs the compile. + */ +export function cachedVueModule( + source, + filename, + directory = vueCacheDirectory(), +) { + if (!directory || !ownCacheDirectory(directory)) { + return compileVueModule(source, filename); + } + const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`); + try { + return readFileSync(entry, 'utf8'); + } catch { + // Not compiled yet. + } + const compiled = compileVueModule(source, filename); + // Concurrent test processes and threads may compile the same component; + // each writes its own new file and the rename makes the entry appear whole. + const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 }); + renameSync(partial, entry); + } catch { + // An unwritable directory, or a rename another process holds open, + // leaves the cache without this entry. + try { + rmSync(partial, { force: true }); + } catch { + // A partial file that cannot be removed is only clutter. + } + } + return compiled; +} + +/** + * Cached modules run as code, so the directory must be this user's own: a + * real directory (not a link), owned by the current user and writable by no + * one else. In a shared temp directory another user could otherwise create it + * first and plant modules. On Windows it sits in the user's own profile. + */ +const checkedDirectories = new Map(); +export function ownCacheDirectory(directory) { + if (!checkedDirectories.has(directory)) { + checkedDirectories.set(directory, isOwnDirectory(directory)); + } + return checkedDirectories.get(directory); +} + +function isOwnDirectory(directory) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stats = lstatSync(directory); + if (!stats.isDirectory()) return false; + if (process.platform === 'win32') return true; + return stats.uid === process.getuid() && (stats.mode & 0o022) === 0; + } catch { + return false; + } +} + +/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */ +export function load(url, context, nextLoad) { if (!url.endsWith('.vue')) return nextLoad(url, context); const filename = fileURLToPath(url); - const source = await readFile(filename, 'utf8'); return { format: 'module', - source: compileVueModule(source, filename), + source: cachedVueModule(readFileSync(filename, 'utf8'), filename), shortCircuit: true, }; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c6ae4b493..240b52e12 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -68,6 +68,7 @@ "server/standalone/vite.config.js", "vite.config.js", "src/tooling/viteBuild.test.mjs", + "src/tooling/vueLoader.test.mjs", "src/tooling/allowedHosts.test.mjs", "server/providers/common/http.js", "server/providers/common/query.js", diff --git a/src/tooling/vueLoader.test.mjs b/src/tooling/vueLoader.test.mjs new file mode 100644 index 000000000..772299c0b --- /dev/null +++ b/src/tooling/vueLoader.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + cachedVueModule, + compileVueModule, + ownCacheDirectory, + vueCacheDirectory, + vueCacheKey, +} from '../../build/vueLoader.js'; + +const SOURCE = + '\n\n'; +const FILE = '/virtual/Greeting.vue'; + +test('a component compiles once and later processes read the cached module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const compiled = cachedVueModule(SOURCE, FILE, directory); + assert.equal(compiled, compileVueModule(SOURCE, FILE)); + const entries = readdirSync(directory); + assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]); + // A cached entry is returned as stored, without compiling again. + writeFileSync(path.join(directory, entries[0]), 'export default "cached";'); + assert.equal( + cachedVueModule(SOURCE, FILE, directory), + 'export default "cached";', + ); +}); + +test('an edited component or another path is a new cache entry', () => { + const key = vueCacheKey(SOURCE, FILE); + assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key); + assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key); + assert.equal(vueCacheKey(SOURCE, FILE), key); +}); + +test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => { + assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null); + if (process.platform !== 'win32') { + const directory = vueCacheDirectory({}); + assert.equal(path.dirname(directory), tmpdir()); + assert.match(path.basename(directory), /^gev-vue-sfc-/); + } + // Windows temp directories may be shared, so the cache lives in the + // user's profile, or not at all. + assert.equal( + vueCacheDirectory( + { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' }, + 'win32', + ), + path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'), + ); + assert.equal(vueCacheDirectory({}, 'win32'), null); + assert.equal( + cachedVueModule(SOURCE, FILE, null), + compileVueModule(SOURCE, FILE), + ); +}); + +test('a cache directory that cannot be written still returns the compiled module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // A file where the cache directory should be makes every write fail. + const blocked = path.join(directory, 'blocked'); + writeFileSync(blocked, ''); + assert.equal( + cachedVueModule(SOURCE, FILE, blocked), + compileVueModule(SOURCE, FILE), + ); +}); + +test( + 'a cache directory others can write to, or a link to one, is never read', + { + skip: process.platform === 'win32', + }, + (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'shared'); + mkdirSync(shared); + chmodSync(shared, 0o777); + // A module planted where the cache would look must not run. + writeFileSync( + path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`), + 'export default "planted";', + ); + assert.equal(ownCacheDirectory(shared), false); + assert.equal( + cachedVueModule(SOURCE, FILE, shared), + compileVueModule(SOURCE, FILE), + ); + const own = path.join(root, 'own'); + mkdirSync(own, { mode: 0o700 }); + const link = path.join(root, 'link'); + symlinkSync(own, link); + assert.equal(ownCacheDirectory(link), false); + assert.equal(ownCacheDirectory(own), true); + }, +); From edf8c0929d2216bd95362af9c9cf5bbb76447576 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:37:29 -0800 Subject: [PATCH 17/17] changelog: note the component cache --- changelog.d/1500-vue-loader-cache.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1500-vue-loader-cache.md diff --git a/changelog.d/1500-vue-loader-cache.md b/changelog.d/1500-vue-loader-cache.md new file mode 100644 index 000000000..da2ff7c13 --- /dev/null +++ b/changelog.d/1500-vue-loader-cache.md @@ -0,0 +1,4 @@ +- `build/register-vue` loads components on the test thread and keeps compiled + components between test processes in a per-user cache directory, so Node + tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles + every time (samehkhamis, #1500).