Merge current main into ALPR camera candidate

This commit is contained in:
Sameh Khamis committed 2026-09-13 20:08:54 -07:00
commit 7068ee7dc7
51 files changed
+78701 -972

No files matched your search

+1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+137
View File
@@ -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);
}
});
+61
View File
@@ -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',
);
});
+272
View File
@@ -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), '');
});
+86
View File
@@ -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);
});
+256
View File
@@ -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);
});
+198
View File
@@ -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)}`;
}
+83
View File
@@ -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);
}
});
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+196
View File
@@ -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');
});
+328
View File
@@ -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,
);
}
});
+81
View File
@@ -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
View File
@@ -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
@@ -130,6 +130,12 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; 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 &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'caltrans-cctv',
html:
@@ -143,6 +149,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 &amp; 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 &amp; 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)',
@@ -184,6 +204,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 &amp; 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 &amp; 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 &amp; 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 &amp; 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,46 @@
# CCTV ground heights
Precomputed ground height under every camera in the served CCTV catalog and
under the nine support points of its monitor plane, aligned to work with
Google Photorealistic 3D Tiles. Heights are WGS84 ellipsoidal metres. The
client places a camera and its plane from this file with no runtime sampling.
Runtime output: `cctv_ground_heights.json`
```
{
"schemaVersion": 1,
"provider": "google-3d-tiles",
"heightReference": "WGS84-ellipsoid",
"generatedAt": "<ISO>",
"cameras": {
"<camera id>": {
"poseHash": "p1-…", // hash of the served pose the entry describes
"status": "ok" | "miss",
"mountGroundM": <number>, // ground under the camera position (absent on a miss)
"supports": { // ground under the plane's 3×3 support grid; null where
"bl","bm","br", // a point has no value (absent on a miss)
"ml","mc","mr",
"tl","tm","tr"
},
"misses": [<keys with no value>],
"sampledAt": "<ISO>",
"attempts": <n>
}
}
}
```
Support point positions are defined by `src/data/cctvFootprint.js`
(`planeSupportPoints`), shared with the server join
(`server/providers/cctv/groundHeights.js`). 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 terrain
proxy.
Regenerate against a running app (resumable; only cameras whose pose changed
or whose entry is incomplete are redone):
```
GEV_BASE=http://localhost:4173 node scripts/precompute-cctv-heights.mjs
```
File diff suppressed because it is too large. Load diff
+46
View File
@@ -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,
+11 -1
View File
@@ -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);
+8 -2
View File
@@ -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;
+163 -40
View File
@@ -8,12 +8,89 @@ 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 values
* are aligned to the Google 3D Tiles surface 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 Google 3D Tiles surface they are
* aligned to.
* @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 +105,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 +126,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 +139,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 +233,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 +339,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 +437,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 +817,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
}
return {
computeFrustumGeometry,
hasShippedFootprint,
footprintPose,
activationProbeClampRange,
frustumCartesians,
frustumFrameEcef,
+81 -1
View File
@@ -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,
+1
View File
@@ -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,
+13 -3
View File
@@ -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);
+11
View File
@@ -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. */
+1
View File
@@ -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),
},
+4
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+365
View File
@@ -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-3d-tiles');
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']);
});
+3 -1
View File
@@ -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`