mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor(fires): separate source, rendering and interaction components
This commit is contained in:
1 parent
e99cc8cf68
commit
6a9f13eab8
28 files changed
+2540
-1731
No files matched your search
+9
-87
@@ -1,87 +1,9 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
// The fix rides the height-datum ship's machinery end to end: anchors read
|
||||
// the SAME shared ground floor every other ground-adjacent consumer uses
|
||||
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
|
||||
// cell), and cold cells warm through the same batched, chunked, cached
|
||||
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
|
||||
// fires are static, and floor cells latch warm for the session, so each
|
||||
// rendered detection costs at most one DEM lookup EVER — and detections
|
||||
// sharing a ~111 m coarse cell share that lookup.
|
||||
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
|
||||
|
||||
/** @constant {number} Metres above the resolved floor for a fire anchor.
|
||||
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
|
||||
* a few metres of lift biases "slightly above the visible surface" (owner
|
||||
* principle) without a visible hover on the screen-sized sprite. */
|
||||
export const FIRE_ANCHOR_LIFT_M = 5;
|
||||
|
||||
/**
|
||||
* Synchronous anchor height for one detection: shared ground floor + lift
|
||||
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
|
||||
* exactly as before until their floor lands). Warm-cache read only; never
|
||||
* triggers network or sampling.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number} Ellipsoidal anchor height in metres.
|
||||
*/
|
||||
export function fireAnchorHeight(lat, lon) {
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
|
||||
}
|
||||
|
||||
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
|
||||
* sequentially so overlapping renders can't stack concurrent requests on
|
||||
* the single dev-server proxy (same courtesy as terrainHeights' sequential
|
||||
* chunking). Each queued batch re-filters against the warm cache when it
|
||||
* actually runs, so cells resolved by an earlier batch are never refetched. */
|
||||
let _chain = null;
|
||||
|
||||
/**
|
||||
* Batched warm of the ground-floor cells behind a rendered detection set.
|
||||
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
|
||||
* one requested point actually gained a warm floor — a failed resolve (proxy
|
||||
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
|
||||
* `false`, so a render → warm → re-render chain terminates instead of
|
||||
* looping; the next natural rebuild retries and the cache self-heals.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
|
||||
* @returns {Promise<boolean>} Whether any requested point warmed.
|
||||
*/
|
||||
export function warmFireAnchorFloors(points) {
|
||||
const cold = collectCold(points);
|
||||
if (!cold.length) return Promise.resolve(false);
|
||||
const prev = _chain;
|
||||
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
|
||||
_chain = run.then(() => true, () => false);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
|
||||
function collectCold(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
const cold = [];
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
|
||||
cold.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
return cold;
|
||||
}
|
||||
|
||||
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
|
||||
async function resolveBatch(points) {
|
||||
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
|
||||
if (!cold.length) return false;
|
||||
try {
|
||||
await resolveGroundFloorCells(cold);
|
||||
} catch { /* resolver is best-effort and never throws; belt and braces */ }
|
||||
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
|
||||
}
|
||||
|
||||
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
|
||||
export function _resetFireAnchorsForTest() {
|
||||
_chain = null;
|
||||
}
|
||||
import { createFireAnchors } from '../layers/firms/anchors.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js';
|
||||
const anchors = createFireAnchors(groundFloor);
|
||||
export const {
|
||||
fireAnchorHeight,
|
||||
warmFireAnchorFloors,
|
||||
_resetFireAnchorsForTest,
|
||||
} = anchors;
|
||||
+45
-1602
File diff suppressed because it is too large.
Load diff
@@ -9,11 +9,11 @@ import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from
|
||||
import { fireDetectionKey } from './firmsLabels.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import fs from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LAYER_SOURCE = fs.readFileSync(
|
||||
const LAYER_SOURCE = readLayerSource(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
|
||||
'utf8',
|
||||
);
|
||||
@@ -40,7 +40,7 @@ function makeFire(overrides = {}) {
|
||||
*/
|
||||
function harness({
|
||||
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
|
||||
withDataSource = false,
|
||||
withDataSource = false, feed = null,
|
||||
} = {}) {
|
||||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||||
const priorWindow = globalThis.window;
|
||||
@@ -58,6 +58,7 @@ function harness({
|
||||
const published = [];
|
||||
let handler = null;
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
...(feed ? { feed } : {}),
|
||||
id: 'firms-test',
|
||||
name: 'Fires',
|
||||
overlayHost: {
|
||||
@@ -349,3 +350,21 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
|
||||
let resolve, signal;
|
||||
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
|
||||
signal = options.signal; return new Promise(done => { resolve = done; });
|
||||
} } });
|
||||
try {
|
||||
const before = h.layer.getStats();
|
||||
const pending = h.layer.update();
|
||||
h.layer.disable();
|
||||
assert.equal(signal.aborted, true);
|
||||
resolve({ fires: [], fetchedAt: Date.now() });
|
||||
await pending;
|
||||
assert.equal(h.layer.getStats().count, before.count);
|
||||
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
|
||||
assert.equal(h.layer.getStats().loading, false);
|
||||
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
||||
});
|
||||
@@ -1556,7 +1556,7 @@ test('production eviction sites actually tag their clears', () => {
|
||||
// whose registration sweep deletes the record the clear needs to see.
|
||||
const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url));
|
||||
const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });');
|
||||
const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);');
|
||||
const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);');
|
||||
assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction');
|
||||
assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding');
|
||||
assert.ok(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
|
||||
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
|
||||
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
||||
assert.match(
|
||||
createFirmsHeatmapLayer.toString(),
|
||||
/registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/,
|
||||
createFirmsRendering.toString(),
|
||||
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
|
||||
'lazy FIRMS registration must restore order immediately',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user