refactor(cctv): separate camera source and scene components

This commit is contained in:
Sameh Khamis committed 2026-09-13 15:02:32 -07:00
1 parent ca5f5fff93
commit 519bc6c362
32 files changed
+6873 -4835

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
export function createCatalog({ state: layerState, services, parts, source }) {
const { CITY_POIS } = services.locations;
/**
* Builds the initial camera catalog from CAMERA_SEEDS definitions.
* Each seed is resolved against its city's POI coordinates, offset, and
* passed through ensureCameraPose to populate derived fields.
* @returns {Object[]} Array of fully-initialized camera objects.
*/
function seedCatalog() {
const catalog = [];
for (const seed of CAMERA_SEEDS) {
const city = CITY_POIS[seed.cityId];
const poi = city?.pois?.[seed.poiIndex];
if (!city || !poi) continue;
const { latOffset, lonOffset } = parts.model.offsetDegrees(
poi.lat,
seed.offsetNorthM || 0,
seed.offsetEastM || 0,
);
const camera = {
id: seed.id,
name: seed.label,
cityId: seed.cityId,
city: city.name,
provider: 'OSM Camera Grid',
sourceKind: 'seed',
feedType: 'image',
feedConfigured: false,
headingConfidence: 'medium',
lat: poi.lat + latOffset,
lon: poi.lon + lonOffset,
headingDeg: parts.model.normalizeHeading(
seed.headingDeg ?? poi.heading ?? 0,
),
fovDeg: parts.model.clamp(seed.fovDeg ?? 70, 20, 120),
rangeM: parts.model.clamp(seed.rangeM ?? 700, 260, 1800),
mountHeightM: parts.model.clamp(seed.elevationM ?? 22, 8, 80),
groundElevationM: Number(city.groundElevation) || 0,
absoluteHeightM:
(Number(city.groundElevation) || 0) +
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
};
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
return catalog;
}
/**
* Looks up a city ID from CITY_POIS by exact or partial name match.
* @param {string} cityName
* @returns {string|null} Matching city ID or null.
*/
function cityIdByName(cityName) {
const probe = String(cityName || '')
.trim()
.toLowerCase();
if (!probe) return null;
for (const [cityId, city] of Object.entries(CITY_POIS)) {
if (city.name.toLowerCase() === probe) return cityId;
}
for (const [cityId, city] of Object.entries(CITY_POIS)) {
if (
city.name.toLowerCase().includes(probe) ||
probe.includes(city.name.toLowerCase())
)
return cityId;
}
return null;
}
/**
* Fetches configured camera sources from the backend.
* @returns {Promise<Object[]>} Array of raw source objects, or empty on failure.
*/
async function loadCameraSources() {
try {
const signal = layerState._sourceAbort?.signal;
const data = await source.getCatalog({ signal });
signal?.throwIfAborted();
if (!Array.isArray(data?.sources)) return [];
return data.sources;
} catch (error) {
if (error?.name === 'AbortError') throw error;
return [];
}
}
/**
* Merges raw backend sources with seed data to produce the final camera catalog.
* Seeds provide fallback values for heading, FOV, range, etc. when not specified
* by the source. Each camera is passed through ensureCameraPose.
* @param {Object[]} rawSources - Raw source objects from the backend.
* @returns {Object[]} Array of fully-initialized camera objects.
*/
function buildCatalogFromSources(rawSources) {
const sources = Array.isArray(rawSources) ? rawSources : [];
if (!sources.length) return [];
const seeded = seedCatalog();
const seedById = new Map(seeded.map((camera) => [camera.id, camera]));
const catalog = [];
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
const id = String(source.id || '').trim();
if (!id) continue;
const seed = seedById.get(id);
const cityId =
String(source.cityId || '').trim() ||
cityIdByName(source.city) ||
seed?.cityId ||
'';
const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null;
const lat = parts.model.safeNumber(source.lat, seed?.lat ?? NaN);
const lon = parts.model.safeNumber(source.lon, seed?.lon ?? NaN);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const sourceHeading = parts.model.safeNumber(source.headingDeg, NaN);
const headingDeg = parts.model.normalizeHeading(
Number.isFinite(sourceHeading)
? sourceHeading
: (seed?.headingDeg ?? parts.model.headingFromId(id)),
);
const fovDeg = parts.model.clamp(
parts.model.safeNumber(source.fovDeg, seed?.fovDeg ?? 74),
20,
125,
);
const rangeM = parts.model.clamp(
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
220,
2200,
);
const mountHeightM = parts.model.clamp(
parts.model.safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24),
6,
120,
);
const pitchDeg = parts.model.clamp(
parts.model.safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17),
-55,
-2,
);
const groundElevationM = parts.model.safeNumber(
source.groundElevationM,
city?.groundElevation ?? seed?.groundElevationM ?? 0,
);
const feedType = parts.model.normalizeFeedType(
source.feedType || source.type || 'image',
);
const headingConfidence = String(
source.headingConfidence || (seed ? 'high' : 'low'),
).toLowerCase();
// CAL badge input (design §3b passthrough): hand-authored file/env source
// entries may carry poseSource:'curated'. Austin Open Data rows never set
// this — they stay RAW PRIOR until a human manually calibrates them.
const poseSource =
source.poseSource === 'curated' ? 'curated' : seed?.poseSource || null;
const camera = {
id,
name: String(source.name || seed?.name || id),
cityId,
city: String(source.city || city?.name || seed?.city || 'Global'),
provider: String(
source.provider || seed?.provider || 'Configured CCTV Source',
),
sourceKind: String(
source.sourceKind ||
source.kind ||
(source.url ? 'configured' : 'seed'),
).toLowerCase(),
feedType,
feedConfigured: typeof source.url === 'string' && !!source.url.trim(),
lat,
lon,
headingDeg,
headingConfidence,
fovDeg,
rangeM,
mountHeightM,
groundElevationM,
absoluteHeightM: groundElevationM + mountHeightM,
pitchDeg,
license: String(source.license || source.licenseNote || ''),
poseSource,
};
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
return catalog;
}
return {
seedCatalog,
cityIdByName,
loadCameraSources,
buildCatalogFromSources,
};
}