mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
567 lines
19 KiB
JavaScript
567 lines
19 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
|
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
|
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
|
|
|
|
export function createModel({ state: layerState, services, parts, source }) {
|
|
const { focusPassIsNeeded, getFocusTarget } = services.focus;
|
|
|
|
/**
|
|
* Converts degrees to radians.
|
|
* @param {number} deg
|
|
* @returns {number}
|
|
*/
|
|
|
|
function toRad(deg) {
|
|
return Cesium.Math.toRadians(deg);
|
|
}
|
|
|
|
/**
|
|
* Normalizes a heading angle to the [0, 360) range.
|
|
* @param {number} deg
|
|
* @returns {number}
|
|
*/
|
|
|
|
function normalizeHeading(deg) {
|
|
let v = deg % 360;
|
|
if (v < 0) v += 360;
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* Clamps a value to [min, max].
|
|
* @param {number} value
|
|
* @param {number} min
|
|
* @param {number} max
|
|
* @returns {number}
|
|
*/
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
/**
|
|
* Normalizes an angle to the (-180, 180] range.
|
|
* @param {number} deg
|
|
* @returns {number}
|
|
*/
|
|
|
|
function normalizeSignedAngle(deg) {
|
|
let value = deg % 360;
|
|
if (value > 180) value -= 360;
|
|
if (value <= -180) value += 360;
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute angular difference between two headings in degrees.
|
|
* @param {number} aDeg
|
|
* @param {number} bDeg
|
|
* @returns {number} Value in [0, 180].
|
|
*/
|
|
|
|
function angularDeltaAbs(aDeg, bDeg) {
|
|
return Math.abs(normalizeSignedAngle(aDeg - bDeg));
|
|
}
|
|
|
|
/**
|
|
* Normalizes a raw feed-type string to a canonical type (image, mjpeg, mp4, hls, webm).
|
|
* @param {string|*} value - Raw feed type from source config.
|
|
* @returns {string} Canonical feed type.
|
|
*/
|
|
|
|
function normalizeFeedType(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return 'image';
|
|
if (raw === 'mjpg') return 'mjpeg';
|
|
if (raw === 'jpeg') return 'image';
|
|
if (raw === 'jpg') return 'image';
|
|
if (raw === 'video') return 'mp4';
|
|
if (raw === 'stream') return 'hls';
|
|
if (raw === 'png') return 'image';
|
|
if (raw === 'gif') return 'image';
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the feed type requires a <video> element rather than an <img>.
|
|
* @param {string} feedType
|
|
* @returns {boolean}
|
|
*/
|
|
|
|
function isVideoFeedType(feedType) {
|
|
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
|
|
}
|
|
|
|
/**
|
|
* Coerces a value to a finite number or returns the fallback.
|
|
* @param {*} value
|
|
* @param {number} [fallback=NaN]
|
|
* @returns {number}
|
|
*/
|
|
|
|
function safeNumber(value, fallback = NaN) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
/**
|
|
* Derives a deterministic heading from a camera ID string via a simple hash.
|
|
* Produces one of 16 evenly-spaced headings (0, 22.5, 45, ..., 337.5).
|
|
* @param {string} id
|
|
* @returns {number} Heading in degrees [0, 360).
|
|
*/
|
|
|
|
function headingFromId(id) {
|
|
const text = String(id || '');
|
|
let acc = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
acc = (acc * 33 + text.charCodeAt(i)) >>> 0;
|
|
}
|
|
return normalizeHeading((acc % 16) * 22.5);
|
|
}
|
|
|
|
/**
|
|
* Rounds a value to the nearest multiple of `step`.
|
|
* @param {number} value
|
|
* @param {number} [step=0.1]
|
|
* @returns {number}
|
|
*/
|
|
|
|
function quantize(value, step = 0.1) {
|
|
return Math.round(value / step) * step;
|
|
}
|
|
|
|
/**
|
|
* Normalizes a coverage-mode request (viewshed design §3b). Accepts the three
|
|
* mode strings plus booleans for `setParams({showCoverage})` back-compat
|
|
* (true → 'on', false → 'off'); anything else keeps the current mode.
|
|
* @param {*} value - Requested mode ('off'|'on'|'viewshed') or boolean.
|
|
* @param {'off'|'on'|'viewshed'} current - Mode to keep when the request is invalid.
|
|
* @returns {'off'|'on'|'viewshed'}
|
|
*/
|
|
|
|
function normalizeCoverageMode(value, current) {
|
|
if (value === true) return 'on';
|
|
if (value === false) return 'off';
|
|
if (value === 'off' || value === 'on' || value === 'viewshed') return value;
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Converts north/east metre offsets to lat/lon degree deltas at a given latitude.
|
|
* Uses the equirectangular approximation (111320 m/deg).
|
|
* @param {number} latDeg - Reference latitude (degrees).
|
|
* @param {number} northMeters - Offset northward (metres).
|
|
* @param {number} eastMeters - Offset eastward (metres).
|
|
* @returns {{ latOffset: number, lonOffset: number }} Degree deltas.
|
|
*/
|
|
|
|
function offsetDegrees(latDeg, northMeters, eastMeters) {
|
|
const latOffset = northMeters / 111320;
|
|
const lonDivisor = Math.max(0.15, Math.cos(toRad(latDeg)));
|
|
const lonOffset = eastMeters / (111320 * lonDivisor);
|
|
return { latOffset, lonOffset };
|
|
}
|
|
|
|
/**
|
|
* Returns `window.localStorage` when it is safely accessible, else null.
|
|
* Split out so store IO can be exercised under plain node:test with an
|
|
* injected storage-like object (getItem/setItem/removeItem) instead.
|
|
* @returns {Storage|null}
|
|
*/
|
|
|
|
function safeWindowLocalStorage() {
|
|
if (typeof window === 'undefined') return null;
|
|
try {
|
|
// NB: the window.localStorage property ACCESS itself throws SecurityError
|
|
// under "block all cookies", so it has to live inside the try (M11).
|
|
return window.localStorage || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes or recomputes a camera's derived pose fields from its base pose
|
|
* and calibration offsets. Also sets intrinsics, extrinsics, and anchor.
|
|
*
|
|
* On first call for a camera, captures the raw values as `basePose`.
|
|
* Subsequent calls re-derive lat/lon/heading/pitch/fov/range by applying
|
|
* calibration deltas to the frozen base pose.
|
|
*
|
|
* Note: the old score-based quality system (`confidenceFromScore`, seeded
|
|
* `camera.quality.score`) is retired — panel trust signal is now the 3-state
|
|
* CAL badge (`deriveCalBadge`, driven by `calSource`/`poseSource`), not a
|
|
* fabricated confidence score.
|
|
*
|
|
* @param {Object} camera - Mutable camera record.
|
|
*/
|
|
|
|
function ensureCameraPose(camera) {
|
|
if (!camera) return;
|
|
if (!camera.basePose) {
|
|
camera.basePose = {
|
|
lat: safeNumber(camera.lat, 0),
|
|
lon: safeNumber(camera.lon, 0),
|
|
headingDeg: normalizeHeading(safeNumber(camera.headingDeg, 0)),
|
|
pitchDeg: clamp(safeNumber(camera.pitchDeg, -17), -70, 10),
|
|
fovDeg: clamp(safeNumber(camera.fovDeg, 74), 20, 130),
|
|
rangeM: clamp(safeNumber(camera.rangeM, 700), 120, 5000),
|
|
mountHeightM: clamp(safeNumber(camera.mountHeightM, 24), 2, 240),
|
|
};
|
|
}
|
|
|
|
const nextCalibration = parts.calibration.normalizeCalibration(
|
|
camera.calibration || DEFAULT_CAMERA_CALIBRATION,
|
|
);
|
|
camera.calibration = nextCalibration;
|
|
|
|
const base = camera.basePose;
|
|
const offsets = offsetDegrees(
|
|
base.lat,
|
|
nextCalibration.offsetNorthM,
|
|
nextCalibration.offsetEastM,
|
|
);
|
|
camera.lat = base.lat + offsets.latOffset;
|
|
camera.lon = base.lon + offsets.lonOffset;
|
|
camera.headingDeg = normalizeHeading(
|
|
base.headingDeg + nextCalibration.headingDeg,
|
|
);
|
|
camera.pitchDeg = clamp(base.pitchDeg + nextCalibration.pitchDeg, -70, 10);
|
|
camera.fovDeg = clamp(base.fovDeg + nextCalibration.fovDeg, 20, 130);
|
|
camera.rangeM = clamp(base.rangeM * nextCalibration.rangeScale, 120, 5000);
|
|
camera.mountHeightM = clamp(
|
|
base.mountHeightM + nextCalibration.heightM,
|
|
2,
|
|
240,
|
|
);
|
|
|
|
camera.intrinsics = {
|
|
fovDeg: camera.fovDeg,
|
|
principalPoint: [0.5, 0.5],
|
|
};
|
|
camera.extrinsics = {
|
|
headingDeg: camera.headingDeg,
|
|
pitchDeg: camera.pitchDeg,
|
|
rollDeg: 0,
|
|
heightM: camera.mountHeightM,
|
|
};
|
|
camera.anchor = {
|
|
lat: camera.lat,
|
|
lon: camera.lon,
|
|
elevM: safeNumber(camera.groundElevationM, 0),
|
|
targetLatLon: camera.anchor?.targetLatLon || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Projects a point along a bearing from a given lat/lon by a distance.
|
|
* Uses the spherical-earth direct geodesic formula (R = 6371 km).
|
|
* @param {number} latDeg - Origin latitude (degrees).
|
|
* @param {number} lonDeg - Origin longitude (degrees).
|
|
* @param {number} bearingDeg - Azimuth from north (degrees).
|
|
* @param {number} distanceM - Distance in metres.
|
|
* @returns {{ lat: number, lon: number }} Destination in degrees.
|
|
*/
|
|
|
|
function projectPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
|
const angular = distanceM / 6371000;
|
|
const bearing = toRad(bearingDeg);
|
|
const lat1 = toRad(latDeg);
|
|
const lon1 = toRad(lonDeg);
|
|
|
|
const sinLat2 =
|
|
Math.sin(lat1) * Math.cos(angular) +
|
|
Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing);
|
|
const lat2 = Math.asin(sinLat2);
|
|
|
|
const y = Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1);
|
|
const x = Math.cos(angular) - Math.sin(lat1) * sinLat2;
|
|
const lon2 = lon1 + Math.atan2(y, x);
|
|
|
|
return {
|
|
lat: Cesium.Math.toDegrees(lat2),
|
|
lon: Cesium.Math.toDegrees(lon2),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computes the great-circle distance between two points using the haversine formula.
|
|
* @param {number} lat1 - Latitude of point 1 (degrees).
|
|
* @param {number} lon1 - Longitude of point 1 (degrees).
|
|
* @param {number} lat2 - Latitude of point 2 (degrees).
|
|
* @param {number} lon2 - Longitude of point 2 (degrees).
|
|
* @returns {number} Distance in kilometres.
|
|
*/
|
|
|
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLon = toRad(lon2 - lon1);
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
/**
|
|
* Computes the area of a circular sector (camera FOV wedge).
|
|
* @param {number} rangeM - Radius in metres.
|
|
* @param {number} fovDeg - Field of view in degrees.
|
|
* @returns {number} Area in km^2.
|
|
*/
|
|
|
|
function sectorAreaKm2(rangeM, fovDeg) {
|
|
const theta = toRad(clamp(fovDeg, 12, 170));
|
|
const areaM2 = 0.5 * rangeM * rangeM * theta;
|
|
return areaM2 / 1_000_000;
|
|
}
|
|
|
|
/**
|
|
* Returns a coarse grid key describing the viewer's current position and zoom
|
|
* level. Used to detect meaningful view changes for auto-hop camera switching.
|
|
* @returns {string} Grid key in the form "zoomBucket:latGrid:lonGrid".
|
|
*/
|
|
|
|
function currentViewContext() {
|
|
const carto = layerState._viewer?.camera?.positionCartographic;
|
|
if (!carto) return 'none';
|
|
const lat = Cesium.Math.toDegrees(carto.latitude);
|
|
const lon = Cesium.Math.toDegrees(carto.longitude);
|
|
const alt = carto.height || 0;
|
|
const zoomBucket =
|
|
alt < 1500
|
|
? 'street'
|
|
: alt < 12000
|
|
? 'city'
|
|
: alt < 75000
|
|
? 'regional'
|
|
: 'global';
|
|
const grid =
|
|
zoomBucket === 'street'
|
|
? 0.045
|
|
: zoomBucket === 'city'
|
|
? 0.24
|
|
: zoomBucket === 'regional'
|
|
? 1.0
|
|
: 4.5;
|
|
return `${zoomBucket}:${Math.floor(lat / grid)}:${Math.floor(lon / grid)}`;
|
|
}
|
|
|
|
/**
|
|
* Reports whether the active Google Photorealistic 3D Tileset (if any) has
|
|
* finished loading the tiles in view. Shared mesh-floor sampling is gated on
|
|
* this so a one-shot cell never bakes in a miss from still-streaming tiles.
|
|
* Discovers + caches the tileset lazily from scene
|
|
* primitives (the CCTV module holds only a `_viewer` reference). When no
|
|
* tileset is present (OSM fallback) this returns true so ground sampling is
|
|
* not permanently blocked.
|
|
*
|
|
* Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`,
|
|
* i.e. a globe stack is active) must NOT report ready — Cesium 1.138's
|
|
* the shared sampler can only inspect *visible* 3D tilesets, so a sample taken
|
|
* against the hidden Google tileset would silently miss.
|
|
*
|
|
* @returns {boolean} True when tiles are loaded AND visible (or no tileset
|
|
* exists to wait on).
|
|
*/
|
|
|
|
function projectionTilesReady() {
|
|
if (
|
|
!layerState._activeTileset ||
|
|
layerState._activeTileset.isDestroyed?.()
|
|
) {
|
|
layerState._activeTileset = null;
|
|
const primitives = layerState._viewer?.scene?.primitives;
|
|
if (primitives && typeof primitives.get === 'function') {
|
|
for (let i = 0; i < primitives.length; i++) {
|
|
const p = primitives.get(i);
|
|
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
|
|
layerState._activeTileset = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!layerState._activeTileset) return true;
|
|
if (layerState._activeTileset.show === false) return false;
|
|
return layerState._activeTileset.tilesLoaded === true;
|
|
}
|
|
|
|
/**
|
|
* Orientation quaternion for the monitor plane entity: local +Z is the plane
|
|
* normal, pointing BACK along the view axis toward the mount so the textured
|
|
* front face reads correctly from the natural viewpoint (focusCamera flies the
|
|
* viewer to look along the camera heading). Local +X = viewer-right, +Y =
|
|
* frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry —
|
|
* computed only on slider/save/activation, never per frame (§2b).
|
|
* @param {Object} camera - Camera pose.
|
|
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF.
|
|
* @returns {Cesium.Quaternion}
|
|
*/
|
|
|
|
function planeOrientationFor(camera, capCenterPos) {
|
|
const frame = parts.geometry.frustumFrameEcef(camera, capCenterPos);
|
|
const right = Cesium.Cartesian3.cross(
|
|
frame.dir,
|
|
frame.up,
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
const normal = Cesium.Cartesian3.negate(frame.dir, new Cesium.Cartesian3());
|
|
const m = new Cesium.Matrix3(
|
|
right.x,
|
|
frame.up.x,
|
|
normal.x,
|
|
right.y,
|
|
frame.up.y,
|
|
normal.y,
|
|
right.z,
|
|
frame.up.z,
|
|
normal.z,
|
|
);
|
|
return Cesium.Quaternion.fromRotationMatrix(m);
|
|
}
|
|
|
|
/**
|
|
* Starts the requestAnimationFrame loop that drives projection canvas updates
|
|
* (frame draw + texture swap) for the active camera.
|
|
*/
|
|
/**
|
|
* The projection rAF has real work only while a camera is actively projected
|
|
* or a focus fade is in flight — otherwise it burned a wakeup + style poll
|
|
* every rendered frame for the whole enabled lifetime of the layer. The tick
|
|
* self-stops when idle; every state edge that creates work re-arms it
|
|
* (enable, setActiveCamera, showProjection, focus-target appearance).
|
|
* (perf wave 1)
|
|
* @returns {boolean} Whether the loop currently has work.
|
|
*/
|
|
|
|
function projectionLoopIsNeeded() {
|
|
if (!layerState._enabled || !layerState._viewer) return false;
|
|
if (layerState._showProjection && parts.selection.getActiveRecord())
|
|
return true;
|
|
return focusPassIsNeeded(
|
|
getFocusTarget(),
|
|
layerState._activeFocusStyleCount,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per
|
|
* tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill
|
|
* — any selected card still missing its FIRST frame — bursts up to 4
|
|
* in-flight fetches at 250 ms spacing so arriving in a new area populates
|
|
* in a few seconds instead of 16-32 s; once every selected card has a first
|
|
* frame the layer drops back to the salvaged steady-state gate (single
|
|
* flight, one request per second). Priority: frameless cards first in ring
|
|
* order (nearest-first — they're what makes a card appear at all), then the
|
|
* stalest refresh-overdue card by its source cadence (staticFrameRefreshMs).
|
|
* Failures back off per camera (frameFetchDue) instead of hammering a dead
|
|
* source, so a dead upstream never eats the burst slots.
|
|
*/
|
|
|
|
function cardFrameTick() {
|
|
if (
|
|
!layerState._enabled ||
|
|
(!layerState._cardIds.size &&
|
|
!(layerState._activeCameraCardEnabled && layerState._activeCameraId))
|
|
)
|
|
return;
|
|
const now = Date.now();
|
|
let frameless = null;
|
|
let stalest = null;
|
|
let coldFill = false;
|
|
const consider = (id) => {
|
|
const record = layerState._recordById.get(id);
|
|
if (!record) return;
|
|
const slot = parts.cards.ensureCardFrameSlot(id);
|
|
if (layerState._cardFetchPendingIds.has(id)) {
|
|
// An in-flight first-frame fetch keeps cold-fill mode active without
|
|
// being re-launchable.
|
|
if (!(slot.stamp > 0)) coldFill = true;
|
|
return;
|
|
}
|
|
const refreshMs = staticFrameRefreshMs(record.camera);
|
|
if (!frameFetchDue(slot, refreshMs, now)) return;
|
|
if (!(slot.stamp > 0)) {
|
|
coldFill = true;
|
|
if (!frameless) frameless = { record, slot, refreshMs };
|
|
return;
|
|
}
|
|
if (!stalest || slot.stamp < stalest.slot.stamp) {
|
|
stalest = { record, slot, refreshMs };
|
|
}
|
|
};
|
|
// The optional protected active card stays outside the 40-card ambient
|
|
// quota and uses the same source-owned pacing/retry/cache lifecycle.
|
|
if (layerState._activeCameraCardEnabled && layerState._activeCameraId)
|
|
consider(layerState._activeCameraId);
|
|
for (const id of layerState._cardIds) consider(id);
|
|
const policy = cardFetchPolicy({
|
|
// The cold-fill burst yields to the staggered geometry drain: 4 concurrent
|
|
// image fetch+decodes mid-drain starve the mesh-floor queue on weak GPUs
|
|
// (qa-cctv-v2 drain-budget regression). Steady 1/s trickle still runs;
|
|
// the burst fires the moment the drain completes.
|
|
coldFill: coldFill && !layerState._geoLoading,
|
|
inFlight: layerState._cardFetchInFlightCount,
|
|
sinceLastLaunchMs:
|
|
layerState._cardLastFetchAt > 0
|
|
? now - layerState._cardLastFetchAt
|
|
: Infinity,
|
|
});
|
|
layerState._cardFetchMode = policy.mode;
|
|
if (!policy.launch) return;
|
|
const pick = frameless || stalest;
|
|
if (pick)
|
|
parts.cards.fetchCardFrame(pick.record, pick.slot, pick.refreshMs);
|
|
}
|
|
|
|
/**
|
|
* Hidden-state gate (perf wave 2): detach in-flight card frame decodes when
|
|
* the document hides — a hidden canvas has no reader, and image decode is
|
|
* the expensive half. New fetches are gated at fetchCardFrame; the steady
|
|
* pacer refills naturally on return. Owned by the initialized layer and detached on destruction.
|
|
*/
|
|
|
|
/**
|
|
* Reports whether selecting a record must run the full activation path.
|
|
* Disable re-arms the still-active record so its obstruction probe runs again
|
|
* on its next real activation after the temporary clamp is cleared.
|
|
* @param {string} cameraId Requested camera ID.
|
|
* @param {string|null} activeCameraId Current active camera ID.
|
|
* @param {Object|null} record Requested camera runtime record.
|
|
* @returns {boolean} Whether activation work must run.
|
|
*/
|
|
|
|
function cctvRecordNeedsActivation(cameraId, activeCameraId, record) {
|
|
return cameraId !== activeCameraId || record?.activationDone !== true;
|
|
}
|
|
return {
|
|
toRad,
|
|
normalizeHeading,
|
|
clamp,
|
|
normalizeSignedAngle,
|
|
angularDeltaAbs,
|
|
normalizeFeedType,
|
|
isVideoFeedType,
|
|
safeNumber,
|
|
headingFromId,
|
|
quantize,
|
|
normalizeCoverageMode,
|
|
offsetDegrees,
|
|
safeWindowLocalStorage,
|
|
ensureCameraPose,
|
|
projectPoint,
|
|
haversineKm,
|
|
sectorAreaKm2,
|
|
currentViewContext,
|
|
projectionTilesReady,
|
|
planeOrientationFor,
|
|
projectionLoopIsNeeded,
|
|
cardFrameTick,
|
|
cctvRecordNeedsActivation,
|
|
};
|
|
}
|