mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(cctv): seven camera packs, semantic labels, terrain-clear monitor planes (#433)
Seven contributed CCTV camera packs (Ontario 511, Fintraffic Finland, DriveBC, TxDOT Texas, Estonia Tallinn + Tarktee, Warendorf, Live Traffic NSW) on a fair per-pack catalog cap; semantic camera labels; monitor planes lifted clear of the terrain from a precomputed, bundled ground-height dataset. Default catalog 800 → 3,446 cameras. Full context in PR #433. Co-authored-by: sendmebits <[email protected]> Co-authored-by: avocadoAgent <[email protected]> Co-authored-by: Alex Harvey <[email protected]> Co-authored-by: Jason Meredith <[email protected]> Co-authored-by: Daniel Smith <[email protected]> Co-authored-by: Vladimir Sobolev <[email protected]> Co-authored-by: Matthias Schneider <[email protected]> Co-authored-by: Cyber Knight <[email protected]> Co-authored-by: NovaSoft Australia <[email protected]>
This commit is contained in:
51 files changed
+78682
-939
No files matched your search
@@ -27,6 +27,7 @@ const layer = createCctvLayer({
|
||||
},
|
||||
});
|
||||
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
|
||||
export const migrateRangeScaleForFloor = layer.migrateRangeScaleForFloor;
|
||||
export const _pushAmbientCardEntriesForTest =
|
||||
layer._pushAmbientCardEntriesForTest;
|
||||
export const _setCctvOverlayHostForTest = layer._setCctvOverlayHostForTest;
|
||||
|
||||
+697
-185
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
allocateSourceCap,
|
||||
resolveCatalogCap,
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
rank: i,
|
||||
})),
|
||||
});
|
||||
|
||||
test('under the cap every pack is served whole, in merge order', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[pack('a', 3), pack('b', 2)],
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => s.id),
|
||||
['a-0', 'a-1', 'a-2', 'b-0', 'b-1'],
|
||||
);
|
||||
assert.deepEqual(packs, [
|
||||
{ name: 'a', offered: 3, kept: 3 },
|
||||
{ name: 'b', offered: 2, kept: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('over the cap the packs share it round-robin, and the last pack is never starved', () => {
|
||||
// The old positional slice kept the first N of [a..., b..., c...], which
|
||||
// deleted pack c entirely; the fair split thins every pack instead.
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[pack('a', 100), pack('b', 100), pack('c', 100)],
|
||||
9,
|
||||
);
|
||||
assert.equal(sources.length, 9);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => p.kept),
|
||||
[3, 3, 3],
|
||||
);
|
||||
// Each pack contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('c-')).map((s) => s.rank),
|
||||
[0, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test('a duplicate inside one pack takes one slot, not two', () => {
|
||||
const dup = {
|
||||
name: 'file',
|
||||
sources: [{ id: 'x', v: 'old' }, { id: 'x', v: 'new' }, { id: 'y' }],
|
||||
};
|
||||
const { sources } = allocateSourceCap([dup], 2);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => [s.id, s.v]),
|
||||
[
|
||||
['x', 'new'],
|
||||
['y', undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a short pack releases its turns to the others', () => {
|
||||
const { packs } = allocateSourceCap([pack('a', 2), pack('b', 50)], 10);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => p.kept),
|
||||
[2, 8],
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate ids resolve last-pack-wins and count against the winning pack', () => {
|
||||
const live = {
|
||||
name: 'live',
|
||||
sources: [{ id: 'cam-1', label: 'live' }, { id: 'cam-2' }],
|
||||
};
|
||||
const override = {
|
||||
name: 'env',
|
||||
sources: [{ id: 'cam-1', label: 'override' }],
|
||||
};
|
||||
const { sources, packs } = allocateSourceCap([live, override], 10);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => [s.id, s.label]),
|
||||
[
|
||||
['cam-2', undefined],
|
||||
['cam-1', 'override'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(packs, [
|
||||
{ name: 'live', offered: 1, kept: 1 },
|
||||
{ name: 'env', offered: 1, kept: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('1'), 8);
|
||||
assert.equal(resolveCatalogCap('999999'), CCTV_MAX_SOURCES_CEILING);
|
||||
assert.equal(resolveCatalogCap('120.9'), 120);
|
||||
});
|
||||
|
||||
test('the catalog applies the fair cap to configured packs end to end', async () => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `env-${i}`,
|
||||
name: `Env ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
process.env.CCTV_MAX_SOURCES = '9';
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const sources = await getSources();
|
||||
assert.equal(sources.length, 9);
|
||||
assert.deepEqual(
|
||||
sources.slice(0, 3).map((s) => s.id),
|
||||
['env-0', 'env-1', 'env-2'],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
cameraDisplayCode,
|
||||
normalizeSourceItem,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
test('cameraDisplayCode trims long names with an ellipsis and collapses whitespace', () => {
|
||||
assert.equal(
|
||||
cameraDisplayCode(' IH 35 at Cesar Chavez '),
|
||||
'IH 35 at Cesar Chavez',
|
||||
);
|
||||
const long = 'City of Windsor – Goyeau Street and Park Street';
|
||||
const code = cameraDisplayCode(long);
|
||||
assert.equal(code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(code.endsWith('…'));
|
||||
assert.equal(cameraDisplayCode(''), '');
|
||||
});
|
||||
|
||||
test('every pack labels by its feed name; explicit codes win; the id is the last resort', () => {
|
||||
const base = { lat: 1, lon: 1, url: 'https://x.test/a.jpg' };
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: '354',
|
||||
name: '5TH ST / CONGRESS AVE',
|
||||
sourceKind: 'austin-open-data',
|
||||
}).code,
|
||||
'5TH ST / CONGRESS AVE',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'on-860',
|
||||
name: 'Goyeau Street and Park Street',
|
||||
sourceKind: 'ontario-511-open-data',
|
||||
}).code,
|
||||
'GOYEAU STREET AND PARK STRE…',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'nsw-46fa631a',
|
||||
name: 'Erskine Street looking west',
|
||||
sourceKind: 'nsw-livetraffic',
|
||||
code: 'ERSKINE ST (SYDNEY)',
|
||||
}).code,
|
||||
'ERSKINE ST (SYDNEY)',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'fi-c1455101',
|
||||
name: '',
|
||||
sourceKind: 'fintraffic-open-data',
|
||||
}).code,
|
||||
'fi-c1455101',
|
||||
'a missing name falls back to the id',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_AUSTIN_MAX_SOURCES,
|
||||
DEFAULT_CALTRANS_MAX_SOURCES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES,
|
||||
DEFAULT_TFL_MAX_SOURCES,
|
||||
DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
DRIVEBC_WEBCAMS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
driveBcImageCredit,
|
||||
loadDriveBcSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** A DriveBC `/api/webcams/` row carrying the fields the loader reads. */
|
||||
function driveBcRow(id, lat, lon, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
name: `Camera ${id}`,
|
||||
is_on: true,
|
||||
should_appear: true,
|
||||
region_name: 'Lower Mainland',
|
||||
orientation: 'N',
|
||||
elevation: 12,
|
||||
location: { type: 'Point', coordinates: [lon, lat] },
|
||||
links: { imageDisplay: `/images/${id}.jpg?t=1` },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('DriveBC loader keeps published cameras and builds frame URLs from the camera id', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([
|
||||
driveBcRow(13, 49.001268, -122.756658, {
|
||||
name: 'Douglas (Peace Arch) Border Crossing',
|
||||
region_name: 'Border Cams',
|
||||
elevation: 10,
|
||||
// The payload's own link is ignored; frames come from the pinned host.
|
||||
links: { imageDisplay: 'https://example.com/13.jpg' },
|
||||
}),
|
||||
driveBcRow(682, 49.0579, -123.0569, {
|
||||
orientation: 'se',
|
||||
elevation: null,
|
||||
credit:
|
||||
'Images courtesy of <a href="https://www.translink.ca/" target="_blank">TransLink</a>',
|
||||
}),
|
||||
driveBcRow(1, 49.2, -123.1, { is_on: false }),
|
||||
driveBcRow(2, 49.2, -123.1, { should_appear: false }),
|
||||
driveBcRow('3', 49.2, -123.1),
|
||||
driveBcRow(4, null, -123.1),
|
||||
driveBcRow(5, 49.2, -123.1, { location: null }),
|
||||
driveBcRow(6, 1000, -123.1),
|
||||
driveBcRow(7, 34.05, -118.24),
|
||||
]);
|
||||
});
|
||||
|
||||
const cameras = await loadDriveBcSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [DRIVEBC_WEBCAMS_URL]);
|
||||
// Nearest to the Vancouver anchor first: the pack is always distance-sorted
|
||||
// so the catalog-wide cap thins it from the far end.
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['drivebc-682', 'drivebc-13'],
|
||||
);
|
||||
const border = cameras.find((camera) => camera.id === 'drivebc-13');
|
||||
const deltaport = cameras.find((camera) => camera.id === 'drivebc-682');
|
||||
assert.equal(border.url, 'https://www.drivebc.ca/images/13.jpg');
|
||||
assert.equal(border.snapshotUrl, border.url);
|
||||
assert.equal(border.name, 'Douglas (Peace Arch) Border Crossing');
|
||||
assert.equal(border.city, 'BC Border');
|
||||
assert.equal(border.provider, 'DriveBC');
|
||||
assert.equal(border.feedType, 'image');
|
||||
assert.equal(border.headingDeg, 0);
|
||||
assert.equal(border.headingConfidence, 'high');
|
||||
assert.equal(border.groundElevationM, 10);
|
||||
assert.match(border.license, /Open Government Licence – British Columbia/);
|
||||
assert.equal(
|
||||
border.credit,
|
||||
'',
|
||||
'a provincial camera carries no partner credit',
|
||||
);
|
||||
assert.equal(
|
||||
deltaport.credit,
|
||||
'Images courtesy of TransLink',
|
||||
'a partner-supplied camera names its owner, HTML stripped',
|
||||
);
|
||||
assert.equal(deltaport.city, 'Lower Mainland');
|
||||
assert.equal(
|
||||
deltaport.headingDeg,
|
||||
135,
|
||||
'orientation codes are case-insensitive',
|
||||
);
|
||||
assert.equal(
|
||||
deltaport.groundElevationM,
|
||||
0,
|
||||
'a missing elevation falls back to sea level',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC loader keeps the cameras nearest Vancouver and Victoria under its cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: '8' });
|
||||
const rows = [
|
||||
// Fort Nelson rows come first in payload order, so only distance can drop them.
|
||||
...[58.8, 58.81, 58.82, 58.83].map((lat, i) =>
|
||||
driveBcRow(100 + i, lat, -122.7),
|
||||
),
|
||||
...[49.28, 49.27, 49.26, 49.25].map((lat, i) =>
|
||||
driveBcRow(200 + i, lat, -123.12),
|
||||
),
|
||||
...[48.43, 48.44, 48.45, 48.46].map((lat, i) =>
|
||||
driveBcRow(300 + i, lat, -123.37),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
|
||||
const cameras = await loadDriveBcSourcesFromOpenData();
|
||||
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras
|
||||
.map((camera) => camera.id)
|
||||
.filter((id) => /^drivebc-10\d$/.test(id)),
|
||||
[],
|
||||
'the northern cameras are the ones dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => Response.json({ webcams: [] }),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadDriveBcSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_DRIVEBC_ENABLED: undefined,
|
||||
CCTV_DRIVEBC_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only DriveBC answers.
|
||||
return String(url) === DRIVEBC_WEBCAMS_URL
|
||||
? Response.json([driveBcRow(13, 49.001268, -122.756658)])
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['drivebc-13'],
|
||||
);
|
||||
assert.equal(sources[0].url, 'https://www.drivebc.ca/images/13.jpg');
|
||||
|
||||
process.env.CCTV_DRIVEBC_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(DRIVEBC_WEBCAMS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
DEFAULT_CALTRANS_MAX_SOURCES +
|
||||
DEFAULT_TFL_MAX_SOURCES +
|
||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES +
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES +
|
||||
DEFAULT_TXDOT_MAX_SOURCES +
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
1;
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
'the default cap must sit inside the CCTV_MAX_SOURCES ceiling',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC credit keeps partner attribution and drops operational notes', () => {
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'Images courtesy of <a href="https://x.test/">TransLink</a>',
|
||||
),
|
||||
'Images courtesy of TransLink',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit('Camera image provided by City of Vancouver'),
|
||||
'Camera image provided by City of Vancouver',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
|
||||
),
|
||||
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'This camera is located in a remote area and relies on solar power. As a result, transmission delays may occur.',
|
||||
),
|
||||
'',
|
||||
);
|
||||
assert.equal(driveBcImageCredit(null), '');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0">
|
||||
<predefinedLocationName>
|
||||
<values><value lang="et">Test Site</value></values>
|
||||
</predefinedLocationName>
|
||||
<location>
|
||||
<pointByCoordinates>
|
||||
<pointCoordinates>
|
||||
<latitude>59.4370</latitude>
|
||||
<longitude>24.7530</longitude>
|
||||
</pointCoordinates>
|
||||
</pointByCoordinates>
|
||||
</location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const SAMPLE_IMAGES_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1">
|
||||
<linearTrafficView id="1">
|
||||
<linearPredefinedLocationReference targetClass="PredefinedLocation" id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0"/>
|
||||
<trafficViewRecord id="1-1">
|
||||
<urlLink>
|
||||
<urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg</urlLinkAddress>
|
||||
<urlLinkType>image</urlLinkType>
|
||||
</urlLink>
|
||||
</trafficViewRecord>
|
||||
</linearTrafficView>
|
||||
</trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
test('Tallinn catalog loads curated ristmikud stills only', () => {
|
||||
const cameras = loadTallinnSourcesFromCatalog();
|
||||
assert.ok(
|
||||
cameras.length >= 200,
|
||||
`expected a full Tallinn pack, got ${cameras.length}`,
|
||||
);
|
||||
for (const camera of cameras.slice(0, 25)) {
|
||||
assert.match(camera.id, /^tln-/);
|
||||
assert.equal(camera.cityId, 'tallinn');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.sourceKind, 'tallinn-ristmikud');
|
||||
assert.ok(camera.url.startsWith('https://ristmikud.tallinn.ee/'));
|
||||
assert.equal(camera.url, camera.snapshotUrl);
|
||||
assert.ok(Number.isFinite(camera.lat) && Number.isFinite(camera.lon));
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee DATEX parsers join location UUIDs to image URLs', () => {
|
||||
const locations = parseTarkteeDatexLocations(SAMPLE_LOCATIONS_XML);
|
||||
const images = parseTarkteeDatexImages(SAMPLE_IMAGES_XML);
|
||||
assert.equal(locations.size, 1);
|
||||
assert.equal(images.size, 1);
|
||||
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
assert.equal(locations.get(id)?.name, 'Test Site');
|
||||
assert.equal(locations.get(id)?.lat, 59.437);
|
||||
assert.equal(locations.get(id)?.lon, 24.753);
|
||||
assert.equal(
|
||||
images.get(id),
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const xml = SAMPLE_IMAGES_XML.replace(
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
'https://evil.example/images/42/42.jpg',
|
||||
);
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadFintrafficSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
fintrafficCameraName,
|
||||
isLikelyFinlandCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
FINTRAFFIC_GROUND_ELEVATION_M,
|
||||
FINTRAFFIC_IMAGE_ORIGIN,
|
||||
FINTRAFFIC_STATIONS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
|
||||
/** One station feature in the shape tie.digitraffic.fi actually returns. */
|
||||
function station(
|
||||
id,
|
||||
{
|
||||
name = `vt4_${id}`,
|
||||
status = 'GATHERING',
|
||||
lon = 24.9384,
|
||||
lat = 60.1699,
|
||||
altitude = 0,
|
||||
presets = [],
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat, altitude] },
|
||||
properties: {
|
||||
id,
|
||||
name,
|
||||
collectionStatus: status,
|
||||
dataUpdatedTime: '2026-09-13T03:25:36Z',
|
||||
presets,
|
||||
state: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the loader against a canned station list, restoring global fetch after. */
|
||||
async function loadWith(features, { env = {}, capture = {} } = {}) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
originalEnv[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
globalThis.fetch = async (url, options) => {
|
||||
capture.url = String(url);
|
||||
capture.options = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
dataUpdatedTime: '2026-09-13T05:31:58Z',
|
||||
features,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
};
|
||||
try {
|
||||
return await loadFintrafficSourcesFromOpenData();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('Fintraffic loader turns every in-collection preset into one camera', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C01503', {
|
||||
name: 'kt51_Inkoo',
|
||||
lon: 23.99616,
|
||||
lat: 60.05374,
|
||||
presets: [
|
||||
{ id: 'C0150301', inCollection: true },
|
||||
{ id: 'C0150302', inCollection: true },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(cameras.length, 2);
|
||||
const [first, second] = cameras;
|
||||
assert.equal(first.id, 'fi-c0150301');
|
||||
assert.equal(first.name, 'kt51 Inkoo (view 01)');
|
||||
assert.equal(second.name, 'kt51 Inkoo (view 02)');
|
||||
assert.equal(first.provider, 'Fintraffic');
|
||||
assert.equal(first.city, 'Finland');
|
||||
assert.equal(first.cityId, 'finland');
|
||||
assert.equal(first.sourceKind, 'fintraffic-open-data');
|
||||
assert.equal(first.license, 'Fintraffic / digitraffic.fi (CC BY 4.0)');
|
||||
assert.equal(first.feedType, 'image');
|
||||
// Presets of one station share the station position...
|
||||
assert.equal(first.lat, 60.05374);
|
||||
assert.equal(second.lat, first.lat);
|
||||
assert.equal(second.lon, first.lon);
|
||||
// ...so the id-hash fallback heading is what keeps their gizmos apart.
|
||||
assert.notEqual(first.headingDeg, second.headingDeg);
|
||||
assert.equal(first.headingConfidence, 'low');
|
||||
});
|
||||
|
||||
test('Fintraffic frame URLs are built on the official image origin', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] }),
|
||||
]);
|
||||
|
||||
assert.equal(cameras[0].url, `${FINTRAFFIC_IMAGE_ORIGIN}C0150301.jpg`);
|
||||
assert.equal(cameras[0].snapshotUrl, cameras[0].url);
|
||||
assert.ok(cameras[0].url.startsWith('https://weathercam.digitraffic.fi/'));
|
||||
});
|
||||
|
||||
test('Fintraffic loader drops dead stations, uncollected presets and bad ids', async () => {
|
||||
const cameras = await loadWith([
|
||||
// Station off collection: every preset goes, live or not.
|
||||
station('C01601', {
|
||||
status: 'REMOVED_TEMPORARILY',
|
||||
presets: [{ id: 'C0160101', inCollection: true }],
|
||||
}),
|
||||
station('C01503', {
|
||||
presets: [
|
||||
{ id: 'C0150301', inCollection: true },
|
||||
{ id: 'C0150302', inCollection: false },
|
||||
{ id: 'C0150303' },
|
||||
// Ids that would escape the synthesized frame path, or belong to
|
||||
// another station, never become a camera.
|
||||
{ id: '../../etc/passwd', inCollection: true },
|
||||
{ id: 'C9999901', inCollection: true },
|
||||
],
|
||||
}),
|
||||
// Coordinates nowhere near Finland (swapped lat/lon).
|
||||
station('C01504', {
|
||||
lon: 60.1699,
|
||||
lat: 24.9384,
|
||||
presets: [{ id: 'C0150401', inCollection: true }],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['fi-c0150301'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Fintraffic ground elevation uses a reported altitude and falls back otherwise', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C08508', {
|
||||
altitude: 136,
|
||||
presets: [{ id: 'C0850801', inCollection: true }],
|
||||
}),
|
||||
station('C01503', {
|
||||
altitude: 0,
|
||||
presets: [{ id: 'C0150301', inCollection: true }],
|
||||
}),
|
||||
station('C03506', {
|
||||
altitude: 99_000,
|
||||
presets: [{ id: 'C0350601', inCollection: true }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('fi-c0850801').groundElevationM, 136);
|
||||
// 0 is "not reported", not sea level.
|
||||
assert.equal(
|
||||
byId.get('fi-c0150301').groundElevationM,
|
||||
FINTRAFFIC_GROUND_ELEVATION_M,
|
||||
);
|
||||
// A garbage upstream value cannot fling a camera kilometres up.
|
||||
assert.equal(byId.get('fi-c0350601').groundElevationM, 1400);
|
||||
});
|
||||
|
||||
test('Fintraffic loader identifies itself to Digitraffic and refuses redirects', async () => {
|
||||
const capture = {};
|
||||
await loadWith(
|
||||
[station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] })],
|
||||
{ capture },
|
||||
);
|
||||
|
||||
assert.equal(capture.url, FINTRAFFIC_STATIONS_URL);
|
||||
assert.equal(capture.options.headers['Digitraffic-User'], 'gods-eye-view');
|
||||
assert.equal(capture.options.headers['Accept-Encoding'], 'gzip');
|
||||
assert.equal(capture.options.redirect, 'manual');
|
||||
assert.ok(capture.options.signal instanceof AbortSignal);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://example.com/stations' },
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('Fintraffic pack caps itself and fails to an empty list, never a throw', async () => {
|
||||
const presets = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `C01503${String(index).padStart(2, '0')}`,
|
||||
inCollection: true,
|
||||
}));
|
||||
const capped = await loadWith([station('C01503', { presets })], {
|
||||
env: { CCTV_FINTRAFFIC_MAX_SOURCES: '8' },
|
||||
});
|
||||
assert.equal(capped.length, 8);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error('network down');
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
globalThis.fetch = async () => new Response('nope', { status: 503 });
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('fintrafficCameraName unpacks the machine station name and the view number', () => {
|
||||
assert.equal(
|
||||
fintrafficCameraName('vt3_Hyvinkää_Noppo', 'C01234', 'C0123402'),
|
||||
'vt3 Hyvinkää Noppo (view 02)',
|
||||
);
|
||||
// Nameless station still gets a usable label.
|
||||
assert.equal(
|
||||
fintrafficCameraName('', 'C01234', 'C0123409'),
|
||||
'Fintraffic C01234 (view 09)',
|
||||
);
|
||||
// A preset id that is just the station id has no view suffix to add.
|
||||
assert.equal(
|
||||
fintrafficCameraName('vt4_Mäntsälä', 'C01234', 'C01234'),
|
||||
'vt4 Mäntsälä',
|
||||
);
|
||||
});
|
||||
|
||||
test('isLikelyFinlandCoordinate spans the catalog extent and rejects the rest', () => {
|
||||
assert.equal(isLikelyFinlandCoordinate(60.1699, 24.9384), true); // Helsinki
|
||||
assert.equal(isLikelyFinlandCoordinate(70.088512, 27.0), true); // Utsjoki, the northern extreme
|
||||
assert.equal(isLikelyFinlandCoordinate(59.856454, 19.618722), true); // the southwestern extreme
|
||||
assert.equal(isLikelyFinlandCoordinate(51.5074, -0.1278), false); // London
|
||||
assert.equal(isLikelyFinlandCoordinate(0, 0), false); // null island
|
||||
assert.equal(isLikelyFinlandCoordinate(NaN, 24.9384), false);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Pure monitor-plane footprint math shared by the client geometry
|
||||
* (src/layers/cctv/geometry.js), the server sidecar join
|
||||
* (server/providers/cctv/groundHeights.js) and the offline precompute
|
||||
* (scripts/precompute-cctv-heights.mjs). Lives outside every ownership
|
||||
* package on purpose. No Cesium, no DOM: the same function
|
||||
* decides where a plane's support points are on the ground for both.
|
||||
*
|
||||
* The plane is the pitched far cap of the camera frustum: center at range R
|
||||
* along the heading (horizontal R·cos(pitch)), half-width R·tan(hFov/2),
|
||||
* half-height R·tan(vFov/2) with vFov from a 16:9 aspect, tilted by the
|
||||
* pitch. Support points are a 3×3 grid over that rectangle: rows bottom /
|
||||
* middle / top, columns left / center / right, named `bl bm br ml mc mr tl tm
|
||||
* tr`. Their horizontal positions depend on the pose only, never on ground
|
||||
* height, so an offline job can sample the ground under them for the nominal
|
||||
* pose and the client can reuse those samples as long as the pose is the same.
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
export const PLANE_VERT_ASPECT = 16 / 9;
|
||||
export const SUPPORT_KEYS = Object.freeze([
|
||||
'bl',
|
||||
'bm',
|
||||
'br',
|
||||
'ml',
|
||||
'mc',
|
||||
'mr',
|
||||
'tl',
|
||||
'tm',
|
||||
'tr',
|
||||
]);
|
||||
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const toDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Offset a lat/lon by a distance along a compass heading (spherical earth;
|
||||
* sub-centimetre at the ≤ 5 km ranges cameras use).
|
||||
* @param {number} lat @param {number} lon @param {number} headingDeg @param {number} distM
|
||||
* @returns {{lat:number, lon:number}}
|
||||
*/
|
||||
export function projectPoint(lat, lon, headingDeg, distM) {
|
||||
const b = toRad(headingDeg);
|
||||
const la = toRad(lat);
|
||||
const lo = toRad(lon);
|
||||
const ad = distM / EARTH_RADIUS_M;
|
||||
const la2 = Math.asin(
|
||||
Math.sin(la) * Math.cos(ad) + Math.cos(la) * Math.sin(ad) * Math.cos(b),
|
||||
);
|
||||
const lo2 =
|
||||
lo +
|
||||
Math.atan2(
|
||||
Math.sin(b) * Math.sin(ad) * Math.cos(la),
|
||||
Math.cos(ad) - Math.sin(la) * Math.sin(la2),
|
||||
);
|
||||
return { lat: toDeg(la2), lon: toDeg(lo2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plane dimensions and offsets for a pose, before any ground is known.
|
||||
* @param {{pitchDeg:number, fovDeg:number, rangeM:number}} pose
|
||||
* @returns {{R:number, pitch:number, halfW:number, halfH:number, horiz:number,
|
||||
* vert:number, upVert:number, upHoriz:number, vFovDeg:number}}
|
||||
*/
|
||||
export function planeDimensions(pose) {
|
||||
const R = Math.max(1, Number(pose.rangeM) || 1);
|
||||
const pitch = toRad(Math.max(-89, Math.min(89, Number(pose.pitchDeg) || 0)));
|
||||
const hFov = toRad(Math.max(8, Math.min(160, Number(pose.fovDeg) || 74)));
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PLANE_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
return {
|
||||
R,
|
||||
pitch,
|
||||
halfW,
|
||||
halfH,
|
||||
horiz: R * Math.cos(pitch),
|
||||
vert: R * Math.sin(pitch),
|
||||
// In-plane "up", decomposed into a vertical part and a horizontal part
|
||||
// along the heading (a downward pitch tilts the plane's top forward).
|
||||
upVert: Math.cos(pitch) * halfH,
|
||||
upHoriz: -Math.sin(pitch) * halfH,
|
||||
vFovDeg: toDeg(vFovRad),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal (lat/lon) positions of the mount and the nine plane support
|
||||
* points for a pose. Altitudes are not part of this: the caller adds the
|
||||
* ground it measured under each point.
|
||||
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number}} pose
|
||||
* @returns {{mount:{lat:number,lon:number}, capCenter:{lat:number,lon:number},
|
||||
* supports: Record<string,{lat:number,lon:number, row:-1|0|1, col:-1|0|1}>}}
|
||||
*/
|
||||
export function planeSupportPoints(pose) {
|
||||
const dims = planeDimensions(pose);
|
||||
const heading = Number(pose.headingDeg) || 0;
|
||||
const cap = projectPoint(pose.lat, pose.lon, heading, dims.horiz);
|
||||
const supports = {};
|
||||
const rows = { b: -1, m: 0, t: 1 };
|
||||
// Column letter: l / r, with the centre spelled `m` in the bottom and top
|
||||
// rows (bm, tm) and `c` in the middle row (mc).
|
||||
const cols = { l: -1, m: 0, c: 0, r: 1 };
|
||||
for (const key of SUPPORT_KEYS) {
|
||||
const row = rows[key[0]];
|
||||
const col = cols[key[1]];
|
||||
const across = projectPoint(
|
||||
cap.lat,
|
||||
cap.lon,
|
||||
heading + 90,
|
||||
col * dims.halfW,
|
||||
);
|
||||
const along = projectPoint(
|
||||
across.lat,
|
||||
across.lon,
|
||||
heading,
|
||||
row * dims.upHoriz,
|
||||
);
|
||||
supports[key] = { lat: along.lat, lon: along.lon, row, col };
|
||||
}
|
||||
return { mount: { lat: pose.lat, lon: pose.lon }, capCenter: cap, supports };
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude of the plane at a support point, given the plane center altitude:
|
||||
* the rigid rectangle's rows sit ± upVert around the center.
|
||||
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
|
||||
* @returns {number}
|
||||
*/
|
||||
export function supportAltitude(capAltM, row, dims) {
|
||||
return capAltM + row * dims.upVert;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single rigid lift (metres, ≥ 0) that puts every support point at least
|
||||
* `clearanceM` above the ground measured under it. Support points without a
|
||||
* measured ground fall back to `fallbackGroundM` (normally the ground at the
|
||||
* mount), which is what makes the pre-precompute path deterministic instead
|
||||
* of buried.
|
||||
* @param {number} capAltM - Unlifted plane center altitude.
|
||||
* @param {{upVert:number}} dims
|
||||
* @param {Record<string, number>|null|undefined} groundUnder - support key → ground altitude.
|
||||
* @param {number} fallbackGroundM
|
||||
* @param {number} clearanceM
|
||||
* @returns {{liftM:number, limitingKey:string|null}}
|
||||
*/
|
||||
export function requiredPlaneLift(
|
||||
capAltM,
|
||||
dims,
|
||||
groundUnder,
|
||||
fallbackGroundM,
|
||||
clearanceM,
|
||||
) {
|
||||
let liftM = 0;
|
||||
let limitingKey = null;
|
||||
const rows = { b: -1, m: 0, t: 1 };
|
||||
for (const key of SUPPORT_KEYS) {
|
||||
const raw = groundUnder ? groundUnder[key] : undefined;
|
||||
// Strict: a null/absent support must fall back, not read as 0 m.
|
||||
const ground =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : fallbackGroundM;
|
||||
if (!Number.isFinite(ground)) continue;
|
||||
const alt = supportAltitude(capAltM, rows[key[0]], dims);
|
||||
const deficit = ground + clearanceM - alt;
|
||||
if (deficit > liftM) {
|
||||
liftM = deficit;
|
||||
limitingKey = key;
|
||||
}
|
||||
}
|
||||
return { liftM, limitingKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of the nominal pose a footprint was sampled for, so shipped
|
||||
* samples are only reused while the pose they describe is unchanged.
|
||||
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number, mountHeightM:number}} pose
|
||||
* @returns {string}
|
||||
*/
|
||||
export function poseHash(pose) {
|
||||
const text = [
|
||||
Number(pose.lat).toFixed(6),
|
||||
Number(pose.lon).toFixed(6),
|
||||
Number(pose.headingDeg).toFixed(1),
|
||||
Number(pose.pitchDeg).toFixed(1),
|
||||
Number(pose.fovDeg).toFixed(1),
|
||||
Number(pose.rangeM).toFixed(1),
|
||||
Number(pose.mountHeightM).toFixed(1),
|
||||
].join('|');
|
||||
// FNV-1a 32-bit, base36: short, stable, dependency-free.
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return `p1-${h.toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeDimensions,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
projectPoint,
|
||||
requiredPlaneLift,
|
||||
} from './cctvFootprint.js';
|
||||
|
||||
const pose = {
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
};
|
||||
|
||||
test('support points form a 3×3 grid across the pitched far cap', () => {
|
||||
const { mount, capCenter, supports } = planeSupportPoints(pose);
|
||||
assert.deepEqual(mount, { lat: pose.lat, lon: pose.lon });
|
||||
assert.deepEqual(Object.keys(supports).sort(), [...SUPPORT_KEYS].sort());
|
||||
const dims = planeDimensions(pose);
|
||||
// Cap center sits R·cos(pitch) east of the mount (heading 90).
|
||||
const east = projectPoint(pose.lat, pose.lon, 90, dims.horiz);
|
||||
assert.ok(
|
||||
Math.abs(capCenter.lat - east.lat) < 1e-9 &&
|
||||
Math.abs(capCenter.lon - east.lon) < 1e-9,
|
||||
);
|
||||
// Middle-center is the cap center; left/right columns straddle it across the heading.
|
||||
assert.ok(Math.abs(supports.mc.lat - capCenter.lat) < 1e-9);
|
||||
assert.ok(
|
||||
supports.ml.lat > capCenter.lat && supports.mr.lat < capCenter.lat,
|
||||
'left is north of centre when looking east',
|
||||
);
|
||||
// A downward pitch tilts the top row forward (further along the heading).
|
||||
assert.ok(
|
||||
supports.tm.lon > supports.mc.lon && supports.bm.lon < supports.mc.lon,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rigid lift is the largest clearance deficit over the supports', () => {
|
||||
const dims = planeDimensions(pose);
|
||||
const ground = 149;
|
||||
const capAlt = ground + pose.mountHeightM + dims.vert; // far below ground for -24°
|
||||
const flat = requiredPlaneLift(capAlt, dims, null, ground, 2);
|
||||
assert.ok(
|
||||
Math.abs(flat.liftM - (ground + 2 - (capAlt - dims.upVert))) < 1e-9,
|
||||
);
|
||||
assert.equal(flat.limitingKey[0], 'b');
|
||||
const bump = requiredPlaneLift(capAlt, dims, { tr: ground + 200 }, ground, 2);
|
||||
assert.equal(bump.limitingKey, 'tr');
|
||||
assert.ok(bump.liftM > flat.liftM);
|
||||
// Already-clear planes need no lift at all.
|
||||
assert.deepEqual(requiredPlaneLift(ground + 500, dims, null, ground, 2), {
|
||||
liftM: 0,
|
||||
limitingKey: null,
|
||||
});
|
||||
// Unusable measurements fall back to the mount ground rather than zero.
|
||||
assert.equal(
|
||||
requiredPlaneLift(capAlt, dims, { bl: null, br: 'x' }, ground, 2).liftM,
|
||||
flat.liftM,
|
||||
);
|
||||
});
|
||||
|
||||
test('poseHash is stable for the same pose and changes with any pose field', () => {
|
||||
const h = poseHash(pose);
|
||||
assert.equal(poseHash({ ...pose }), h);
|
||||
assert.match(h, /^p1-[0-9a-z]+$/);
|
||||
for (const [key, delta] of [
|
||||
['lat', 0.00001],
|
||||
['headingDeg', 0.1],
|
||||
['pitchDeg', 0.1],
|
||||
['fovDeg', 0.1],
|
||||
['rangeM', 1],
|
||||
['mountHeightM', 0.1],
|
||||
]) {
|
||||
assert.notEqual(poseHash({ ...pose, [key]: pose[key] + delta }), h, key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
joinGroundHeights,
|
||||
loadGroundHeights,
|
||||
} from '../../server/providers/cctv/groundHeights.js';
|
||||
import { poseHash } from './cctvFootprint.js';
|
||||
|
||||
const source = (over = {}) => ({
|
||||
id: 'cam-1',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
...over,
|
||||
});
|
||||
|
||||
test('a shipped entry attaches only while the nominal pose still matches', () => {
|
||||
const s = source();
|
||||
const entry = {
|
||||
status: 'ok',
|
||||
poseHash: poseHash(s),
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5, tl: null, tm: 'x' },
|
||||
};
|
||||
const [joined] = joinGroundHeights([s], { 'cam-1': entry });
|
||||
assert.deepEqual(joined.groundHeights, {
|
||||
poseHash: entry.poseHash,
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5 },
|
||||
});
|
||||
|
||||
const moved = source({ lat: 30.2673 });
|
||||
const [notJoined] = joinGroundHeights([moved], { 'cam-1': entry });
|
||||
assert.equal(
|
||||
notJoined.groundHeights,
|
||||
undefined,
|
||||
'a moved camera loses the shipped value',
|
||||
);
|
||||
|
||||
const [missed] = joinGroundHeights([source()], {
|
||||
'cam-1': { ...entry, status: 'miss', mountGroundM: null },
|
||||
});
|
||||
assert.equal(missed.groundHeights, undefined, 'a miss ships nothing');
|
||||
assert.deepEqual(
|
||||
joinGroundHeights([source()], null)[0].groundHeights,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sidecar loads from the source root and tolerates a missing or malformed file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-heights-'));
|
||||
fs.mkdirSync(
|
||||
path.join(dir, 'src', 'data', 'local_data', 'cctv_ground_heights'),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'missing file');
|
||||
const file = path.join(
|
||||
dir,
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'cctv_ground_heights',
|
||||
'cctv_ground_heights.json',
|
||||
);
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'malformed file');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, cameras: { a: { status: 'ok' } } }),
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), { a: { status: 'ok' } });
|
||||
});
|
||||
+122
-47
@@ -31,6 +31,13 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'austin transportation & public works': 5 * 60 * 1000,
|
||||
'transport for london': 3 * 60 * 1000,
|
||||
caltrans: 3 * 60 * 1000,
|
||||
'ontario 511': 3 * 60 * 1000,
|
||||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||||
txdot: 3 * 60 * 1000,
|
||||
// Digitraffic publishes a new weathercam frame on each station's
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -42,7 +49,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
* @returns {{cardLimit:number}}
|
||||
*/
|
||||
export function cctvLodBudgets(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
|
||||
const height = Number.isFinite(cameraHeightM)
|
||||
? Math.max(0, cameraHeightM)
|
||||
: CITY_HEIGHT_M;
|
||||
if (height <= STREET_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||||
}
|
||||
@@ -68,7 +77,11 @@ export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||||
* @param {number} [factor]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
|
||||
export function incumbentRankKm(
|
||||
distanceKm,
|
||||
isIncumbent,
|
||||
factor = CCTV_CARD_INCUMBENT_FACTOR,
|
||||
) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
return isIncumbent ? km * factor : km;
|
||||
}
|
||||
@@ -81,8 +94,9 @@ export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
return Number.isFinite(viewW) && viewW > 0
|
||||
&& Number.isFinite(viewH) && viewH > 0;
|
||||
return (
|
||||
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +131,10 @@ export function cctvCandidateSpreadKm(
|
||||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!finite.length) return 0;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(percentile) ? percentile : 0));
|
||||
const p = Math.min(
|
||||
1,
|
||||
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
|
||||
);
|
||||
return finite[Math.round(p * (finite.length - 1))];
|
||||
}
|
||||
|
||||
@@ -176,32 +193,47 @@ export const CCTV_CARD_GRID_ROWS = 4;
|
||||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||||
* global-rank fill).
|
||||
*/
|
||||
export function distributeCctvCards(candidates, {
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {}) {
|
||||
export function distributeCctvCards(
|
||||
candidates,
|
||||
{
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {},
|
||||
) {
|
||||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy));
|
||||
const valid = (Array.isArray(candidates) ? candidates : []).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
);
|
||||
if (!cap || !valid.length) return [];
|
||||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||||
const width = validViewport ? viewW : 1;
|
||||
const height = validViewport ? viewH : 1;
|
||||
const nCols = Math.max(1, Math.floor(cols));
|
||||
const nRows = Math.max(1, Math.floor(rows));
|
||||
const byRank = (a, b) => (Number.isFinite(a.rankKm) ? a.rankKm : Infinity)
|
||||
- (Number.isFinite(b.rankKm) ? b.rankKm : Infinity)
|
||||
|| a.id.localeCompare(b.id);
|
||||
const byRank = (a, b) =>
|
||||
(Number.isFinite(a.rankKm) ? a.rankKm : Infinity) -
|
||||
(Number.isFinite(b.rankKm) ? b.rankKm : Infinity) ||
|
||||
a.id.localeCompare(b.id);
|
||||
|
||||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||||
const cells = new Map();
|
||||
for (const candidate of valid) {
|
||||
const col = Math.min(nCols - 1, Math.max(0, Math.floor((candidate.sx / width) * nCols)));
|
||||
const row = Math.min(nRows - 1, Math.max(0, Math.floor((candidate.sy / height) * nRows)));
|
||||
const col = Math.min(
|
||||
nCols - 1,
|
||||
Math.max(0, Math.floor((candidate.sx / width) * nCols)),
|
||||
);
|
||||
const row = Math.min(
|
||||
nRows - 1,
|
||||
Math.max(0, Math.floor((candidate.sy / height) * nRows)),
|
||||
);
|
||||
const key = row * nCols + col;
|
||||
const bucket = cells.get(key);
|
||||
if (bucket) bucket.push(candidate);
|
||||
@@ -249,29 +281,42 @@ export function distributeCctvCards(candidates, {
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||||
*/
|
||||
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
|
||||
export function selectCctvLod(
|
||||
candidates,
|
||||
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
|
||||
) {
|
||||
const budgets = cctvLodBudgets(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const incumbents =
|
||||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||||
if (!screened) {
|
||||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||||
// including its acceptance of any non-empty string ID. The stricter
|
||||
// valid-viewport rules below must not leak into this compatibility branch.
|
||||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((candidate) => candidate && typeof candidate.id === 'string' && candidate.id)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate && typeof candidate.id === 'string' && candidate.id,
|
||||
)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
distanceKm: Number.isFinite(candidate.distanceKm) ? Math.max(0, candidate.distanceKm) : Infinity,
|
||||
rankKm: incumbentRankKm(candidate.distanceKm, incumbents.has(candidate.id)),
|
||||
distanceKm: Number.isFinite(candidate.distanceKm)
|
||||
? Math.max(0, candidate.distanceKm)
|
||||
: Infinity,
|
||||
rankKm: incumbentRankKm(
|
||||
candidate.distanceKm,
|
||||
incumbents.has(candidate.id),
|
||||
),
|
||||
inView: candidate.inView === true,
|
||||
isVideo: candidate.isVideo === true,
|
||||
}))
|
||||
.filter((candidate) => candidate.inView)
|
||||
.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const cardIds = [];
|
||||
const seen = new Set();
|
||||
for (const candidate of inView) {
|
||||
@@ -284,9 +329,15 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
const eligibleById = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id.trim()
|
||||
|| candidate.inView !== true || candidate.isVideo === true
|
||||
|| !Number.isFinite(candidate.distanceKm)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
!candidate.id.trim() ||
|
||||
candidate.inView !== true ||
|
||||
candidate.isVideo === true ||
|
||||
!Number.isFinite(candidate.distanceKm)
|
||||
)
|
||||
continue;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceKm: Math.max(0, candidate.distanceKm),
|
||||
@@ -295,7 +346,14 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||||
};
|
||||
const current = eligibleById.get(normalized.id);
|
||||
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
|
||||
if (
|
||||
!current ||
|
||||
compareCctvRepresentative(normalized, current, {
|
||||
screened,
|
||||
viewW,
|
||||
viewH,
|
||||
}) < 0
|
||||
) {
|
||||
eligibleById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +362,9 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
// robust distance scale or consume a bounded ambient-card slot.
|
||||
const stills = [...eligibleById.values()];
|
||||
|
||||
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
|
||||
const spreadKm = cctvCandidateSpreadKm(
|
||||
stills.map((candidate) => candidate.distanceKm),
|
||||
);
|
||||
for (const candidate of stills) {
|
||||
const blendedKm = blendCenterRankKm(
|
||||
candidate.distanceKm,
|
||||
@@ -313,17 +373,22 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
);
|
||||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||||
}
|
||||
stills.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
stills.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
let cardIds;
|
||||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||||
// dropped by the distribution pass; top up from the ranked pool
|
||||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||||
cardIds = distributeCctvCards(stills, { budget: budgets.cardLimit, viewW, viewH });
|
||||
cardIds = distributeCctvCards(stills, {
|
||||
budget: budgets.cardLimit,
|
||||
viewW,
|
||||
viewH,
|
||||
});
|
||||
if (cardIds.length < budgets.cardLimit) {
|
||||
const chosen = new Set(cardIds);
|
||||
for (const candidate of stills) {
|
||||
@@ -336,10 +401,12 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
|
||||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (a.distanceKm !== b.distanceKm)
|
||||
return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (screened) {
|
||||
const centerDelta = screenCenterFraction(a.sx, a.sy, viewW, viewH)
|
||||
- screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
const centerDelta =
|
||||
screenCenterFraction(a.sx, a.sy, viewW, viewH) -
|
||||
screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
if (centerDelta) return centerDelta;
|
||||
}
|
||||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||||
@@ -414,7 +481,10 @@ export function applyEvictionGrace({
|
||||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||||
// first (they have had the longest chance to return); ties break on more
|
||||
// misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
graced.sort(
|
||||
(a, b) =>
|
||||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
@@ -423,7 +493,10 @@ export function applyEvictionGrace({
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
nextGrace.set(graced[i].id, {
|
||||
misses: graced[i].misses,
|
||||
since: graced[i].since,
|
||||
});
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
@@ -442,6 +515,8 @@ export function staticFrameRefreshMs(camera) {
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||||
}
|
||||
const provider = String(camera?.provider || '').trim().toLowerCase();
|
||||
const provider = String(camera?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||||
}
|
||||
+215
-74
@@ -41,7 +41,9 @@ test('cctvLodBudgets scales the card budget from 20 to 40 with view height', ()
|
||||
assert.equal(CCTV_AMBIENT_CARD_MAX, 40);
|
||||
assert.deepEqual(cctvLodBudgets(500), { cardLimit: CCTV_AMBIENT_CARD_MIN });
|
||||
assert.deepEqual(cctvLodBudgets(8_000), { cardLimit: CCTV_AMBIENT_CARD_MID });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), { cardLimit: CCTV_AMBIENT_CARD_MAX });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), {
|
||||
cardLimit: CCTV_AMBIENT_CARD_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
test('selectCctvLod caps ambient cards at the 40 hard cap regardless of catalog size', () => {
|
||||
@@ -54,19 +56,30 @@ test('selectCctvLod uses only in-view cameras and orders by viewer distance', ()
|
||||
const input = candidates(20, { hiddenAt: 0 }).reverse();
|
||||
const selected = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), ['cam-001', 'cam-002', 'cam-003']);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), [
|
||||
'cam-001',
|
||||
'cam-002',
|
||||
'cam-003',
|
||||
]);
|
||||
});
|
||||
|
||||
test('video cameras never consume ambient card slots', () => {
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), { cameraHeightM: 500 });
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), {
|
||||
cameraHeightM: 500,
|
||||
});
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
});
|
||||
|
||||
test('selectCctvLod tolerates malformed candidate rows', () => {
|
||||
const selected = selectCctvLod(
|
||||
[null, {}, { id: '', inView: true }, { id: 'cam-ok', inView: true, distanceKm: NaN }],
|
||||
{ cameraHeightM: 500 }
|
||||
[
|
||||
null,
|
||||
{},
|
||||
{ id: '', inView: true },
|
||||
{ id: 'cam-ok', inView: true, distanceKm: NaN },
|
||||
],
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.deepEqual(selected.cardIds, ['cam-ok']);
|
||||
});
|
||||
@@ -84,7 +97,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
// incumbent at 5.0 km holds its card against a newcomer at 4.5 km (only
|
||||
// 10% closer).
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -101,7 +116,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
|
||||
test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incumbent', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -111,13 +128,19 @@ test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incu
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(['incumbent']),
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('newcomer'), 'meaningfully closer camera wins');
|
||||
assert.ok(
|
||||
selected.cardIds.includes('newcomer'),
|
||||
'meaningfully closer camera wins',
|
||||
);
|
||||
assert.equal(selected.cardIds.includes('incumbent'), false);
|
||||
});
|
||||
|
||||
test('selectCctvLod: no incumbents means plain nearest-first (unchanged behavior)', () => {
|
||||
const plain = selectCctvLod(candidates(20), { cameraHeightM: 500 });
|
||||
const withEmpty = selectCctvLod(candidates(20), { cameraHeightM: 500, incumbentIds: new Set() });
|
||||
const withEmpty = selectCctvLod(candidates(20), {
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(),
|
||||
});
|
||||
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
|
||||
});
|
||||
|
||||
@@ -129,25 +152,39 @@ test('distributeCctvCards spreads clustered candidates across cells', () => {
|
||||
// sits alone bottom-right. Nearest-first would take the whole cluster —
|
||||
// distribution gives every occupied cell its best first.
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 2, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 2,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery'],
|
||||
);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: leftover budget falls back to global rank order', () => {
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
// 2 occupied cells, budget 4: both cell winners, then the cluster's next
|
||||
// two by rank.
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 4, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery', 'center-1', 'center-2']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 4,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery', 'center-1', 'center-2'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,64 +192,101 @@ test('distributeCctvCards: empty cells are skipped, budget respected', () => {
|
||||
// Everything in ONE cell — the 19 empty cells claim nothing; the single
|
||||
// occupied cell fills the whole budget in rank order.
|
||||
const oneCell = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c-${i}`, sx: 20 + i * 5, sy: 20, rankKm: i,
|
||||
id: `c-${i}`,
|
||||
sx: 20 + i * 5,
|
||||
sy: 20,
|
||||
rankKm: i,
|
||||
}));
|
||||
const ids = distributeCctvCards(oneCell, { budget: 3, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(oneCell, {
|
||||
budget: 3,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.deepEqual(ids, ['c-0', 'c-1', 'c-2']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: an under-budget cut keeps the nearest cell winners', () => {
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['near-cell']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: incumbency (effective distance) is honored inside a cell', () => {
|
||||
// Same cell: the incumbent at 5.0 km (discounted to 4.0) beats the
|
||||
// non-carded newcomer at 4.5 km for the cell slot.
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['incumbent']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: malformed rows dropped; offscreen anchors clamp to edge cells', () => {
|
||||
const ids = distributeCctvCards([
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
], { budget: 5, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
],
|
||||
{ budget: 5, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids.sort(), ['edge', 'ok']);
|
||||
assert.deepEqual(distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }), []);
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('selectCctvLod with viewport dims routes the fill through screen distribution', () => {
|
||||
// A cluster larger than the street budget sits mid-screen; a periphery
|
||||
// camera ranks past the budget. Nearest-first drops it — distribution
|
||||
// keeps it because its cell is otherwise empty.
|
||||
const clustered = Array.from({ length: CCTV_AMBIENT_CARD_MIN + 2 }, (_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
const clustered = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN + 2 },
|
||||
(_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}),
|
||||
);
|
||||
const periphery = {
|
||||
id: 'periphery',
|
||||
distanceKm: 9,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}));
|
||||
const periphery = { id: 'periphery', distanceKm: 9, inView: true, sx: 950, sy: 750 };
|
||||
sx: 950,
|
||||
sy: 750,
|
||||
};
|
||||
const selected = selectCctvLod([...clustered, periphery], {
|
||||
cameraHeightM: 500, viewW: 1000, viewH: 800,
|
||||
cameraHeightM: 500,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN, 'budget respected');
|
||||
assert.ok(selected.cardIds.includes('periphery'), 'periphery cell holds a card');
|
||||
assert.equal(
|
||||
selected.cardIds.length,
|
||||
CCTV_AMBIENT_CARD_MIN,
|
||||
'budget respected',
|
||||
);
|
||||
assert.ok(
|
||||
selected.cardIds.includes('periphery'),
|
||||
'periphery cell holds a card',
|
||||
);
|
||||
// Without screen info the original nearest-first cap applies unchanged.
|
||||
const plain = selectCctvLod(
|
||||
[...clustered, periphery].map(({ sx, sy, ...rest }) => rest),
|
||||
{ cameraHeightM: 500 }
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(plain.cardIds.includes('periphery'), false);
|
||||
});
|
||||
@@ -253,11 +327,13 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
const legacy = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
|
||||
@@ -266,7 +342,8 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
{ id: 'normal', distanceKm: 2, inView: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 }).cardIds,
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 })
|
||||
.cardIds,
|
||||
[' ', 'normal'],
|
||||
'invalid viewport preserves every non-empty string ID accepted by the legacy path',
|
||||
);
|
||||
@@ -294,18 +371,34 @@ test('center weighting deterministically favors center without starving peripher
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const selected = selectCctvLod([...edge, ...center], view);
|
||||
const reordered = selectCctvLod([...center].reverse().concat([...edge].reverse()), view);
|
||||
const reordered = selectCctvLod(
|
||||
[...center].reverse().concat([...edge].reverse()),
|
||||
view,
|
||||
);
|
||||
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('center-')).length, 10);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('edge-')).length, 10);
|
||||
assert.deepEqual(reordered.cardIds, selected.cardIds, 'input order cannot affect winners or ordering');
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('edge-')).length,
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(
|
||||
reordered.cardIds,
|
||||
selected.cardIds,
|
||||
'input order cannot affect winners or ordering',
|
||||
);
|
||||
|
||||
const legacy = selectCctvLod(
|
||||
[...edge, ...center].map(({ sx, sy, ...candidate }) => candidate),
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(legacy.cardIds.filter((id) => id.startsWith('center-')).length, 0);
|
||||
assert.equal(
|
||||
legacy.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('ineligible video, hidden, and malformed-distance rows cannot alter eligible winners', () => {
|
||||
@@ -322,13 +415,26 @@ test('ineligible video, hidden, and malformed-distance rows cannot alter eligibl
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
})),
|
||||
{ id: 'hidden-outlier', distanceKm: 9_000, inView: false, sx: 900, sy: 700 },
|
||||
{
|
||||
id: 'hidden-outlier',
|
||||
distanceKm: 9_000,
|
||||
inView: false,
|
||||
sx: 900,
|
||||
sy: 700,
|
||||
},
|
||||
{ id: 'bad-distance', distanceKm: NaN, inView: true, sx: 900, sy: 700 },
|
||||
{ id: '', distanceKm: 0, inView: true, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...noise], view).cardIds, baseline);
|
||||
assert.deepEqual(selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...noise], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view)
|
||||
.cardIds,
|
||||
baseline,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed-distance rows remain slot-neutral when valid candidates leave spare capacity', () => {
|
||||
@@ -339,7 +445,9 @@ test('malformed-distance rows remain slot-neutral when valid candidates leave sp
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod(candidates, view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, [
|
||||
'valid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('duplicate IDs are slot-neutral and choose a deterministic representative', () => {
|
||||
@@ -353,7 +461,10 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
{ ...center[0], distanceKm: NaN, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...duplicates], view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...duplicates], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...duplicates, ...eligible].reverse(), view).cardIds,
|
||||
baseline,
|
||||
@@ -362,13 +473,16 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
});
|
||||
|
||||
test('center weighting has deterministic ID ties and preserves invalid-anchor eligibility', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}));
|
||||
const anchors = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN - 1 },
|
||||
(_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}),
|
||||
);
|
||||
const tied = [
|
||||
{ id: 'tie-b', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'tie-a', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
@@ -380,13 +494,26 @@ test('center weighting has deterministic ID ties and preserves invalid-anchor el
|
||||
viewH: 800,
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('tie-a'));
|
||||
assert.equal(selected.cardIds.includes('tie-b'), false, 'ID breaks an exact contested tie');
|
||||
assert.equal(selected.cardIds.includes('invalid-anchor'), false, 'invalid anchor does not displace valid winners');
|
||||
assert.equal(
|
||||
selected.cardIds.includes('tie-b'),
|
||||
false,
|
||||
'ID breaks an exact contested tie',
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.includes('invalid-anchor'),
|
||||
false,
|
||||
'invalid anchor does not displace valid winners',
|
||||
);
|
||||
|
||||
const onlyInvalid = selectCctvLod([
|
||||
{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN },
|
||||
], { cameraHeightM: 500, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(onlyInvalid.cardIds, ['invalid-only'], 'defensive top-up preserves eligibility');
|
||||
const onlyInvalid = selectCctvLod(
|
||||
[{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN }],
|
||||
{ cameraHeightM: 500, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
onlyInvalid.cardIds,
|
||||
['invalid-only'],
|
||||
'defensive top-up preserves eligibility',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace keeps a fallen-out card and clears grace when it returns', () => {
|
||||
@@ -480,7 +607,11 @@ test('applyEvictionGrace never exceeds the hard cap; graced cards go first, olde
|
||||
// cam-c is newly selected and enters immediately despite grace pressure.
|
||||
assert.ok(result.keepIds.includes('cam-c'));
|
||||
assert.ok(result.keepIds.includes('cam-new'), 'newest grace entry survives');
|
||||
assert.deepEqual(result.evictIds, ['cam-old'], 'oldest grace entry evicted for the cap');
|
||||
assert.deepEqual(
|
||||
result.evictIds,
|
||||
['cam-old'],
|
||||
'oldest grace entry evicted for the cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
@@ -497,9 +628,19 @@ test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
});
|
||||
|
||||
test('staticFrameRefreshMs follows known pack cadences and bounds explicit values', () => {
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Transport for London' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadNswSourcesFromOpenData,
|
||||
nswCameraLabel,
|
||||
nswCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
cctvUpstreamUserAgent,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from '../../server/providers/cctv/media.js';
|
||||
import {
|
||||
NSW_CAMERAS_URL,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
|
||||
const feature = (overrides = {}, props = {}) => ({
|
||||
type: 'Feature',
|
||||
id: '5_ways_miranda',
|
||||
geometry: { type: 'Point', coordinates: [151.1036, -34.0337] },
|
||||
properties: {
|
||||
region: 'SYD_SOUTH',
|
||||
title: '5 Ways (Miranda)',
|
||||
view: '5 Ways at The Boulevarde looking west towards Sutherland.',
|
||||
direction: 'W',
|
||||
href: 'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/5_ways_miranda.jpeg',
|
||||
...props,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('NSW label prefers the view sentence and falls back to the title for works notices', () => {
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: '5 Ways (Miranda)', view: '5 Ways looking west.' }),
|
||||
'5 Ways looking west.',
|
||||
);
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: 'Victoria Pass', view: 'x'.repeat(200) }),
|
||||
'Victoria Pass',
|
||||
);
|
||||
assert.equal(nswCameraLabel({ title: 'T', view: 'line one\nline two' }), 'T');
|
||||
assert.equal(nswCameraLabel({}), '');
|
||||
});
|
||||
|
||||
test('NSW feature maps to a source with a compass heading and a pinned image host', () => {
|
||||
const source = nswCameraToSource(feature({}, { direction: 'N-E' }));
|
||||
assert.equal(source.id, 'nsw-5_ways_miranda');
|
||||
assert.equal(source.city, 'SYD SOUTH');
|
||||
assert.equal(source.provider, 'Live Traffic NSW');
|
||||
assert.equal(source.lat, -34.0337);
|
||||
assert.equal(source.lon, 151.1036);
|
||||
assert.equal(source.headingDeg, 45);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.sourceKind, 'nsw-livetraffic');
|
||||
assert.match(source.license, /CC BY 4.0/);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
});
|
||||
|
||||
test('NSW mapping rejects off-host images and unusable geometry', () => {
|
||||
assert.equal(
|
||||
nswCameraToSource(feature({}, { href: 'https://evil.example/x.jpeg' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nswCameraToSource(feature({ geometry: null })), null);
|
||||
assert.equal(nswCameraToSource(feature({ id: '' })), null);
|
||||
const noBearing = nswCameraToSource(feature({}, { direction: '' }));
|
||||
assert.equal(noBearing.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(noBearing.headingDeg));
|
||||
});
|
||||
|
||||
test('NSW loader reads the keyless feed and caps around Sydney', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature({ id: 'other' }, { href: 'https://evil.example/x.jpeg' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadNswSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [NSW_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['nsw-5_ways_miranda'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the browser User-Agent applies to the NSW image host only', async () => {
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/x.jpeg',
|
||||
),
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au.evil.test/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://example.test/webcams.transport.nsw.gov.au/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent('not a url'),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
|
||||
const seen = [];
|
||||
await fetchCctvImageFromUpstream(
|
||||
'https://cctv.austinmobility.io/image/1.jpg',
|
||||
{
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push(init.headers['User-Agent']);
|
||||
return new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(seen, ['gods-eye-view-cctv-proxy/1.0']);
|
||||
});
|
||||
|
||||
test('the frame path follows redirects within the host only', async () => {
|
||||
const jpeg = () =>
|
||||
new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
const redirect = (location) =>
|
||||
new Response(null, { status: 302, headers: { location } });
|
||||
const start = 'https://cctv.austinmobility.io/image/1.jpg';
|
||||
|
||||
const sameHost = [];
|
||||
const followed = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
assert.equal(init.redirect, 'manual', 'the fetch itself never follows');
|
||||
sameHost.push(url);
|
||||
return sameHost.length === 1 ? redirect('/image/moved/1.jpg') : jpeg();
|
||||
},
|
||||
});
|
||||
assert.equal(followed?.ok, true);
|
||||
assert.deepEqual(sameHost, [
|
||||
start,
|
||||
'https://cctv.austinmobility.io/image/moved/1.jpg',
|
||||
]);
|
||||
|
||||
const offHost = [];
|
||||
const refused = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
offHost.push(url);
|
||||
return redirect('https://evil.example/1.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(refused, null);
|
||||
assert.deepEqual(offHost, [start], 'the off-host target is never requested');
|
||||
|
||||
// Same host on another port, a plaintext downgrade, and a protocol-relative
|
||||
// other host are all a different origin and are refused.
|
||||
for (const target of [
|
||||
'https://cctv.austinmobility.io:8443/image/1.jpg',
|
||||
'http://cctv.austinmobility.io/image/1.jpg',
|
||||
'//evil.example/1.jpg',
|
||||
]) {
|
||||
const seen = [];
|
||||
const result = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
seen.push(url);
|
||||
return redirect(target);
|
||||
},
|
||||
});
|
||||
assert.equal(result, null, target);
|
||||
assert.deepEqual(seen, [start], target);
|
||||
}
|
||||
|
||||
let hops = 0;
|
||||
const looped = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => {
|
||||
hops += 1;
|
||||
return redirect('/image/again.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(looped, null);
|
||||
assert.equal(hops, 3, 'at most two redirect hops are followed');
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTxdotSourcesFromOpenData,
|
||||
normalizeTxdotDistrictPayload,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { fetchTxdotSnapshot } from '../../server/providers/cctv/media.js';
|
||||
import { TXDOT_CCTV_STATUS_URL } from '../../server/providers/cctv/constants.js';
|
||||
|
||||
/** Minimal valid JPEG head: SOI marker plus a byte of payload. */
|
||||
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
|
||||
const JPEG_B64 = JPEG_BYTES.toString('base64');
|
||||
|
||||
/** One "Device Online" record in the shape GetCctvStatusListByDistrict returns. */
|
||||
function cameraRow(overrides = {}) {
|
||||
return {
|
||||
icd_Id: 'FM-734 @ US-290 EB',
|
||||
name: 'FM-734 @ US-290 EB',
|
||||
latitude: 30.34396,
|
||||
longitude: -97.57988,
|
||||
statusDescription: 'Device Online',
|
||||
hasSnapshot: true,
|
||||
dirDescription: 'North',
|
||||
equipLoc: { roadway: 'FM-734', direction: 'North' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const districtPayload = (rows) => ({
|
||||
roadwayCctvStatuses: { 'FM-734': rows },
|
||||
});
|
||||
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
test('TxDOT catalog keeps only online cameras with finite coordinates', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'a', name: 'a' }),
|
||||
cameraRow({
|
||||
icd_Id: 'b',
|
||||
name: 'b',
|
||||
statusDescription: 'Device Offline',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'c', name: 'c', statusDescription: 'Device Error' }),
|
||||
cameraRow({ icd_Id: 'd', name: 'd', latitude: null }),
|
||||
cameraRow({ icd_Id: 'e', name: 'e', longitude: 'not-a-number' }),
|
||||
cameraRow({ icd_Id: 'f', name: 'f', latitude: 0, longitude: 0 }),
|
||||
cameraRow({
|
||||
icd_Id: 'g',
|
||||
name: 'g',
|
||||
latitude: '30.1',
|
||||
longitude: '-97.7',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'h', name: 'h', hasSnapshot: false }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].name, 'a');
|
||||
assert.equal(cameras[0].provider, 'TxDOT');
|
||||
assert.equal(cameras[0].feedType, 'image');
|
||||
assert.equal(cameras[0].sourceKind, 'txdot-its');
|
||||
assert.equal(cameras[0].license, 'Public TxDOT traffic camera data');
|
||||
assert.equal(
|
||||
cameras[0].url,
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=a&districtCode=AUS',
|
||||
);
|
||||
assert.equal(
|
||||
cameras[0].id,
|
||||
`txdot-aus-${Buffer.from('a').toString('base64url')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT ids stay distinct for distinct device keys, whatever they hash or slug to', () => {
|
||||
const ids = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
// These two share a 32-bit FNV hash.
|
||||
cameraRow({ icd_Id: 'costarring', name: 'costarring' }),
|
||||
cameraRow({ icd_Id: 'liquid', name: 'liquid' }),
|
||||
// These two share a lowercase slug and a hash.
|
||||
cameraRow({ icd_Id: 'abcDEfaBCDEFAbcdEFABCdEfAbcdeFaB', name: 'x' }),
|
||||
cameraRow({ icd_Id: 'ABcDeFABCDefaBCDeFAbCdeFabcdefAB', name: 'y' }),
|
||||
]),
|
||||
'AUS',
|
||||
).map((camera) => camera.id);
|
||||
assert.equal(new Set(ids).size, 4);
|
||||
assert.equal(
|
||||
Buffer.from(ids[0].replace(/^txdot-aus-/, ''), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
'costarring',
|
||||
'the device key is recoverable from the id',
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT catalog dedupes a camera listed under two roadways', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
{
|
||||
roadwayCctvStatuses: {
|
||||
'IH-35': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
'SH-71': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
},
|
||||
},
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
|
||||
test('TxDOT heading comes from an explicit travel token, never the roadway direction', () => {
|
||||
const [eastbound, westbound, plain] = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'eb', name: 'FM-734 @ US-290 EB' }),
|
||||
cameraRow({ icd_Id: 'wb', name: 'FM-734 @ US-290 WB' }),
|
||||
cameraRow({ icd_Id: 'plain', name: 'FM-734 @ Bellingham Dr' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(eastbound.headingDeg, 90);
|
||||
assert.equal(eastbound.headingConfidence, 'high');
|
||||
assert.equal(westbound.headingDeg, 270);
|
||||
assert.equal(westbound.headingConfidence, 'high');
|
||||
assert.equal(plain.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(plain.headingDeg));
|
||||
});
|
||||
|
||||
test('TxDOT heading refuses bare cardinals in Texas route names', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'lamar', name: 'N Lamar Blvd @ Rundberg Ln' }),
|
||||
cameraRow({ icd_Id: 'west', name: 'West Ave @ 6th St' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.equal(
|
||||
camera.headingConfidence,
|
||||
'low',
|
||||
`${camera.name} must not claim a heading`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT camera ids are district-scoped and stable across runs', () => {
|
||||
const build = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.id;
|
||||
assert.equal(build('AUS'), build('AUS'));
|
||||
assert.ok(build('AUS').startsWith('txdot-aus-'));
|
||||
assert.ok(build('HOU').startsWith('txdot-hou-'));
|
||||
assert.notEqual(build('AUS'), build('HOU'));
|
||||
});
|
||||
|
||||
test('TxDOT ground elevation uses a per-district prior', () => {
|
||||
const at = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.groundElevationM;
|
||||
assert.equal(at('HOU'), 15);
|
||||
assert.equal(at('ELP'), 1140);
|
||||
assert.equal(at('AUS'), 149);
|
||||
assert.equal(at('SAT'), 198);
|
||||
});
|
||||
|
||||
test('TxDOT catalog tolerates a malformed payload', () => {
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload(null, 'AUS'), []);
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload({}, 'AUS'), []);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload({ roadwayCctvStatuses: [] }, 'AUS'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload(
|
||||
{ roadwayCctvStatuses: { 'IH-35': null } },
|
||||
'AUS',
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT loader fetches each configured district and ignores unknown codes', async (t) => {
|
||||
quiet(t);
|
||||
const saved = process.env.CCTV_TXDOT_DISTRICTS;
|
||||
process.env.CCTV_TXDOT_DISTRICTS = 'aus, SAT, NOPE, aus';
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const district = new URL(String(url)).searchParams.get('districtCode');
|
||||
return Response.json(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: `${district}-1`, name: `${district}-1` }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
try {
|
||||
const cameras = await loadTxdotSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [
|
||||
TXDOT_CCTV_STATUS_URL('AUS'),
|
||||
TXDOT_CCTV_STATUS_URL('SAT'),
|
||||
]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.cityId).sort(), [
|
||||
'tx-aus',
|
||||
'tx-sat',
|
||||
]);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_TXDOT_DISTRICTS;
|
||||
else process.env.CCTV_TXDOT_DISTRICTS = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT snapshot decodes JSON only from the official origin and only as JPEG', async () => {
|
||||
const jsonResponse = (body) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
const official =
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x&districtCode=AUS';
|
||||
|
||||
const frame = await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
});
|
||||
assert.equal(frame?.ok, true);
|
||||
assert.equal(frame?.contentType, 'image/jpeg');
|
||||
assert.deepEqual(frame?.body, JPEG_BYTES);
|
||||
|
||||
// Off-origin with the right path, and right origin with the wrong path,
|
||||
// are each refused on their own.
|
||||
for (const bad of [
|
||||
'https://evil.example/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x',
|
||||
'https://its.txdot.gov/its/DistrictIts/Other?icdId=x',
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(bad, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
}),
|
||||
null,
|
||||
bad,
|
||||
);
|
||||
}
|
||||
// A redirect is never followed, so the pin holds for the request made.
|
||||
let calls = 0;
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (_url, init) => {
|
||||
calls += 1;
|
||||
assert.equal(init.redirect, 'manual');
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/x.json' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
// Junk base64 and oversize bodies are refused before decoding.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9!@#j/4AA=' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// Malformed padding is refused even though Buffer.from() would decode it.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9j/4AA==' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// A real JPEG larger than the frame cap is refused after decoding...
|
||||
const bigJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(59, 0x11)]);
|
||||
assert.equal(bigJpeg.length, 64);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 64,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
})
|
||||
)?.body.length,
|
||||
64,
|
||||
'the same frame passes when it fits the cap',
|
||||
);
|
||||
// ...and an envelope that overflows the streaming cap never gets parsed.
|
||||
const hugeJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(8192, 0x11)]);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: hugeJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const notJpeg = Buffer.from('<html>nope</html>').toString('base64');
|
||||
for (const body of [
|
||||
null,
|
||||
{ snippet: null },
|
||||
{ snippet: '' },
|
||||
{ snippet: notJpeg },
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse(body),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { loadWarendorfSourcesFromCatalog } from '../../server/providers/cctv/sources.js';
|
||||
|
||||
test('Warendorf catalog registers the Marktplatz webcam on official hosts only', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const cameras = loadWarendorfSourcesFromCatalog();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['warendorf-marktplatz-rathaus'],
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.ok(
|
||||
/^https?:\/\/(webcam\.warendorf\.de|www\.kreis-warendorf\.de)\//.test(
|
||||
camera.url,
|
||||
),
|
||||
camera.url,
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.equal(camera.sourceKind, 'municipal-webcam');
|
||||
assert.equal(camera.poseSource, 'curated');
|
||||
assert.match(camera.license, /^Public municipal webcam data/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Warendorf loader tolerates a missing catalog file', (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
assert.deepEqual(
|
||||
loadWarendorfSourcesFromCatalog({ sourceRoot: '/nonexistent' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Warendorf loader skips malformed rows without throwing', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-warendorf-'));
|
||||
fs.mkdirSync(path.join(dir, 'config'));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config', 'cctv_sources.warendorf.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: { toString: null },
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'ok',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'text-coords',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: '51.9',
|
||||
lon: '7.9',
|
||||
},
|
||||
{
|
||||
id: 'null-island',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
{
|
||||
id: 'off-host',
|
||||
url: 'https://evil.example/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cameras = loadWarendorfSourcesFromCatalog({ sourceRoot: dir });
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['ok'],
|
||||
);
|
||||
});
|
||||
+55
-1
@@ -7,7 +7,7 @@ import * as Cesium from 'cesium';
|
||||
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
|
||||
* display carries its own license and required attribution — ODbL (OSM
|
||||
* datacenters/dams, adsb.lol, Overpass roads), CC BY-NC-SA (TeleGeography
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS operators, OpenSky.
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic (CC BY 4.0),
|
||||
* The MIT code license does NOT cover this data.
|
||||
*
|
||||
* These credits are registered ONCE at init as STATIC credits with
|
||||
@@ -122,6 +122,12 @@ export const DATA_CREDITS = [
|
||||
'CCTV cameras & frames: City of Austin, TX — ' +
|
||||
'<a href="https://data.austintexas.gov" target="_blank" rel="noopener">data.austintexas.gov</a>',
|
||||
},
|
||||
{
|
||||
key: 'txdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Texas): ' +
|
||||
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'caltrans-cctv',
|
||||
html:
|
||||
@@ -135,6 +141,20 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
|
||||
'Contains OS data © Crown copyright and database rights.',
|
||||
},
|
||||
{
|
||||
key: 'ontario-511-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Finland): Fintraffic / ' +
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
@@ -176,6 +196,40 @@ export const DATA_CREDITS = [
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (British Columbia): ' +
|
||||
'<a href="https://www.drivebc.ca/" target="_blank" rel="noopener">DriveBC</a>. ' +
|
||||
'Contains information licensed under the ' +
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Tallinn): City of Tallinn — ' +
|
||||
'<a href="https://ristmikud.tallinn.ee/" target="_blank" rel="noopener">ristmikud.tallinn.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'tarktee-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Estonia road weather): Transpordiamet / Tarktee — ' +
|
||||
'<a href="https://tarktee.transpordiamet.ee/" target="_blank" rel="noopener">tarktee.transpordiamet.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New South Wales): ' +
|
||||
'<a href="https://www.livetraffic.com/" target="_blank" rel="noopener">Live Traffic NSW</a> — Transport for NSW ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
|
||||
},
|
||||
{
|
||||
key: 'telegeography',
|
||||
html:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# CCTV ground heights
|
||||
|
||||
Precomputed ground height under every camera in the served CCTV catalog and
|
||||
under the nine support points of its monitor plane, so the client can place a
|
||||
camera and its plane with no runtime sampling. Shared placement data: any
|
||||
consumer that renders these cameras on the same photorealistic surface can
|
||||
reuse it.
|
||||
|
||||
Source surface: Google Photorealistic 3D Tiles, sampled at maximum detail with
|
||||
CesiumJS `scene.sampleHeightMostDetailed` from a running God's Eye View
|
||||
(scripts/precompute-cctv-heights.mjs). Heights are WGS84 **ellipsoidal**
|
||||
metres; no geoid conversion is needed or applied. This is placement data
|
||||
derived from the rendered surface, not imagery.
|
||||
|
||||
Runtime output: `cctv_ground_heights.json`
|
||||
|
||||
```
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"provider": "google-photorealistic-3d",
|
||||
"heightReference": "WGS84-ellipsoid",
|
||||
"generatedAt": "<ISO>",
|
||||
"cameras": {
|
||||
"<camera id>": {
|
||||
"poseHash": "p1-…", // hash of the served pose the samples describe
|
||||
"status": "ok" | "miss",
|
||||
"mountGroundM": <number>, // ground under the camera position (absent on a miss)
|
||||
"supports": { // ground under the plane's 3×3 support grid; a value is
|
||||
// null where that point had no sample (absent on a miss)
|
||||
"bl","bm","br", // bottom row (left, middle, right)
|
||||
"ml","mc","mr", // middle row
|
||||
"tl","tm","tr" // top row
|
||||
},
|
||||
"misses": [<keys with no sample: "mount" and/or support keys>],
|
||||
"sampledAt": "<ISO>",
|
||||
"attempts": <n>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Support point positions are defined by `src/data/cctvFootprint.js`
|
||||
(`planeSupportPoints`), which the client, the server join
|
||||
(`server/providers/cctv/groundHeights.js`) and the precompute all share. An
|
||||
entry is used only while the camera's served pose still hashes to
|
||||
`poseHash`; a camera whose feed moved it, or whose pose a user edited, falls
|
||||
back to runtime placement from the Re:Earth DEM.
|
||||
|
||||
Regenerate (resumable; only cameras whose pose changed or whose entry is
|
||||
incomplete are re-sampled):
|
||||
|
||||
```
|
||||
GEV_BASE=http://localhost:4173 node scripts/precompute-cctv-heights.mjs
|
||||
```
|
||||
|
||||
Coverage at generation (2026-09-14): 3,445 of 3,446 cameras `ok` (six of them
|
||||
with one or more null supports), 34,444 sampled heights, 43 minutes on a real
|
||||
GPU. Regenerate whenever a pack changes its poses or a new pack lands.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@ import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCalibration({
|
||||
@@ -75,6 +77,35 @@ export function createCalibration({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-expresses a saved `rangeScale` after the catalog's range floor moved.
|
||||
* Entries were authored as a multiplier on the OLD client base range
|
||||
* (`max(220, packRange)`); the base is now `max(120, packRange)`, so a camera
|
||||
* whose pack range sits below 220 m would silently shrink. Scaling by the
|
||||
* ratio of the two bases keeps the user's effective range; the 3× ceiling
|
||||
* still applies (an extreme entry loses a little rather than exploding).
|
||||
* @param {Object} values - Normalized 7-field calibration.
|
||||
* @param {number} packRangeM - The camera's served (pack) range.
|
||||
* @returns {Object} A new values object.
|
||||
*/
|
||||
function migrateRangeScaleForFloor(values, packRangeM) {
|
||||
const oldBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
const newBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
if (oldBase === newBase) return values;
|
||||
return normalizeCalibration({
|
||||
...values,
|
||||
rangeScale: values.rangeScale * (oldBase / newBase),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given calibration is effectively the default (all offsets near zero).
|
||||
* @param {Object} calibration
|
||||
@@ -120,6 +151,9 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, 0),
|
||||
// Range floor the entry's rangeScale was authored against; absent
|
||||
// on entries saved before the floor dropped from 220 m to 120 m.
|
||||
rangeFloorM: parts.model.safeNumber(entry.rangeFloorM, NaN),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
@@ -147,6 +181,12 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, Date.now()),
|
||||
// Only entries that were migrated or saved against the current
|
||||
// floor carry the marker; an entry for a camera absent from this
|
||||
// catalog keeps its eligibility to migrate when it returns.
|
||||
...(Number.isFinite(entry.rangeFloorM)
|
||||
? { rangeFloorM: entry.rangeFloorM }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload));
|
||||
@@ -243,6 +283,8 @@ export function createCalibration({
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
}
|
||||
// The edited pose has a new footprint; resolve the ground under it once.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
return true;
|
||||
}
|
||||
@@ -279,6 +321,9 @@ export function createCalibration({
|
||||
endPatch: (draggedRecord) => {
|
||||
const record = liveRecord(draggedRecord);
|
||||
if (!record) return;
|
||||
// The drag changed the plane's footprint; resolve the ground under
|
||||
// the released pose once (revision-guarded, cached proxy).
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
if (record.calibrationAnchorDirty) {
|
||||
record.calibrationAnchorDirty = false;
|
||||
parts.ground.resolveCommittedGroundAnchor(record);
|
||||
@@ -298,6 +343,7 @@ export function createCalibration({
|
||||
calibrationPatchMovesAnchor,
|
||||
normalizeCalibration,
|
||||
isDefaultCalibration,
|
||||
migrateRangeScaleForFloor,
|
||||
readCalibrationStoreV2,
|
||||
writeCalibrationStoreV2,
|
||||
loadCalibrationStore,
|
||||
|
||||
@@ -136,9 +136,12 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
20,
|
||||
125,
|
||||
);
|
||||
// Same floor as the pose model (model.js): the packs' 145/210 m ranges
|
||||
// are intentional, and inflating them to 220 m pushed the monitor
|
||||
// plane's far edge into higher ground (owner field test 2026-09-13).
|
||||
const rangeM = parts.model.clamp(
|
||||
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
|
||||
220,
|
||||
120,
|
||||
2200,
|
||||
);
|
||||
const mountHeightM = parts.model.clamp(
|
||||
@@ -193,6 +196,13 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
credit: String(source.credit || ''),
|
||||
code: String(source.code || ''),
|
||||
// Shipped precompute (see server/providers/cctv/groundHeights.js).
|
||||
groundHeights:
|
||||
source.groundHeights && typeof source.groundHeights === 'object'
|
||||
? source.groundHeights
|
||||
: null,
|
||||
poseSource,
|
||||
};
|
||||
parts.model.ensureCameraPose(camera);
|
||||
|
||||
Vendored
+8
-2
@@ -3,6 +3,7 @@ import {
|
||||
MIN_AUTO_HOP_SEC,
|
||||
MAX_AUTO_HOP_SEC,
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -100,8 +101,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
parts.model.ensureCameraPose(targetRecord.camera);
|
||||
layerState._calibrationById.delete(targetCameraId);
|
||||
parts.calibration.saveCalibrationStore();
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once.
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once,
|
||||
// and the footprint under the restored pose.
|
||||
parts.ground.resolveCommittedGroundAnchor(targetRecord);
|
||||
void parts.ground.resolveFootprintGround(targetRecord);
|
||||
parts.frames.refreshProjectionImage(targetRecord, true);
|
||||
}
|
||||
if (
|
||||
@@ -134,6 +137,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
values: { ...targetRecord.camera.calibration },
|
||||
source: 'manual',
|
||||
savedAt: Date.now(),
|
||||
// Authored against the current range floor: never migrated.
|
||||
rangeFloorM: CALIBRATION_RANGE_FLOOR_M,
|
||||
});
|
||||
}
|
||||
targetRecord.calDirty = false;
|
||||
@@ -217,7 +222,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
sourceId: camera.id,
|
||||
id: `CAM-${camera.id}`,
|
||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||
id: `CAM-${camera.code || camera.id}`,
|
||||
type: 'CAM',
|
||||
});
|
||||
if (objects.length >= maxCount) break;
|
||||
|
||||
+161
-40
@@ -8,12 +8,87 @@ import {
|
||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||
COVERAGE_NEIGHBOR_LIMIT,
|
||||
IDLE_COVERAGE_COLOR,
|
||||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
} from './policy.js';
|
||||
|
||||
import {
|
||||
planeDimensions,
|
||||
poseHash,
|
||||
requiredPlaneLift,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||
const { sampleMeshFloorCells } = services.mesh;
|
||||
|
||||
/**
|
||||
* Ground measured under the plane's support points for the record's
|
||||
* CURRENT pose, or null. Shipped precompute samples (`camera.groundHeights`)
|
||||
* were taken for the nominal pose; they apply only while the pose hash still
|
||||
* matches, so a calibrated (edited) camera falls back to the mount ground
|
||||
* until its own footprint is resolved. Photoreal regime only: the samples
|
||||
* are Google mesh heights and would be wrong against a globe DEM.
|
||||
* @param {Object} record
|
||||
* @returns {Record<string, number>|null}
|
||||
*/
|
||||
function footprintGroundFor(record) {
|
||||
if (hasShippedFootprint(record)) {
|
||||
return record.camera.groundHeights.supports || null;
|
||||
}
|
||||
// DEM footprint resolved on demand (activation / calibration commit) —
|
||||
// valid in either regime, the DEM is what the globe stacks render anyway.
|
||||
const resolved = record?.footprintGround;
|
||||
if (resolved && resolved.poseHash === poseHash(footprintPose(record))) {
|
||||
return resolved.supports || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pose the monitor plane is actually rendered with: the calibrated
|
||||
* camera fields plus the activation probe's range clamp. Footprint samples
|
||||
* (shipped or DEM) are only valid for exactly this pose.
|
||||
* @param {Object} record
|
||||
* @returns {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number, mountHeightM:number}}
|
||||
*/
|
||||
function footprintPose(record) {
|
||||
const camera = record.camera;
|
||||
const override = parts.model.safeNumber(record.probeClampRangeM, NaN);
|
||||
const rangeM =
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(camera.rangeM, override)
|
||||
: camera.rangeM;
|
||||
return {
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
headingDeg: camera.headingDeg,
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
rangeM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the record carries shipped precompute samples for its CURRENT
|
||||
* pose and the scene is rendering the photoreal mesh they were taken from.
|
||||
* @param {Object} record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasShippedFootprint(record) {
|
||||
const shipped = record?.camera?.groundHeights;
|
||||
if (!shipped || typeof shipped !== 'object') return false;
|
||||
if (parts.ground.currentSurfaceRegime() !== 'google-3d') return false;
|
||||
// Samples describe the pose as served. Any calibration, client clamp or
|
||||
// activation range clamp changes the rendered pose and its hash, and the
|
||||
// camera then falls back to its own DEM footprint.
|
||||
return (
|
||||
Number.isFinite(shipped.mountGroundM) &&
|
||||
shipped.poseHash === poseHash(footprintPose(record))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
|
||||
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
|
||||
@@ -28,16 +103,20 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
|
||||
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
|
||||
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
|
||||
* The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
|
||||
* pitch must never bury the plane's anchor); corners derive rigidly from the
|
||||
* clamped center so the wireframe rays always terminate on the plane's
|
||||
* corners — the bottom pair may dip below ground (tiles occlude it).
|
||||
* The whole rectangle is then lifted rigidly by the smallest amount that
|
||||
* puts every support point (a 3×3 grid over the plane) at least 2 m above
|
||||
* the ground measured under it — the ground at the mount where nothing
|
||||
* finer is known — so the plane never clips into the terrain and the
|
||||
* wireframe rays still terminate exactly on its corners.
|
||||
*
|
||||
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
|
||||
* rangeM, mountHeightM.
|
||||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||||
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
|
||||
* effective range (never lengthens it).
|
||||
* @param {Record<string, number>|null} [groundUnderPlane=null] - Ground
|
||||
* altitude measured under each plane support point (`bl`…`tr`, see
|
||||
* footprint.js), from the shipped precompute or a DEM lookup.
|
||||
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
|
||||
* mount: {lat:number,lon:number,alt:number},
|
||||
* capCenter: {lat:number,lon:number,alt:number},
|
||||
@@ -45,7 +124,12 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
|
||||
*/
|
||||
|
||||
function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) {
|
||||
function computeFrustumGeometry(
|
||||
camera,
|
||||
groundAltM,
|
||||
rangeOverrideM = null,
|
||||
groundUnderPlane = null,
|
||||
) {
|
||||
const ground = parts.model.safeNumber(groundAltM, 0);
|
||||
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
|
||||
const override = parts.model.safeNumber(rangeOverrideM, NaN);
|
||||
@@ -53,73 +137,92 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(poseRange, override)
|
||||
: poseRange;
|
||||
const pitch = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
|
||||
const pitchDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.pitchDeg, -17),
|
||||
-89,
|
||||
89,
|
||||
);
|
||||
const hFov = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160),
|
||||
const fovDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.fovDeg, 74),
|
||||
8,
|
||||
160,
|
||||
);
|
||||
const heading = parts.model.safeNumber(camera.headingDeg, 0);
|
||||
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
|
||||
const dims = planeDimensions({ rangeM: R, pitchDeg, fovDeg });
|
||||
|
||||
const horiz = R * Math.cos(pitch);
|
||||
const vert = R * Math.sin(pitch);
|
||||
const capLL = parts.model.projectPoint(
|
||||
camera.lat,
|
||||
camera.lon,
|
||||
heading,
|
||||
horiz,
|
||||
dims.horiz,
|
||||
);
|
||||
const capAlt = mountAlt + vert;
|
||||
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
|
||||
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
|
||||
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
|
||||
const upVert = Math.cos(pitch) * halfH;
|
||||
const upHoriz = -Math.sin(pitch) * halfH;
|
||||
|
||||
const capAlt = mountAlt + dims.vert;
|
||||
const capL = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading - 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
const capR = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading + 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
|
||||
// never buries the plane's anchor — then derive the corners RIGIDLY from the
|
||||
// lifted center. Clamping each corner independently flattened the wireframe
|
||||
// into a ground-hugging fan while the rigid plane kept its height (owner
|
||||
// field test 2026-07-04): the corner rays must always terminate exactly on
|
||||
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
|
||||
// tiles occlude that portion, exactly as they do for the plane itself.
|
||||
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
|
||||
const capAltClamped = Math.max(minAlt, capAlt);
|
||||
// Ground clearance: ONE rigid lift for the whole rectangle, sized by the
|
||||
// support point with the largest deficit against the ground measured
|
||||
// under it (a 3×3 grid over the plane; see footprint.js). Points without
|
||||
// a measurement fall back to the ground at the mount, so with no
|
||||
// footprint data the bottom edge still clears the mount's ground — the
|
||||
// old center-only clamp left the bottom edge tens of metres underground
|
||||
// (owner field test 2026-09-13). The rectangle stays rigid and the
|
||||
// wireframe rays still terminate exactly on its corners.
|
||||
// Two lifts: what the mount's own ground demands (always honoured), plus
|
||||
// whatever the measured footprint adds, capped so a building under the
|
||||
// far edge cannot send the plane into the sky.
|
||||
const base = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
null,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const measured = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
groundUnderPlane,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const footprintExtraM = Math.max(0, measured.liftM - base.liftM);
|
||||
const liftM =
|
||||
base.liftM + Math.min(PLANE_FOOTPRINT_LIFT_CAP_M, footprintExtraM);
|
||||
const limitingKey =
|
||||
footprintExtraM > 0 ? measured.limitingKey : base.limitingKey;
|
||||
const capAltLifted = capAlt + liftM;
|
||||
const corner = (base, sign) => {
|
||||
const ll = parts.model.projectPoint(
|
||||
base.lat,
|
||||
base.lon,
|
||||
heading,
|
||||
sign * upHoriz,
|
||||
sign * dims.upHoriz,
|
||||
);
|
||||
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
|
||||
return {
|
||||
lat: ll.lat,
|
||||
lon: ll.lon,
|
||||
alt: capAltLifted + sign * dims.upVert,
|
||||
};
|
||||
};
|
||||
|
||||
const topCenter = corner(capLL, 1);
|
||||
return {
|
||||
rangeM: R,
|
||||
vFovDeg: Cesium.Math.toDegrees(vFovRad),
|
||||
halfW,
|
||||
halfH,
|
||||
vFovDeg: dims.vFovDeg,
|
||||
halfW: dims.halfW,
|
||||
halfH: dims.halfH,
|
||||
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltLifted },
|
||||
corners: {
|
||||
tl: corner(capL, 1),
|
||||
tr: corner(capR, 1),
|
||||
@@ -128,6 +231,10 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
topCenter,
|
||||
groundAltM: ground,
|
||||
liftM,
|
||||
liftLimitedBy: limitingKey,
|
||||
liftCapped: footprintExtraM > PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
footprintMeasured: !!groundUnderPlane,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,6 +337,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
record.camera,
|
||||
groundAltM,
|
||||
record.probeClampRangeM,
|
||||
footprintGroundFor(record),
|
||||
);
|
||||
const positions = frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
@@ -327,6 +435,17 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime with shipped precompute samples for exactly this pose:
|
||||
// the mount ground and the footprint come from the sidecar, no scene
|
||||
// query and no shared-cell write (aircraft floors stay untouched).
|
||||
if (hasShippedFootprint(record)) {
|
||||
const shippedGround = record.camera.groundHeights.mountGroundM;
|
||||
record.groundSamples['google-3d'] = shippedGround;
|
||||
record.groundResolved['google-3d'] = true;
|
||||
applyFrustumGeometry(record, shippedGround);
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
@@ -696,6 +815,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
return {
|
||||
computeFrustumGeometry,
|
||||
hasShippedFootprint,
|
||||
footprintPose,
|
||||
activationProbeClampRange,
|
||||
frustumCartesians,
|
||||
frustumFrameEcef,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
/** Cooldown before a provisional (geoid-fallback / partial) footprint is retried. */
|
||||
const FOOTPRINT_RETRY_MS = 60_000;
|
||||
|
||||
export function createGround({ state: layerState, services, parts, source }) {
|
||||
const { resolveGroundFloorCells } = services.ground;
|
||||
const { resolveEllipsoidalGround } = services.terrain;
|
||||
@@ -248,8 +257,13 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
const ground = groundAltFor(record, regime);
|
||||
// Skip the entity rewrite when the applied ground already matches (e.g.
|
||||
// entering google-3d before any sample: prior → prior is a no-op).
|
||||
// entering google-3d before any sample: prior → prior is a no-op) —
|
||||
// unless the record has a footprint source, whose eligibility depends
|
||||
// on the regime (shipped mesh samples apply in google-3d only).
|
||||
const hasFootprintSource =
|
||||
!!record.camera?.groundHeights || !!record.footprintGround;
|
||||
if (
|
||||
!hasFootprintSource &&
|
||||
record.frustumGeometry &&
|
||||
Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001
|
||||
) {
|
||||
@@ -266,11 +280,77 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
// under the ≤1-per-(camera, session) ceiling.
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
}
|
||||
// The active camera may have just lost its shipped footprint (globe
|
||||
// regime) — resolve its DEM footprint for the new surface.
|
||||
const active = layerState._recordById.get(layerState._activeCameraId);
|
||||
if (active) void resolveFootprintGround(active);
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
/**
|
||||
* Resolves the ground under the monitor plane's nine support points for the
|
||||
* record's CURRENT pose from the Re:Earth DEM (network-cached proxy, never a
|
||||
* scene query), then re-applies the geometry so the rigid lift accounts for
|
||||
* terrain rising under the plane's far edge. This is the fallback for
|
||||
* cameras without a shipped precompute for this pose (a new pack, or a
|
||||
* camera the user has edited), and it runs only on demand: activation and
|
||||
* calibration commit. A late result for a stale pose or a torn-down record
|
||||
* is dropped. Never rejects.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resolveFootprintGround(record) {
|
||||
if (!record?.camera) return;
|
||||
const pose = parts.geometry.footprintPose(record);
|
||||
const hash = poseHash(pose);
|
||||
const existing = record.footprintGround;
|
||||
if (existing?.poseHash === hash) {
|
||||
// Complete real-DEM results are final; a geoid-fallback (proxy outage)
|
||||
// or partial result is retried after a cooldown.
|
||||
if (!existing.provisional || Date.now() < existing.retryAt) return;
|
||||
}
|
||||
if (parts.geometry.hasShippedFootprint(record)) return;
|
||||
const revision = (record.footprintRevision || 0) + 1;
|
||||
record.footprintRevision = revision;
|
||||
const { supports } = planeSupportPoints(pose);
|
||||
const coords = SUPPORT_KEYS.map((key) => ({
|
||||
lat: supports[key].lat,
|
||||
lon: supports[key].lon,
|
||||
}));
|
||||
let results = null;
|
||||
try {
|
||||
results = await resolveEllipsoidalGround(coords);
|
||||
} catch {
|
||||
results = null;
|
||||
}
|
||||
if (!Array.isArray(results)) return;
|
||||
if (record.footprintRevision !== revision) return;
|
||||
if (layerState._recordById.get(record.camera.id) !== record) return;
|
||||
if (poseHash(parts.geometry.footprintPose(record)) !== hash) return;
|
||||
const under = {};
|
||||
let real = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (Number.isFinite(result?.ellipsoid)) {
|
||||
under[SUPPORT_KEYS[index]] = result.ellipsoid;
|
||||
if (result.source === 'reearth') real += 1;
|
||||
}
|
||||
});
|
||||
const provisional = real < SUPPORT_KEYS.length;
|
||||
record.footprintGround = {
|
||||
poseHash: hash,
|
||||
supports: under,
|
||||
source: provisional ? 'dem-provisional' : 'dem',
|
||||
provisional,
|
||||
retryAt: provisional ? Date.now() + FOOTPRINT_RETRY_MS : 0,
|
||||
};
|
||||
parts.geometry.applyFrustumGeometry(record, groundAltFor(record));
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceRegimeKey,
|
||||
currentSurfaceRegime,
|
||||
resolveFootprintGround,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
groundAltFor,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createCctvLayer({ services, source }) {
|
||||
{
|
||||
calibrationPatchMovesAnchor:
|
||||
parts.calibration.calibrationPatchMovesAnchor,
|
||||
migrateRangeScaleForFloor: parts.calibration.migrateRangeScaleForFloor,
|
||||
_pushAmbientCardEntriesForTest:
|
||||
parts.testing._pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GROUND_PRIOR_INIT_WAIT_MS,
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -92,9 +93,18 @@ export function createLifecycle({
|
||||
for (const camera of catalog) {
|
||||
const savedEntry = layerState._calibrationById.get(camera.id);
|
||||
if (savedEntry) {
|
||||
camera.calibration = parts.calibration.normalizeCalibration(
|
||||
savedEntry.values,
|
||||
);
|
||||
// Entries saved before the range floor dropped (no rangeFloorM)
|
||||
// keep their effective range by re-basing rangeScale once.
|
||||
const values =
|
||||
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
|
||||
? savedEntry.values
|
||||
: parts.calibration.migrateRangeScaleForFloor(
|
||||
savedEntry.values,
|
||||
camera.rangeM,
|
||||
);
|
||||
savedEntry.values = values;
|
||||
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
|
||||
camera.calibration = parts.calibration.normalizeCalibration(values);
|
||||
camera.calSource = savedEntry.source;
|
||||
}
|
||||
parts.model.ensureCameraPose(camera);
|
||||
|
||||
@@ -98,6 +98,17 @@ export const PROJECTION_VERT_ASPECT =
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
export const FRUSTUM_GROUND_CLEARANCE_M = 2;
|
||||
/**
|
||||
* Most the footprint clearance may lift a monitor plane beyond what the
|
||||
* ground at its own mount requires (metres). A plane whose far edge crosses
|
||||
* a tall building would otherwise float hundreds of metres up to "clear" it;
|
||||
* past this the plane accepts the intersection instead of leaving the scene.
|
||||
*/
|
||||
export const PLANE_FOOTPRINT_LIFT_CAP_M = 60;
|
||||
/** Client range floor the pose model and catalog agree on (metres). */
|
||||
export const CALIBRATION_RANGE_FLOOR_M = 120;
|
||||
/** The floor saved v2 calibrations were authored against before 2026-09-13. */
|
||||
export const LEGACY_CALIBRATION_RANGE_FLOOR_M = 220;
|
||||
|
||||
/** Public result codes for explicit CCTV camera flights. */
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export function createPresentation({
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
credit: camera.credit || '',
|
||||
calibration: {
|
||||
...parts.calibration.normalizeCalibration(camera.calibration),
|
||||
},
|
||||
|
||||
@@ -135,6 +135,10 @@ export function createSelection({
|
||||
// until update()'s one-shot tiles-ready completion pass re-grounds it.
|
||||
parts.ground.rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
// Ground under the plane's footprint (DEM, cached proxy) for cameras
|
||||
// without a shipped precompute for this pose — only the camera the user
|
||||
// is looking at ever asks.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||
|
||||
+677
-165
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,365 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
batches,
|
||||
cellKey,
|
||||
mergeCameraSamples,
|
||||
mergeSidecar,
|
||||
needsSampling,
|
||||
nominalPose,
|
||||
parseArgs,
|
||||
planCells,
|
||||
sampleCell,
|
||||
samplingPoints,
|
||||
withDeadline,
|
||||
plausibleHeight,
|
||||
} from '../../scripts/precompute-cctv-heights.mjs';
|
||||
import {
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
SUPPORT_KEYS,
|
||||
} from '../data/cctvFootprint.js';
|
||||
|
||||
const pose = {
|
||||
lat: 30,
|
||||
lon: -95,
|
||||
headingDeg: 45,
|
||||
pitchDeg: -17,
|
||||
fovDeg: 74,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
};
|
||||
const sampledAt = '2026-09-13T12:00:00.000Z';
|
||||
const camera = (id, overrides = {}) => ({
|
||||
id,
|
||||
sourceKind: 'test',
|
||||
pose: { ...pose, ...overrides },
|
||||
});
|
||||
|
||||
test('nominal poses are hashed exactly as served, never clamped', () => {
|
||||
assert.deepEqual(nominalPose({ id: 'a', ...pose }), pose);
|
||||
// The server join and the client hash the same raw served values, so a
|
||||
// range outside the client's display clamp still round-trips untouched.
|
||||
for (const rangeM of [20, 120, 145, 3000]) {
|
||||
const result = nominalPose({ id: 'a', ...pose, rangeM });
|
||||
assert.equal(result.rangeM, rangeM);
|
||||
assert.equal(poseHash(result), poseHash({ ...pose, rangeM }));
|
||||
}
|
||||
assert.throws(() => nominalPose({ ...pose, rangeM: null }), /invalid rangeM/);
|
||||
assert.throws(() => nominalPose({ ...pose, lat: 91 }), /invalid coordinates/);
|
||||
assert.equal(pose.rangeM, 145);
|
||||
});
|
||||
|
||||
test('cells have stable per-row longitude widths and handle boundaries', () => {
|
||||
assert.equal(cellKey({ lat: 0.001, lon: 0.001 }), '4500,9000');
|
||||
assert.equal(cellKey({ lat: 0.019, lon: 0.001 }), '4500,9000');
|
||||
assert.equal(cellKey({ lat: 0.021, lon: 0.001 }).split(',')[0], '4501');
|
||||
assert.equal(cellKey({ lat: -0.001, lon: 0.001 }).split(',')[0], '4499');
|
||||
const column = (lat, lon) => Number(cellKey({ lat, lon }).split(',')[1]);
|
||||
assert.equal(column(0.001, 2) - column(0.001, 0), 100);
|
||||
assert.equal(column(60.001, 2) - column(60.001, 0), 50);
|
||||
assert.equal(cellKey({ lat: 30, lon: 180 }), cellKey({ lat: 30, lon: -180 }));
|
||||
for (const lat of [-90, 90])
|
||||
assert.match(cellKey({ lat, lon: 0 }), /^\d+,\d+$/);
|
||||
assert.throws(() => cellKey({ lat: NaN, lon: 0 }), /Invalid/);
|
||||
});
|
||||
|
||||
test('plans order cells numerically and preserve camera order within cells', () => {
|
||||
const cameras = [
|
||||
camera('north', { lat: 50 }),
|
||||
camera('east', { lon: -90 }),
|
||||
camera('a'),
|
||||
camera('b'),
|
||||
];
|
||||
const plan = planCells(cameras);
|
||||
assert.deepEqual(
|
||||
plan.map((cell) => cell.cameras.map((item) => item.id)),
|
||||
[['a', 'b'], ['east'], ['north']],
|
||||
);
|
||||
const sparse = planCells([
|
||||
camera('ten', { lat: -89.79 }),
|
||||
camera('two', { lat: -89.95 }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
sparse.map((cell) => cell.cameras[0].id),
|
||||
['two', 'ten'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((item) => item.id),
|
||||
['north', 'east', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('batches cap 16 cameras at 160 points, including partial and empty batches', () => {
|
||||
const items = Array.from({ length: 35 }, (_, i) => i);
|
||||
const result = batches(items);
|
||||
assert.deepEqual(
|
||||
result.map((batch) => batch.length),
|
||||
[16, 16, 3],
|
||||
);
|
||||
assert.deepEqual(result.flat(), items);
|
||||
assert.deepEqual(batches([], 4), []);
|
||||
for (const size of [0, -1, 1.5, Infinity])
|
||||
assert.throws(() => batches(items, size));
|
||||
});
|
||||
|
||||
test('plausibility preserves ellipsoid heights without null or string coercion', () => {
|
||||
for (const height of [-150, -10.25, 0, 123.456, 9000])
|
||||
assert.equal(plausibleHeight(height), height);
|
||||
for (const height of [
|
||||
-150.001,
|
||||
undefined,
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
'0',
|
||||
false,
|
||||
])
|
||||
assert.equal(plausibleHeight(height), null);
|
||||
});
|
||||
|
||||
test('resume requires both ok status and the current shared pose hash', () => {
|
||||
const entry = { status: 'ok', poseHash: poseHash(pose) };
|
||||
assert.equal(needsSampling(entry, pose), false);
|
||||
for (const key of Object.keys(pose))
|
||||
assert.equal(needsSampling(entry, { ...pose, [key]: pose[key] + 1 }), true);
|
||||
for (const prior of [
|
||||
undefined,
|
||||
{},
|
||||
{ ...entry, status: 'miss' },
|
||||
{ poseHash: entry.poseHash },
|
||||
])
|
||||
assert.equal(needsSampling(prior, pose), true);
|
||||
// A complete-looking entry with missing supports is resampled.
|
||||
assert.equal(needsSampling({ ...entry, misses: ['tr'] }, pose), true);
|
||||
assert.equal(needsSampling({ ...entry, misses: [] }, pose), false);
|
||||
});
|
||||
|
||||
test('entries preserve support ordering, partial misses and retry successes', () => {
|
||||
const first = mergeCameraSamples(
|
||||
pose,
|
||||
[0, 10, null, 12, 13, 14, 15, 16, 17, -151],
|
||||
null,
|
||||
1,
|
||||
sampledAt,
|
||||
);
|
||||
assert.equal(first.status, 'ok');
|
||||
assert.equal(first.mountGroundM, 0);
|
||||
assert.deepEqual(Object.keys(first.supports), SUPPORT_KEYS);
|
||||
assert.deepEqual(first.misses, ['bm', 'tr']);
|
||||
assert.equal(first.supports.bm, null);
|
||||
const retried = mergeCameraSamples(
|
||||
pose,
|
||||
[undefined, null, 11, null, null, null, null, null, null, 18],
|
||||
first,
|
||||
2,
|
||||
sampledAt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retried.supports,
|
||||
Object.fromEntries(SUPPORT_KEYS.map((key, i) => [key, i + 10])),
|
||||
);
|
||||
assert.deepEqual(retried.misses, []);
|
||||
assert.equal(retried.mountGroundM, 0);
|
||||
assert.equal(retried.attempts, 2);
|
||||
assert.equal(retried.sampledAt, sampledAt);
|
||||
// A partial first pass is 'ok' (the mount landed) but still resamples so
|
||||
// the missing supports get another chance.
|
||||
assert.equal(needsSampling(first, pose), first.misses.length > 0);
|
||||
});
|
||||
|
||||
test('a missing mount stores no heights and stale poses cannot supply retry heights', () => {
|
||||
const missing = mergeCameraSamples(
|
||||
pose,
|
||||
[-151, ...Array(9).fill(10)],
|
||||
null,
|
||||
3,
|
||||
sampledAt,
|
||||
);
|
||||
assert.equal(missing.status, 'miss');
|
||||
assert.deepEqual(missing.misses, ['mount']);
|
||||
assert.equal(Object.hasOwn(missing, 'mountGroundM'), false);
|
||||
assert.equal(Object.hasOwn(missing, 'supports'), false);
|
||||
const previous = mergeCameraSamples(
|
||||
{ ...pose, rangeM: 200 },
|
||||
Array(10).fill(99),
|
||||
null,
|
||||
1,
|
||||
sampledAt,
|
||||
);
|
||||
const entry = mergeCameraSamples(pose, [], previous, 2, sampledAt);
|
||||
assert.equal(entry.status, 'miss');
|
||||
assert.deepEqual(entry.misses, ['mount', ...SUPPORT_KEYS]);
|
||||
});
|
||||
|
||||
test('sidecar merging sorts ids, preserves unselected entries and replaces stale heights', () => {
|
||||
const old = {
|
||||
cameras: { z: { status: 'ok' }, a: { status: 'ok', mountGroundM: 20 } },
|
||||
};
|
||||
const result = mergeSidecar(
|
||||
old,
|
||||
{ b: { status: 'ok' }, a: { status: 'miss' } },
|
||||
sampledAt,
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.cameras), ['a', 'b', 'z']);
|
||||
assert.deepEqual(result.cameras.a, { status: 'miss' });
|
||||
assert.equal(old.cameras.a.mountGroundM, 20);
|
||||
assert.equal(result.schemaVersion, 1);
|
||||
assert.equal(result.provider, 'google-photorealistic-3d');
|
||||
assert.equal(result.heightReference, 'WGS84-ellipsoid');
|
||||
assert.equal(result.generatedAt, sampledAt);
|
||||
assert.deepEqual(mergeSidecar(null, {}, sampledAt).cameras, {});
|
||||
});
|
||||
|
||||
test('CLI supports combined pilot filters and rejects malformed flags', () => {
|
||||
assert.deepEqual(
|
||||
parseArgs(['--kinds', 'a, b', '--limit', '40', '--dry-run']),
|
||||
{ limit: 40, kinds: ['a', 'b'], dryRun: true },
|
||||
);
|
||||
assert.equal(parseArgs(['--limit', '0']).limit, 0);
|
||||
assert.equal(parseArgs([]).limit, Infinity);
|
||||
for (const args of [
|
||||
['--limit'],
|
||||
['--limit', '-1'],
|
||||
['--limit', '1.5'],
|
||||
['--limit', '1e3'],
|
||||
['--kinds'],
|
||||
['--kinds', 'a,'],
|
||||
['--kinds', '--dry-run'],
|
||||
['--unknown'],
|
||||
])
|
||||
assert.throws(() => parseArgs(args));
|
||||
});
|
||||
|
||||
test('sampling uses the shared footprint directly and rejects invalid geometry', () => {
|
||||
const footprint = planeSupportPoints(pose);
|
||||
const expected = [
|
||||
footprint.mount,
|
||||
...SUPPORT_KEYS.map((key) => footprint.supports[key]),
|
||||
].map(({ lat, lon }) => ({ lat, lon }));
|
||||
assert.deepEqual(samplingPoints(pose), expected);
|
||||
assert.equal(expected.length, 10);
|
||||
assert.ok(
|
||||
expected.every(
|
||||
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon),
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() => samplingPoints({ ...pose, lat: NaN }),
|
||||
/invalid coordinates/,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed calls recycle before serial retries in batches of 4, then singly', async () => {
|
||||
const cameras = Array.from({ length: 16 }, (_, i) => camera(String(i)));
|
||||
const sizes = [];
|
||||
const seen = new Map();
|
||||
let active = 0;
|
||||
let needsRecycle = false;
|
||||
let recycled = 0;
|
||||
const result = await sampleCell(
|
||||
cameras,
|
||||
{
|
||||
async sample(batch) {
|
||||
assert.equal(active, 0);
|
||||
assert.equal(needsRecycle, false);
|
||||
active += 1;
|
||||
sizes.push(batch.length);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
for (const item of batch)
|
||||
seen.set(item.id, (seen.get(item.id) || 0) + 1);
|
||||
if (sizes.length === 1) {
|
||||
needsRecycle = true;
|
||||
throw new Error('timeout');
|
||||
}
|
||||
return batch.map((item) =>
|
||||
seen.get(item.id) === 2 ? [] : Array(10).fill(25),
|
||||
);
|
||||
},
|
||||
async recycle() {
|
||||
assert.equal(active, 0);
|
||||
needsRecycle = false;
|
||||
recycled += 1;
|
||||
},
|
||||
},
|
||||
() => sampledAt,
|
||||
);
|
||||
assert.deepEqual(sizes, [16, 4, 4, 4, 4, ...Array(16).fill(1)]);
|
||||
assert.equal(recycled, 1);
|
||||
for (const entry of Object.values(result)) {
|
||||
assert.equal(entry.status, 'ok');
|
||||
assert.equal(entry.attempts, 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('only failed cameras retry; partial successes survive and mount misses stop after three attempts', async () => {
|
||||
const calls = [];
|
||||
const result = await sampleCell(
|
||||
[camera('ok'), camera('partial'), camera('miss')],
|
||||
{
|
||||
async sample(batch) {
|
||||
calls.push(batch.map((item) => item.id));
|
||||
return batch.map((item) => {
|
||||
if (item.id === 'miss') return [];
|
||||
if (item.id === 'partial')
|
||||
return calls.length === 1
|
||||
? [10, ...Array(8).fill(11), null]
|
||||
: [null, ...Array(8).fill(null), 12];
|
||||
return Array(10).fill(10);
|
||||
});
|
||||
},
|
||||
async recycle() {
|
||||
assert.fail('No call failed');
|
||||
},
|
||||
},
|
||||
() => sampledAt,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['ok', 'partial', 'miss'],
|
||||
['partial', 'miss'],
|
||||
['miss'],
|
||||
]);
|
||||
assert.equal(result.ok.attempts, 1);
|
||||
assert.equal(result.partial.attempts, 2);
|
||||
assert.deepEqual(result.partial.misses, []);
|
||||
assert.equal(result.partial.supports.tr, 12);
|
||||
assert.equal(result.miss.status, 'miss');
|
||||
assert.equal(result.miss.attempts, 3);
|
||||
});
|
||||
|
||||
test('deadline rejects hung calls and passes successful or rejected operations through', async () => {
|
||||
assert.equal(await withDeadline(Promise.resolve(42)), 42);
|
||||
await assert.rejects(
|
||||
withDeadline(Promise.reject(new Error('provider failed'))),
|
||||
/provider failed/,
|
||||
);
|
||||
await assert.rejects(withDeadline(new Promise(() => {}), 5), /timed out/);
|
||||
});
|
||||
|
||||
test('a resumed camera keeps its valid heights when the retry only fails', async () => {
|
||||
const prior = mergeCameraSamples(
|
||||
pose,
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
undefined,
|
||||
1,
|
||||
't0',
|
||||
);
|
||||
const partial = {
|
||||
...prior,
|
||||
supports: { ...prior.supports, tr: null },
|
||||
misses: ['tr'],
|
||||
};
|
||||
const sampler = {
|
||||
sample: async (batch) => batch.map(() => Array(10).fill(undefined)),
|
||||
recycle: async () => {},
|
||||
};
|
||||
const entries = await sampleCell([{ id: 'cam', pose }], sampler, () => 't1', {
|
||||
cam: partial,
|
||||
});
|
||||
assert.equal(entries.cam.status, 'ok');
|
||||
assert.equal(entries.cam.mountGroundM, 1);
|
||||
assert.equal(entries.cam.supports.bl, 2);
|
||||
assert.deepEqual(entries.cam.misses, ['tr']);
|
||||
});
|
||||
@@ -148,11 +148,13 @@ export function _renderCctvState(state) {
|
||||
const statusMsg = activeCamera.sourceMessage
|
||||
? ` · ${activeCamera.sourceMessage}`
|
||||
: '';
|
||||
// A partner-supplied feed inside a pack names its owner here.
|
||||
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
|
||||
const calBadge = activeCamera.calBadge
|
||||
? this._calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF';
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`;
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${credit}${statusMsg}`;
|
||||
} else if (cameras.length > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? `${cameras.length} cameras loaded · click a camera to activate`
|
||||
|
||||
Reference in new issue
Block a user