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 1/7] =?UTF-8?q?fix(vessels):=20show=20a=20359.7=C2=B0=20co?= =?UTF-8?q?urse=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 2/7] 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 812cdf6be71ba80d0b762a293951cbb5e782c373 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:09:27 +0530 Subject: [PATCH 3/7] =?UTF-8?q?fix(transit):=20show=20a=20reported=20359.6?= =?UTF-8?q?=C2=B0=20bearing=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 selected-vehicle card rounded the operator's reported bearing without wrapping, so a GTFS-Realtime vehicle heading 359.5° or more read "reported hdg 360°". The card now wraps after rounding: 359.6° reads "reported hdg 0°", 359.4° still reads "reported hdg 359°". --- docs/CURRENT-STATE.md | 4 +++- src/layers/transit/policy.js | 5 ++++- src/layers/transit/vocabulary.test.mjs | 12 ++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..4b9876428 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4347,7 +4347,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes. display delay and newest-report age, reported speed/stop, vehicle ID, and available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded count is below 90; otherwise it shows completed minutes calculated from the -rounded second count. Its anchor follows the rendered marker; text is +rounded second count. The reported heading wraps after rounding to whole +degrees, so a bearing of 359.5° or more reads `hdg 0°` rather than `hdg 360°`. +Its anchor follows the rendered marker; text is checked at 4 Hz and published when it changes. DETECT labels retain route, mode and motion, with the selected label suppressed beside its fuller card. Candidate membership changes on revisions, coalesced at 4 Hz; paints read diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 5dc17daeb..33e03aab4 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -405,9 +405,12 @@ 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) }), + t('reported hdg {heading}°', { + heading: Math.round(record.bearing) % 360, + }), ); // The operator's own claim, kept as a REPORT rather than as the live state. // It describes the moment of the report, which the display may not have diff --git a/src/layers/transit/vocabulary.test.mjs b/src/layers/transit/vocabulary.test.mjs index d20910b74..89481efa3 100644 --- a/src/layers/transit/vocabulary.test.mjs +++ b/src/layers/transit/vocabulary.test.mjs @@ -43,6 +43,18 @@ test('the selection card calls a Toronto 501 a streetcar', () => { assert.doesNotMatch(copy.details.join(' | '), /Tram/); }); +test('the selection card reads a bearing that rounds to north as hdg 0°', () => { + const hdg = (bearing) => + buildTransitSelectionCopy( + ttc(), + { id: '4400', routeId: '501', timestamp: 0, bearing }, + 'tram', + 0, + ).details.find((line) => line.includes('hdg')); + assert.equal(hdg(359.4), 'reported hdg 359°'); + assert.equal(hdg(359.6), 'reported hdg 0°'); +}); + test('detection labels carry the operator vocabulary within the field width', () => { const klass = transitDetectionClass('tram', ttc()); assert.equal(klass, 'STREETCAR TTC'); 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 4/7] =?UTF-8?q?fix(alpr):=20show=20a=20359.6=C2=B0=20camer?= =?UTF-8?q?a=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 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 5/7] 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 197f24d8972b67ace59076748d37347c81559c13 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:38:45 +0530 Subject: [PATCH 6/7] docs(changelog): add #1452 fragment --- changelog.d/1452-transit-heading-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1452-transit-heading-north.md diff --git a/changelog.d/1452-transit-heading-north.md b/changelog.d/1452-transit-heading-north.md new file mode 100644 index 000000000..95c81a080 --- /dev/null +++ b/changelog.d/1452-transit-heading-north.md @@ -0,0 +1,3 @@ +- Show a transit vehicle's reported bearing of 359.5° or more as 0°. The + selected-vehicle card rounded it without wrapping, so a bus heading 359.6° + read "reported hdg 360°" (Aditya, @Rohilalala, #1452). From 0e170e0515b646d5250e2eb28ddb1f1a798b801f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:37 -0800 Subject: [PATCH 7/7] 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(); }