Merge branch 'main' into fix/e-context-heading-north

This commit is contained in:
Sameh Khamis committed 2026-11-01 21:41:48 -08:00
commit e200e46695
15 files changed
+121 -11

No files matched your search

+3
View File
@@ -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).
+3
View File
@@ -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).
+2
View File
@@ -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).
@@ -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).
+12 -4
View File
@@ -557,7 +557,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,
@@ -958,7 +960,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,
@@ -4488,7 +4492,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
@@ -4924,7 +4930,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
+21
View File
@@ -1321,6 +1321,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.readout.text, /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');
+18
View File
@@ -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);
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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 });
@@ -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' }),
+4 -1
View File
@@ -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
+12
View File
@@ -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');
+6 -3
View File
@@ -55,7 +55,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),
@@ -86,7 +89,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();
@@ -176,7 +179,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 : '--',
});
}
+23
View File
@@ -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° ·/);
});
+1 -1
View File
@@ -107,7 +107,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)}°`,
heading: `${Math.round(activeCamera.headingDeg) % 360}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,