Files
gods-eye-view/src/data/cctvFootprint.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00

199 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
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}
*/
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)}`;
}