mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
The catalog now passes the gnss-integrity source's windowMs to the layer. A missing, non-finite or non-positive value keeps the 30-minute default. The row text now names the window the layer uses instead of a fixed 30 min.
443 lines
15 KiB
JavaScript
443 lines
15 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
import { formatNumber, msg, t } from '../../i18n/index.js';
|
||
import { translateFeedError } from '../../ui/statusText.js';
|
||
import {
|
||
GNSS_LEVEL_COLORS,
|
||
GNSS_MIN_AIRCRAFT,
|
||
GNSS_WINDOW_MS,
|
||
accumulateGnssObservations,
|
||
binGnssCells,
|
||
gnssProvenance,
|
||
pruneGnssObservations,
|
||
} from './records.js';
|
||
export * from './records.js';
|
||
export { createAdsbGnssSource } from './source.js';
|
||
|
||
/**
|
||
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
|
||
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
|
||
* This fill participates only in rendering, so sibling polygons remain pickable.
|
||
*/
|
||
class GnssGroundFill extends Cesium.GroundPrimitive {
|
||
update(frameState) {
|
||
if (!frameState.passes.pick) super.update(frameState);
|
||
}
|
||
}
|
||
|
||
/** Low stays visible over land so coverage reads apart from no data. */
|
||
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
|
||
const LEVEL_LABELS = Object.freeze({
|
||
low: msg('Low-accuracy score < 2%'),
|
||
medium: msg('Low-accuracy score 2–10%'),
|
||
high: msg('Low-accuracy score > 10%'),
|
||
});
|
||
|
||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||
export function defaultGnssViewAnchor(viewer) {
|
||
const canvas = viewer?.scene?.canvas;
|
||
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
|
||
// At globe scale the visible rectangle can be the whole world, whose
|
||
// midpoint is unrelated to the place under the screen centre.
|
||
const centre =
|
||
canvas?.clientWidth > 0 &&
|
||
canvas.clientHeight > 0 &&
|
||
viewer.camera?.pickEllipsoid?.(
|
||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||
ellipsoid,
|
||
);
|
||
const rectangle =
|
||
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
|
||
const carto = centre
|
||
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
|
||
: rectangle
|
||
? Cesium.Rectangle.center(rectangle)
|
||
: viewer?.camera?.positionCartographic;
|
||
if (!carto) return null;
|
||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
||
return Number.isFinite(latitude) && Number.isFinite(longitude)
|
||
? { latitude, longitude }
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* Own one GNSS navigation-integrity display: rolling integrity observations
|
||
* around the views the user visits, binned into cells coloured by the
|
||
* gpsjam low-accuracy score. Share links retain token 3.
|
||
*/
|
||
export function createGnssIntegrityLayer({
|
||
source,
|
||
viewAnchor = defaultGnssViewAnchor,
|
||
now = () => Date.now(),
|
||
monotonicNow = () => performance.now(),
|
||
windowMs: requestedWindowMs,
|
||
} = {}) {
|
||
if (typeof source?.getSnapshot !== 'function')
|
||
throw new TypeError('GNSS integrity layer requires a snapshot source');
|
||
// A source may set its own evidence window; anything but a positive finite
|
||
// duration keeps the default.
|
||
const windowMs =
|
||
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
|
||
? requestedWindowMs
|
||
: GNSS_WINDOW_MS;
|
||
let _viewer = null;
|
||
let _primitives = null;
|
||
let _request = null;
|
||
let _enabled = false;
|
||
let _cells = [];
|
||
let _signature = null;
|
||
let _lastUpdate = null;
|
||
let _lastError = null;
|
||
let _stale = false;
|
||
let _manager = null;
|
||
let _cameraTimer = null;
|
||
let _backgroundRefresh = null;
|
||
let _lastQueryKey = null;
|
||
let _lastRequestAt = -Infinity;
|
||
let _removeCameraEnd = null;
|
||
let _removeCameraChanged = null;
|
||
let _coverage = null;
|
||
let _retryTimer = null;
|
||
let _retryAt = 0;
|
||
let _failureCount = 0;
|
||
const _observations = new Map();
|
||
const provenance = gnssProvenance({ windowMs });
|
||
|
||
const queryKey = (anchor) =>
|
||
anchor
|
||
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
|
||
: null;
|
||
|
||
function scheduleCameraRefresh() {
|
||
if (!_enabled || !_primitives) return;
|
||
if (now() < _retryAt) return;
|
||
const key = queryKey(viewAnchor(_viewer));
|
||
if (!key || key === _lastQueryKey) return;
|
||
clearTimeout(_cameraTimer);
|
||
// Debounce drags and bound moving/follow cameras to at most 24 requests
|
||
// per minute. Nearby views share the proxy's whole-degree cache key.
|
||
_cameraTimer = setTimeout(
|
||
async () => {
|
||
_cameraTimer = null;
|
||
if (!_enabled || _backgroundRefresh || _request) return;
|
||
const key = queryKey(viewAnchor(_viewer));
|
||
if (!key || key === _lastQueryKey) return;
|
||
await refreshInBackground();
|
||
},
|
||
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
|
||
);
|
||
}
|
||
|
||
async function refreshInBackground() {
|
||
if (!_enabled || _backgroundRefresh) return;
|
||
const controller = new AbortController();
|
||
_backgroundRefresh = controller;
|
||
try {
|
||
if (_manager)
|
||
await _manager.refreshLayer(layer.id, { signal: controller.signal });
|
||
else await layer.update(undefined, { signal: controller.signal });
|
||
} finally {
|
||
if (_backgroundRefresh === controller) {
|
||
_backgroundRefresh = null;
|
||
scheduleCameraRefresh();
|
||
scheduleRetry();
|
||
}
|
||
}
|
||
}
|
||
|
||
function scheduleRetry() {
|
||
clearTimeout(_retryTimer);
|
||
_retryTimer = null;
|
||
if (!_enabled || !_retryAt) return;
|
||
// Long provider cooldowns keep their full deadline. Browser timers cannot
|
||
// exceed ~24 days, so wake in bounded chunks without fetching early.
|
||
_retryTimer = setTimeout(
|
||
() => {
|
||
_retryTimer = null;
|
||
void refreshInBackground();
|
||
},
|
||
Math.min(2147483647, Math.max(0, _retryAt - now())),
|
||
);
|
||
_retryTimer?.unref?.();
|
||
}
|
||
|
||
/** Age the store without new evidence, so expired cells leave the globe. */
|
||
function expire() {
|
||
pruneGnssObservations(_observations, now(), { windowMs });
|
||
_cells = binGnssCells(_observations);
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
// Only the id and band change what is drawn; counts alone do not.
|
||
const signature = JSON.stringify(
|
||
_cells.map(({ id, level }) => [id, level]),
|
||
);
|
||
if (signature === _signature) return;
|
||
_signature = signature;
|
||
_primitives.removeAll();
|
||
if (_cells.length) {
|
||
_primitives.add(
|
||
new GnssGroundFill({
|
||
geometryInstances: _cells.map(
|
||
(cell) =>
|
||
new Cesium.GeometryInstance({
|
||
id: `${layer.id}:${cell.id}`,
|
||
geometry: new Cesium.RectangleGeometry({
|
||
rectangle: Cesium.Rectangle.fromDegrees(
|
||
cell.west,
|
||
cell.south,
|
||
cell.east,
|
||
cell.north,
|
||
),
|
||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||
}),
|
||
attributes: {
|
||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||
Cesium.Color.fromCssColorString(
|
||
GNSS_LEVEL_COLORS[cell.level],
|
||
).withAlpha(FILL_ALPHA[cell.level]),
|
||
),
|
||
},
|
||
}),
|
||
),
|
||
appearance: new Cesium.PerInstanceColorAppearance({
|
||
flat: true,
|
||
translucent: true,
|
||
}),
|
||
classificationType: Cesium.ClassificationType.BOTH,
|
||
allowPicking: false,
|
||
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
|
||
releaseGeometryInstances: false,
|
||
}),
|
||
);
|
||
}
|
||
_viewer?.scene?.requestRender?.();
|
||
}
|
||
|
||
const layer = {
|
||
id: 'gnss-integrity',
|
||
name: 'GNSS Integrity',
|
||
icon: '📡',
|
||
source: 'adsb.lol',
|
||
/** Where each part of the method comes from; see records.js. */
|
||
provenance,
|
||
updateInterval: 60000,
|
||
|
||
attachDataManager(manager) {
|
||
_manager = manager;
|
||
},
|
||
|
||
init(viewer) {
|
||
if (_viewer)
|
||
throw new Error('GNSS integrity layer is already initialized');
|
||
_viewer = viewer;
|
||
_primitives = new Cesium.PrimitiveCollection();
|
||
_primitives.id = layer.id;
|
||
_primitives.show = false;
|
||
viewer.scene.groundPrimitives.add(_primitives);
|
||
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
|
||
scheduleCameraRefresh,
|
||
);
|
||
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
|
||
scheduleCameraRefresh,
|
||
);
|
||
},
|
||
|
||
enable() {
|
||
_enabled = true;
|
||
// Cells left from before a long disable must not reappear past the window.
|
||
if (_primitives) {
|
||
expire();
|
||
_primitives.show = true;
|
||
}
|
||
},
|
||
|
||
disable() {
|
||
clearTimeout(_cameraTimer);
|
||
_cameraTimer = null;
|
||
clearTimeout(_retryTimer);
|
||
_retryTimer = null;
|
||
_backgroundRefresh?.abort();
|
||
_backgroundRefresh = null;
|
||
_request?.abort();
|
||
_request = null;
|
||
_enabled = false;
|
||
if (_primitives) _primitives.show = false;
|
||
},
|
||
|
||
async update(_viewerArg, { signal } = {}) {
|
||
if (!_enabled || !_primitives || signal?.aborted) return false;
|
||
if (now() < _retryAt) {
|
||
expire();
|
||
scheduleRetry();
|
||
return true;
|
||
}
|
||
_retryAt = 0;
|
||
clearTimeout(_retryTimer);
|
||
_retryTimer = null;
|
||
const anchor = viewAnchor(_viewer);
|
||
// No anchor yet (camera still settling) is not a failure: returning
|
||
// false would make the manager reject the enable. The next interval
|
||
// retries; cells already drawn still age out of the window.
|
||
if (!anchor) {
|
||
expire();
|
||
return true;
|
||
}
|
||
_request?.abort();
|
||
const request = new AbortController();
|
||
_request = request;
|
||
const onAbort = () => request.abort(signal.reason);
|
||
signal?.addEventListener('abort', onAbort, { once: true });
|
||
_lastQueryKey = queryKey(anchor);
|
||
_lastRequestAt = monotonicNow();
|
||
_coverage = {
|
||
kind: 'regional',
|
||
latitude: Math.round(anchor.latitude),
|
||
longitude: Math.round(anchor.longitude),
|
||
radiusNm: 250,
|
||
};
|
||
let received = false;
|
||
try {
|
||
const snapshot = await source.getSnapshot(anchor, {
|
||
signal: request.signal,
|
||
});
|
||
if (request.signal.aborted || _request !== request || !_enabled)
|
||
return false;
|
||
received = true;
|
||
// Date the rows by when the proxy observed them: a cached or stale
|
||
// replay keeps its original age instead of restarting the window.
|
||
// The proxy reports that age, so client and server clocks never mix.
|
||
const at = now();
|
||
accumulateGnssObservations(_observations, snapshot.rows, at, {
|
||
windowMs,
|
||
observedAt: at - snapshot.ageMs,
|
||
});
|
||
_cells = binGnssCells(_observations);
|
||
render();
|
||
_stale = snapshot.stale === true;
|
||
_lastUpdate = at - snapshot.ageMs;
|
||
_lastError = null;
|
||
_retryAt = 0;
|
||
_failureCount = 0;
|
||
return true;
|
||
} catch (e) {
|
||
if (request.signal.aborted || _request !== request || !_enabled)
|
||
return false;
|
||
// A renderer/programming failure remains a real lifecycle failure.
|
||
// Only acquisition failures are kept enabled for recovery.
|
||
if (received) throw e;
|
||
console.warn('[Data:GNSS] Fetch error:', e);
|
||
_lastError = e?.message || msg('GNSS integrity source unavailable');
|
||
// Keep the last cells only while their evidence is inside the window.
|
||
expire();
|
||
if (e?.retryable === false) return false;
|
||
_failureCount = Math.min(3, _failureCount + 1);
|
||
const delay =
|
||
Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0
|
||
? e.retryAfterMs
|
||
: Math.min(120000, 30000 * 2 ** (_failureCount - 1));
|
||
_retryAt = Math.max(
|
||
_retryAt,
|
||
Math.min(Number.MAX_VALUE, now() + delay),
|
||
);
|
||
// Enabling the display succeeded. Stats still report an unavailable
|
||
// or degraded feed; the manager keeps its normal refresh failure lane.
|
||
return true;
|
||
} finally {
|
||
signal?.removeEventListener('abort', onAbort);
|
||
if (_request === request) _request = null;
|
||
scheduleCameraRefresh();
|
||
scheduleRetry();
|
||
}
|
||
},
|
||
|
||
destroy(viewer = _viewer) {
|
||
layer.disable();
|
||
_removeCameraEnd?.();
|
||
_removeCameraChanged?.();
|
||
_removeCameraEnd = null;
|
||
_removeCameraChanged = null;
|
||
_observations.clear();
|
||
_cells = [];
|
||
_signature = null;
|
||
if (_primitives) {
|
||
viewer.scene.groundPrimitives.remove(_primitives);
|
||
_primitives = null;
|
||
}
|
||
_viewer = null;
|
||
_lastUpdate = null;
|
||
_lastError = null;
|
||
_stale = false;
|
||
_coverage = null;
|
||
_retryAt = 0;
|
||
_failureCount = 0;
|
||
_lastQueryKey = null;
|
||
_manager = null;
|
||
},
|
||
|
||
getRowControls() {
|
||
return {
|
||
chips: [],
|
||
info:
|
||
t(
|
||
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
|
||
{ minutes: formatNumber(provenance.window.minutes) },
|
||
) +
|
||
(_lastError && _retryAt
|
||
? ` · ${translateFeedError(_lastError)} · ${
|
||
_retryAt <= 8640000000000000
|
||
? t('Retrying automatically at {time} UTC', {
|
||
time: new Date(_retryAt).toISOString().slice(11, 19),
|
||
})
|
||
: t('Retrying automatically after provider cooldown')
|
||
}`
|
||
: ''),
|
||
infoTitle: t(
|
||
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
|
||
),
|
||
legend: ['high', 'medium', 'low'].map((level, index) => ({
|
||
label: t(LEVEL_LABELS[level]),
|
||
color: GNSS_LEVEL_COLORS[level],
|
||
count: _cells.filter((cell) => cell.level === level).length,
|
||
...(index === 0
|
||
? {
|
||
blurb: t(
|
||
'Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.',
|
||
{
|
||
aircraft: formatNumber(GNSS_MIN_AIRCRAFT),
|
||
minutes: formatNumber(provenance.window.minutes),
|
||
},
|
||
),
|
||
}
|
||
: {}),
|
||
})),
|
||
};
|
||
},
|
||
|
||
getStats() {
|
||
return {
|
||
count: _cells.length,
|
||
levels: Object.fromEntries(
|
||
['low', 'medium', 'high'].map((level) => [
|
||
level,
|
||
_cells.filter((cell) => cell.level === level).length,
|
||
]),
|
||
),
|
||
lastUpdate: _lastUpdate,
|
||
error: _lastError,
|
||
retryInSec: _retryAt
|
||
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
|
||
: null,
|
||
stale: _stale,
|
||
provenance,
|
||
coverage: _coverage,
|
||
// Load-bearing: without an explicit boolean, layerFeedState reads the
|
||
// adsb.lol source name as a flights fallback (src/data/feedState.js).
|
||
fallback: false,
|
||
};
|
||
},
|
||
};
|
||
return layer;
|
||
}
|